diff --git a/.changeset/cuddly-rivers-bathe.md b/.changeset/cuddly-rivers-bathe.md new file mode 100644 index 00000000..16e728ff --- /dev/null +++ b/.changeset/cuddly-rivers-bathe.md @@ -0,0 +1,6 @@ +--- +'@featureboard/node-sdk': minor +'@featureboard/js-sdk': minor +--- + +Added native open telemetry integration diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 40d47125..42c9345f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -69,6 +69,10 @@ jobs: sed -i "s/.*<\/Version>/$version<\/Version>/" $csproj_path sed -i "s/.*<\/Copyright>/Copyright (c) Arkahna $currentYear<\/Copyright>/" $csproj_path + - name: Update Node/JS SDK version + run: | + pnpm nx run-many --target version --all + - name: Push changes run: | pnpm i -r --no-frozen-lockfile diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 94061435..28929ab3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -42,24 +42,4 @@ jobs: run: pnpm tsc -b - name: Lint, build and test - run: pnpm nx run-many --target build,lint,test --all --exclude examples-dotnet-api - - - name: Version command - id: version - run: | - npx changeset version - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Update DotNet SDK version and copyright year - run: | - version=$(node -pe "require('./libs/dotnet-sdk/package.json').version") - echo "Version: $version" - currentYear=$(node -pe "new Date().getFullYear()") - csproj_path=libs/dotnet-sdk/FeatureBoard.DotnetSdk.csproj - sed -i "s/.*<\/Version>/$version<\/Version>/" $csproj_path - sed -i "s/.*<\/Copyright>/Copyright (c) Arkahna $currentYear<\/Copyright>/" $csproj_path - cat $csproj_path - - - name: Package libs - run: pnpm package + run: pnpm nx run-many --target build,lint,test,package --all --exclude examples-dotnet-api diff --git a/.gitignore b/.gitignore index 7a056d15..b3c3ff71 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ tsconfig.tsbuildinfo **/src/**/*.js **/src/**/*.d.ts **/src/**/*.js.map +!**/src/**/schema.d.ts + +.env diff --git a/.vscode/settings.json b/.vscode/settings.json index 932308d5..2b62b3ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,10 +14,17 @@ "**/cypress/**", "**/.{idea,git,cache,output,temp}/**" ], - "eslint.validate": ["json"], - "cSpell.enableFiletypes": ["typescript"], - "cSpell.words": ["featureboard"], + "eslint.validate": [ + "json" + ], + "cSpell.enableFiletypes": [ + "typescript" + ], + "cSpell.words": [ + "fbsdk", + "featureboard" + ], "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } -} +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ff8da06..bc09c73e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,3 +15,11 @@ pnpm cli ``` `pnpm cli login` will login to the dev app registration by default. If you want to login to production FeatureBoard + +### Testing + +If you would like to test packages locally, you can run a local verdaccio registry. + +This can host the packages locally and allow you to test them in a target project. + +See [Use Verdaccio](docs/use-verdaccio.md) for more details. diff --git a/FeatureBoardSdks.sln.DotSettings b/FeatureBoardSdks.sln.DotSettings deleted file mode 100644 index 1477bfa8..00000000 --- a/FeatureBoardSdks.sln.DotSettings +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/README.md b/README.md index a622b695..124dd23a 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,15 @@ Go to [our website](https://featureboard.app) to find out more. ## Documentation Installation and usage instructions can be found on our [docs site](https://docs.featureboard.app). + +## Open Telemetry + +The FeatureBoard SDKs are instrumented with Open Telemetry natively, so you can easily integrate with your existing observability tools. + +### Unit tests + +To configure the SDK to use a local Open Telemetry Collector, set the following environment variables: + +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 + +You can put this in a `.env` file in the root of the project. diff --git a/docs/use-verdaccio.md b/docs/use-verdaccio.md new file mode 100644 index 00000000..8ef8aef2 --- /dev/null +++ b/docs/use-verdaccio.md @@ -0,0 +1,22 @@ +# How to use Verdaccio to test locally + +## Start Verdaccio + +```bash +nx local-registry +``` + +## Publish your package + +```bash +cd libs/js-sdk (or whatever package) +pnpm publish --registry http://localhost:4873/ --no-git-checks +``` + +## Install the package + +Now in your own project + +```bash +pnpm add @featureboard/js-sdk --registry http://localhost:4873/ +``` diff --git a/libs/js-sdk/.eslintrc.cjs b/libs/js-sdk/.eslintrc.cjs index 5090f6c2..663bb88e 100644 --- a/libs/js-sdk/.eslintrc.cjs +++ b/libs/js-sdk/.eslintrc.cjs @@ -14,5 +14,11 @@ module.exports = { }, }, ], - ignorePatterns: ['!**/*', 'node_modules', 'out-tsc'], + ignorePatterns: [ + '!**/*', + 'node_modules', + 'tsc-out', + 'vitest.config.ts', + 'test-setup.ts', + ], } diff --git a/libs/js-sdk/package.json b/libs/js-sdk/package.json index 08710bd6..6b538427 100644 --- a/libs/js-sdk/package.json +++ b/libs/js-sdk/package.json @@ -33,7 +33,16 @@ }, "dependencies": { "@featureboard/contracts": "workspace:*", - "debug": "^4.3.4", "promise-completion-source": "^1.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "devDependencies": { + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.45.1", + "@opentelemetry/sdk-node": "^0.45.1", + "@opentelemetry/sdk-trace-base": "^1.18.1", + "@opentelemetry/sdk-trace-node": "^1.18.1" } } diff --git a/libs/js-sdk/project.json b/libs/js-sdk/project.json index 4b568998..52c0134a 100644 --- a/libs/js-sdk/project.json +++ b/libs/js-sdk/project.json @@ -17,6 +17,13 @@ "cwd": "libs/js-sdk" } }, + "version": { + "executor": "nx:run-commands", + "options": { + "command": "echo \"export const version = '$(jq -r '.version' package.json)';\" > src/version.ts", + "cwd": "libs/js-sdk" + } + }, "package": { "executor": "nx:run-commands", "options": { @@ -28,7 +35,8 @@ ], "cwd": "libs/js-sdk", "parallel": false - } + }, + "dependsOn": ["version"] } } } diff --git a/libs/js-sdk/src/client-options.ts b/libs/js-sdk/src/client-options.ts new file mode 100644 index 00000000..9098e8cc --- /dev/null +++ b/libs/js-sdk/src/client-options.ts @@ -0,0 +1,9 @@ +export interface ClientOptions { + /** + * Controls whether the FeatureBoard SDK emits traces using OpenTelemetry. + * Set to `true` to disable trace emission. + * + * @default false + */ + disableOTel?: boolean +} diff --git a/libs/js-sdk/src/create-browser-client.ts b/libs/js-sdk/src/create-browser-client.ts index fcbda911..635950d9 100644 --- a/libs/js-sdk/src/create-browser-client.ts +++ b/libs/js-sdk/src/create-browser-client.ts @@ -1,15 +1,20 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' +import type { Span } from '@opentelemetry/api' +import { SpanStatusCode } from '@opentelemetry/api' import { PromiseCompletionSource } from 'promise-completion-source' import type { BrowserClient } from './client-connection' +import type { ClientOptions } from './client-options' import { createClientInternal } from './create-client' import { EffectiveFeatureStateStore } from './effective-feature-state-store' import type { FeatureBoardApiConfig } from './featureboard-api-config' import { featureBoardHostedService } from './featureboard-service-urls' -import { debugLog } from './log' import { resolveUpdateStrategy } from './update-strategies/resolveUpdateStrategy' import type { UpdateStrategies } from './update-strategies/update-strategies' import { compareArrays } from './utils/compare-arrays' +import { getTracer } from './utils/get-tracer' +import { resolveError } from './utils/resolve-error' import { retry } from './utils/retry' +import { setTracingEnabled } from './utils/trace-provider' /** * Create a FeatureBoard client for use in the browser @@ -22,6 +27,7 @@ export function createBrowserClient({ api, audiences, initialValues, + options, }: { /** Connect to a self hosted instance of FeatureBoard */ api?: FeatureBoardApiConfig @@ -40,30 +46,23 @@ export function createBrowserClient({ initialValues?: EffectiveFeatureValue[] environmentApiKey: string + + options?: ClientOptions }): BrowserClient { - const initialPromise = new PromiseCompletionSource() + // enable or disable OpenTelemetry, enabled by default + setTracingEnabled(!options || !options.disableOTel) + const tracer = getTracer() + + const waitingForInitialisation: Array> = [] + const initialisedCallbacks: Array<(initialised: boolean) => void> = [] + const initialisedState: { - initialisedCallbacks: Array<(initialised: boolean) => void> initialisedPromise: PromiseCompletionSource - initialisedError: Error | undefined + initialisedCancellationToken: { cancel: boolean } } = { - initialisedCallbacks: [], - initialisedPromise: initialPromise, - initialisedError: undefined, + initialisedPromise: new PromiseCompletionSource(), + initialisedCancellationToken: { cancel: false }, } - initialisedState.initialisedPromise.promise.then(() => { - // If the promise has changed, then we don't want to invoke the callback - if (initialPromise !== initialisedState.initialisedPromise) { - return - } - - // Get the value from the function, just incase it has changed - const initialised = isInitialised() - initialisedState.initialisedCallbacks.forEach((c) => c(initialised)) - }) - - // Ensure that the init promise doesn't cause an unhandled promise rejection - initialisedState.initialisedPromise.promise.catch(() => {}) const stateStore = new EffectiveFeatureStateStore(audiences, initialValues) @@ -73,145 +72,186 @@ export function createBrowserClient({ api || featureBoardHostedService, ) - const retryCancellationToken = { cancel: false } - retry(async () => { - debugLog('SDK connecting in background (%o)', { - audiences, - }) - return await updateStrategyImplementation.connect(stateStore) - }, retryCancellationToken) - .then(() => { + async function initializeWithAudiences( + initializeSpan: Span, + audiences: string[], + ) { + const initialPromise = new PromiseCompletionSource() + const cancellationToken = { cancel: false } + initialPromise.promise.catch(() => {}) + initialisedState.initialisedPromise = initialPromise + initialisedState.initialisedCancellationToken = cancellationToken + + try { + await retry(async () => { + if (cancellationToken.cancel) { + return + } + + return await updateStrategyImplementation.connect(stateStore) + }, cancellationToken) + } catch (error) { if (initialPromise !== initialisedState.initialisedPromise) { + initializeSpan.addEvent( + "Ignoring initialisation error as it's out of date", + ) + initializeSpan.end() return } + const err = resolveError(error) - if (!initialPromise.completed) { - debugLog('SDK connected (%o)', { - audiences, - }) - initialPromise.resolve(true) - } - }) - .catch((err) => { - if (!initialisedState.initialisedPromise.completed) { - debugLog( - 'SDK failed to connect (%o): %o', - { - audiences, - }, - err, - ) - console.error( - 'FeatureBoard SDK failed to connect after 5 retries', - err, - ) - initialisedState.initialisedError = err - initialisedState.initialisedPromise.resolve(true) - } - }) - const isInitialised = () => { - return initialisedState.initialisedPromise.completed + initializeSpan.setStatus({ + code: SpanStatusCode.ERROR, + }) + console.error( + 'FeatureBoard SDK failed to connect after 5 retries', + err, + ) + initialisedState.initialisedPromise.reject(err) + + waitingForInitialisation.forEach((w) => w.reject(err)) + waitingForInitialisation.length = 0 + initializeSpan.end() + return + } + + // Successfully completed + if (initialPromise !== initialisedState.initialisedPromise) { + initializeSpan.addEvent( + "Ignoring initialisation event as it's out of date", + ) + initializeSpan.end() + return + } + + initialisedState.initialisedPromise.resolve(true) + + notifyWaitingForInitialisation(initialisedCallbacks, initializeSpan) + waitingForInitialisation.forEach((w) => w.resolve(true)) + waitingForInitialisation.length = 0 + initializeSpan.end() } + void tracer.startActiveSpan( + 'fbsdk-connect-with-retry', + { + attributes: { + audiences, + updateStrategy: updateStrategyImplementation.name, + }, + }, + (connectWithRetrySpan) => + initializeWithAudiences(connectWithRetrySpan, audiences), + ) + return { client: createClientInternal(stateStore), get initialised() { - return isInitialised() + return initialisedState.initialisedPromise.completed }, waitForInitialised() { - return new Promise((resolve, reject) => { - const interval = setInterval(() => { - if (initialisedState.initialisedError) { - clearInterval(interval) - reject(initialisedState.initialisedError) - } else if (isInitialised()) { - clearInterval(interval) - resolve(true) - } - }, 100) - }) + if (initialisedState.initialisedPromise.completed) { + return initialisedState.initialisedPromise.promise + } + + const initialised = new PromiseCompletionSource() + waitingForInitialisation.push(initialised) + return initialised.promise }, subscribeToInitialisedChanged(callback) { - debugLog('Subscribing to initialised changed: %o', { - initialised: isInitialised(), - }) - - initialisedState.initialisedCallbacks.push(callback) + initialisedCallbacks.push(callback) return () => { - initialisedState.initialisedCallbacks.splice( - initialisedState.initialisedCallbacks.indexOf(callback), + initialisedCallbacks.splice( + initialisedCallbacks.indexOf(callback), 1, ) } }, async updateAudiences(updatedAudiences: string[]) { - if (compareArrays(stateStore.audiences, updatedAudiences)) { - debugLog('Skipped updating audiences, no change: %o', { - updatedAudiences, - currentAudiences: stateStore.audiences, - initialised: isInitialised(), - }) - // No need to update audiences - return Promise.resolve() - } - - debugLog('Updating audiences: %o', { - updatedAudiences, - currentAudiences: stateStore.audiences, - initialised: isInitialised(), - }) + await tracer.startActiveSpan( + 'fbsdk-update-audiences', + { + attributes: { + audiences, + updateStrategy: updateStrategyImplementation.name, + }, + }, + (updateAudiencesSpan) => { + try { + if ( + compareArrays( + stateStore.audiences, + updatedAudiences, + ) + ) { + updateAudiencesSpan.addEvent( + 'Skipped update audiences', + { + updatedAudiences, + currentAudiences: stateStore.audiences, + }, + ) - // Close connection and cancel retry - updateStrategyImplementation.close() - retryCancellationToken.cancel = true - - const newPromise = new PromiseCompletionSource() - initialisedState.initialisedPromise = newPromise - initialisedState.initialisedError = undefined - initialisedState.initialisedPromise.promise.catch(() => {}) - initialisedState.initialisedPromise.promise.then(() => { - // If the promise has changed, then we don't want to invoke the callback - if (newPromise !== initialisedState.initialisedPromise) { - return - } + // No need to update audiences + return Promise.resolve() + } - // Get the value from the function, just incase it has changed - const initialised = isInitialised() - initialisedState.initialisedCallbacks.forEach((c) => - c(initialised), - ) - }) - debugLog('updateAudiences: invoke initialised callback with false') - initialisedState.initialisedCallbacks.forEach((c) => c(false)) + // Close connection and cancel retry + updateStrategyImplementation.close() + initialisedState.initialisedCancellationToken.cancel = + true - stateStore.audiences = updatedAudiences - debugLog( - 'updateAudiences: Audiences updated (%o), getting new effective values', - updatedAudiences, + stateStore.audiences = updatedAudiences + return initializeWithAudiences( + updateAudiencesSpan, + updatedAudiences, + ) + } finally { + updateAudiencesSpan.end() + } + }, ) - - updateStrategyImplementation - .connect(stateStore) - .then(() => { - newPromise?.resolve(true) - debugLog('Audiences updated: %o', { - updatedAudiences, - currentAudiences: stateStore.audiences, - initialised: isInitialised(), - }) - }) - .catch((error) => { - console.error('Failed to connect to SDK', error) - initialisedState.initialisedError = error - newPromise?.resolve(true) - }) }, updateFeatures() { - return updateStrategyImplementation.updateFeatures() + return tracer.startActiveSpan( + 'fbsdk-update-features-manually', + (span) => { + try { + return updateStrategyImplementation + .updateFeatures() + .then(() => span.end()) + } finally { + span.end() + } + }, + ) }, close() { - retryCancellationToken.cancel = true + initialisedState.initialisedCancellationToken.cancel = true return updateStrategyImplementation.close() }, } } +function notifyWaitingForInitialisation( + initialisedCallbacks: ((initialised: boolean) => void)[], + initializeSpan: Span, +) { + const errors: Error[] = [] + initialisedCallbacks.forEach((c) => { + try { + c(true) + } catch (error) { + const err = resolveError(error) + initializeSpan.recordException(err) + errors.push(err) + } + }) + + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Multiple callback errors occurred') + } + initialisedCallbacks.length = 0 +} diff --git a/libs/js-sdk/src/create-client.ts b/libs/js-sdk/src/create-client.ts index df8aa694..67ea4727 100644 --- a/libs/js-sdk/src/create-client.ts +++ b/libs/js-sdk/src/create-client.ts @@ -1,7 +1,7 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import type { EffectiveFeatureStateStore } from './effective-feature-state-store' import type { FeatureBoardClient } from './features-client' -import { debugLog } from './log' +import { getTracer } from './utils/get-tracer' /** Designed for internal SDK use */ export function createClientInternal( @@ -9,56 +9,119 @@ export function createClientInternal( ): FeatureBoardClient { return { getEffectiveValues() { - const all = stateStore.all() - return { - audiences: [...stateStore.audiences], - effectiveValues: Object.keys(all) - .filter((key) => all[key]) - .map((key) => ({ - featureKey: key, - value: all[key]!, - })), - } + return getTracer().startActiveSpan( + 'fbsdk-get-effective-values', + (span) => { + try { + const all = stateStore.all() + span.setAttributes({ + store: JSON.stringify(all), + }) + + return { + audiences: [...stateStore.audiences], + effectiveValues: Object.keys(all) + .filter((key) => all[key]) + .map((key) => ({ + featureKey: key, + value: all[key]!, + })), + } + } finally { + span.end() + } + }, + ) }, getFeatureValue: (featureKey, defaultValue) => { - const value = stateStore.get(featureKey as string) - debugLog('getFeatureValue: %o', { - featureKey, - value, - defaultValue, - }) + return getTracer().startActiveSpan( + 'fbsdk-get-feature-value', + (span) => { + try { + const value = stateStore.get(featureKey as string) + span.setAttributes({ + 'feature.key': featureKey, + 'feature.value': value, + 'feature.defaultValue': defaultValue, + }) - return value ?? defaultValue + return value ?? defaultValue + } finally { + span.end() + } + }, + ) }, subscribeToFeatureValue( featureKey: string, defaultValue: any, onValue: (value: any) => void, ) { - debugLog('subscribeToFeatureValue: %s', featureKey) - - const callback = (updatedFeatureKey: string, value: any): void => { - if (featureKey === updatedFeatureKey) { - debugLog( - 'subscribeToFeatureValue: %s update: %o', - featureKey, - { - featureKey, - value, - defaultValue, - }, - ) - onValue(value ?? defaultValue) - } - } + return getTracer().startActiveSpan( + 'fbsdk-subscribe-to-feature-value', + { + attributes: { + 'feature.key': featureKey, + 'feature.defaultValue': defaultValue, + }, + }, + (span) => { + try { + const callback = ( + updatedFeatureKey: string, + value: any, + ): void => { + if (featureKey === updatedFeatureKey) { + getTracer().startActiveSpan( + 'fbsdk-subscribeToFeatureValue-onValue', + { + attributes: { + 'feature.key': featureKey, + 'feature.value': value, + 'feature.defaultValue': + defaultValue, + }, + }, + (span) => { + try { + onValue(value ?? defaultValue) + } finally { + span.end() + } + }, + ) + } + } - stateStore.on('feature-updated', callback) - onValue(stateStore.get(featureKey) ?? defaultValue) + stateStore.on('feature-updated', callback) + onValue(stateStore.get(featureKey) ?? defaultValue) - return () => { - debugLog('unsubscribeToFeatureValue: %s', featureKey) - stateStore.off('feature-updated', callback) - } + return () => { + getTracer().startActiveSpan( + 'fbsdk-unsubscribe-to-feature-value', + { + attributes: { + 'feature.key': featureKey, + 'feature.defaultValue': defaultValue, + }, + }, + (span) => { + try { + stateStore.off( + 'feature-updated', + callback, + ) + } finally { + span.end() + } + }, + ) + } + } finally { + span.end() + } + }, + ) }, } } diff --git a/libs/js-sdk/src/effective-feature-state-store.ts b/libs/js-sdk/src/effective-feature-state-store.ts index 2d9e89eb..dcc00376 100644 --- a/libs/js-sdk/src/effective-feature-state-store.ts +++ b/libs/js-sdk/src/effective-feature-state-store.ts @@ -1,10 +1,8 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' -import { debugLog } from './log' +import { addDebugEvent } from './utils/add-debug-event' export type FeatureValue = EffectiveFeatureValue['value'] | undefined -const stateStoreDebug = debugLog.extend('state-store') - export class EffectiveFeatureStateStore { private _audiences: string[] = [] private _store: Record = {} @@ -57,7 +55,8 @@ export class EffectiveFeatureStateStore { } set(featureKey: string, value: FeatureValue) { - stateStoreDebug("set '%s': %o", featureKey, value) + addDebugEvent('Feature store: set feature', { featureKey, value }) + this._store[featureKey] = value this.valueUpdatedCallbacks.forEach((valueUpdated) => @@ -67,7 +66,7 @@ export class EffectiveFeatureStateStore { get(featureKey: string): FeatureValue { const value = this._store[featureKey] - stateStoreDebug("get '%s': %o", featureKey, value) + addDebugEvent('Feature store: get feature', { featureKey, value }) return value } } diff --git a/libs/js-sdk/src/ensure-single.ts b/libs/js-sdk/src/ensure-single.ts index dc63fe84..1e84a567 100644 --- a/libs/js-sdk/src/ensure-single.ts +++ b/libs/js-sdk/src/ensure-single.ts @@ -1,4 +1,5 @@ import { TooManyRequestsError } from '@featureboard/contracts' +import { trace } from '@opentelemetry/api' /** De-dupes calls while the promise is in flight, otherwise will trigger again */ export function createEnsureSingleWithBackoff( @@ -12,8 +13,10 @@ export function createEnsureSingleWithBackoff( tooManyRequestsError && tooManyRequestsError.retryAfter > new Date() ) { + trace.getActiveSpan()?.recordException(tooManyRequestsError) return Promise.reject(tooManyRequestsError) } + tooManyRequestsError = undefined if (!current) { current = cb() .catch((error: Error) => { diff --git a/libs/js-sdk/src/featureboard-fixture.ts b/libs/js-sdk/src/featureboard-fixture.ts new file mode 100644 index 00000000..7ba297f0 --- /dev/null +++ b/libs/js-sdk/src/featureboard-fixture.ts @@ -0,0 +1,38 @@ +import { trace } from '@opentelemetry/api' +import { type RequestHandler } from 'msw' +import { setupServer, type SetupServer } from 'msw/node' +import { type TestFunction } from 'vitest' +import { openTelemetryTracePassthrough } from './utils/openTelemetryTracePassthrough' + +export function featureBoardFixture( + testContext: Context, + handlers: (context: Context) => Array, + testFn: TestFunction<{ testContext: Context; server: SetupServer }>, +): TestFunction { + return async (context) => { + const { task } = context + const tracer = trace.getTracer(task.suite.name) + const server = setupServer( + openTelemetryTracePassthrough, + ...handlers(testContext), + ) + server.listen({ onUnhandledRequest: 'error' }) + + await tracer.startActiveSpan( + `${task.suite.name ? task.suite.name + ': ' : ''}${task.name}`, + { + root: true, + attributes: {}, + }, + async (span) => { + try { + await testFn({ ...context, testContext, server }) + } finally { + server.resetHandlers() + server.close() + span.end() + } + }, + ) + } +} diff --git a/libs/js-sdk/src/index.ts b/libs/js-sdk/src/index.ts index 7975c36c..2f5ca504 100644 --- a/libs/js-sdk/src/index.ts +++ b/libs/js-sdk/src/index.ts @@ -1,4 +1,5 @@ export type { BrowserClient } from './client-connection' +export type { ClientOptions } from './client-options' export { createBrowserClient } from './create-browser-client' export { createManualClient } from './create-manual-client' export type { FeatureBoardApiConfig } from './featureboard-api-config' @@ -8,6 +9,7 @@ export type { FeatureBoardClient } from './features-client' // Need to figure out how to do that with the current build setup export { createEnsureSingleWithBackoff } from './ensure-single' export { featureBoardHostedService } from './featureboard-service-urls' +export { resolveError } from './utils/resolve-error' export { retry } from './utils/retry' export interface Features {} diff --git a/libs/js-sdk/src/log.ts b/libs/js-sdk/src/log.ts deleted file mode 100644 index e5f131f5..00000000 --- a/libs/js-sdk/src/log.ts +++ /dev/null @@ -1,3 +0,0 @@ -import debug from 'debug' - -export const debugLog = debug('featureboard-sdk') diff --git a/libs/js-sdk/src/tests/http-client.spec.ts b/libs/js-sdk/src/tests/http-client.spec.ts index 19433add..80b4a55a 100644 --- a/libs/js-sdk/src/tests/http-client.spec.ts +++ b/libs/js-sdk/src/tests/http-client.spec.ts @@ -3,268 +3,274 @@ import { type EffectiveFeatureValue, } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' import { describe, expect, it } from 'vitest' import { createBrowserClient } from '../create-browser-client' +import { featureBoardFixture } from '../featureboard-fixture' import { featureBoardHostedService } from '../featureboard-service-urls' describe('http client', () => { - it('can wait for initialisation, initialised false', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - - expect(httpClient.initialised).toEqual(false) - expect(value).toEqual('default-value') - await httpClient.waitForInitialised() - expect(httpClient.initialised).toEqual(true) - } finally { - server.resetHandlers() - server.close() - } - }) - - it('can wait for initialisation, initialised true', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', + it( + 'can wait for initialisation, initialised false', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => { + const values: EffectiveFeatureValue[] = [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ] + + return HttpResponse.json(values) + }), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + + expect(httpClient.initialised).toEqual(false) + expect(value).toEqual('default-value') + await httpClient.waitForInitialised() + expect(httpClient.initialised).toEqual(true) }, - ] - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(httpClient.initialised).toEqual(true) - expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('can trigger manual update', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', + ), + ) + + it( + 'can wait for initialisation, initialised true', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => { + const values: EffectiveFeatureValue[] = [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ] + return HttpResponse.json(values) + }), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await httpClient.waitForInitialised() + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(httpClient.initialised).toEqual(true) + expect(value).toEqual('service-default-value') }, - ] + ), + ) + + it( + 'can trigger manual update', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/effective', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), + { once: true }, + ), + http.get( + 'https://client.featureboard.app/effective', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', + await httpClient.waitForInitialised() + await httpClient.updateFeatures() + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('new-service-default-value') }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), - { once: true }, - ), - http.get( - 'https://client.featureboard.app/effective', - () => HttpResponse.json(newValues), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - await httpClient.updateFeatures() - - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) + ), + ) // Below tests are testing behavior around https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match - it('Attaches etag header to update requests', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - const lastModified = new Date().toISOString() - let matched = false - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => - HttpResponse.json(values, { - headers: { etag: lastModified }, - }), - { once: true }, - ), - http.get( - 'https://client.featureboard.app/effective', - ({ request }) => { - if (request.headers.get('if-none-match') === lastModified) { - matched = true - return new Response(null, { status: 304 }) - } + it( + 'attaches etag header to update requests', + featureBoardFixture( + { matched: false, lastModified: new Date().toISOString() }, + (context) => [ + http.get( + 'https://client.featureboard.app/effective', + () => { + const values: EffectiveFeatureValue[] = [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ] + + return HttpResponse.json(values, { + headers: { etag: context.lastModified }, + }) + }, + { once: true }, + ), + http.get( + 'https://client.featureboard.app/effective', + ({ request }) => { + if ( + request.headers.get('if-none-match') === + context.lastModified + ) { + context.matched = true + return new Response(null, { status: 304 }) + } + + console.warn( + 'Request Mismatch', + request.url, + request.headers.get('if-none-match'), + context.lastModified, + ) + return HttpResponse.json({}, { status: 500 }) + }, + { once: true }, + ), + ], + async ({ testContext }) => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + await httpClient.waitForInitialised() + await httpClient.updateFeatures() - console.warn('Request Mismatch', request.url, lastModified) - return HttpResponse.json({}, { status: 500 }) - }, - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - await httpClient.waitForInitialised() - await httpClient.updateFeatures() - - expect(matched).toEqual(true) - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Handles updates from server', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - { - featureKey: 'my-feature-2', - value: 'service-default-value', + expect(testContext.matched).toEqual(true) }, - ] - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', - }, - ] - const lastModified = new Date().toISOString() - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - ({ request }) => { - const ifNoneMatchHeader = - request.headers.get('if-none-match') - - if (ifNoneMatchHeader === lastModified) { - const newLastModified = new Date().toISOString() - return HttpResponse.json(newValues, { - headers: { etag: newLastModified }, - }) - } + ), + ) - return HttpResponse.json(values, { - headers: { - etag: lastModified, - }, - }) - }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - await httpClient.updateFeatures() - - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - - const value2 = httpClient.client.getFeatureValue( - 'my-feature-2', - 'default-value', - ) - expect(value2).toEqual('default-value') - } finally { - server.resetHandlers() - server.close() - } - }) + it( + 'handles updates from server', + featureBoardFixture( + { lastModified: new Date().toISOString() }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/effective', + ({ request }) => { + const ifNoneMatchHeader = + request.headers.get('if-none-match') + + if (ifNoneMatchHeader === testContext.lastModified) { + const newLastModified = new Date().toISOString() + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) + } + + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + { + featureKey: 'my-feature-2', + value: 'service-default-value', + }, + ], + { + headers: { + etag: testContext.lastModified, + }, + }, + ) + }, + ), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await httpClient.waitForInitialised() + await httpClient.updateFeatures() + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('new-service-default-value') + + const value2 = httpClient.client.getFeatureValue( + 'my-feature-2', + 'default-value', + ) + expect(value2).toEqual('default-value') + }, + ), + ) it( 'can start with last known good config', - async () => { - const server = setupServer( + featureBoardFixture( + {}, + + () => [ http.get('https://client.featureboard.app/effective', () => HttpResponse.json({}, { status: 500 }), ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async ({}) => { const client = createBrowserClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -288,353 +294,350 @@ describe('http client', () => { await client.waitForInitialised() }).rejects.toThrowError('500') expect(client.initialised).toEqual(true) - } finally { - server.resetHandlers() - server.close() - } - }, - { timeout: 60000 }, - ) - - it('Handles updating audience', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - const lastModified = new Date().toISOString() - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - ({ request }) => { - const url = new URL(request.url) - if (url.searchParams.get('audiences') === 'test-audience') { - const newLastModified = new Date().toISOString() - return HttpResponse.json(newValues, { - headers: { etag: newLastModified }, - }) - } - - return HttpResponse.json(values, { - headers: { etag: lastModified }, - }) - }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) + ), + ) - try { - expect.assertions(5) + it( + 'handles updating audience', + featureBoardFixture( + { lastModified: new Date().toISOString() }, + ({ lastModified }) => [ + http.get( + 'https://client.featureboard.app/effective', + ({ request }) => { + const url = new URL(request.url) + if ( + url.searchParams.get('audiences') === + 'test-audience' + ) { + const newLastModified = new Date().toISOString() + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) + } + + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + { + headers: { etag: lastModified }, + }, + ) + }, + ), + ], + async () => { + expect.assertions(4) - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - await httpClient.waitForInitialised() + await httpClient.waitForInitialised() - expect(httpClient.initialised).toEqual(true) + expect(httpClient.initialised).toEqual(true) - httpClient.subscribeToInitialisedChanged((init) => { - if (!init) { - expect(httpClient.initialised).toEqual(false) - } else { + httpClient.subscribeToInitialisedChanged((init) => { expect(httpClient.initialised).toEqual(true) const value = httpClient.client.getFeatureValue( 'my-feature', 'default-value', ) expect(value).toEqual('new-service-default-value') - } - }) - - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-default-value') - - await httpClient.updateAudiences(['test-audience']) - await httpClient.waitForInitialised() - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Subscribe and unsubscribe to initialised changes', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - let lastModified = new Date().toISOString() - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', + }) + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-default-value') + + await httpClient.updateAudiences(['test-audience']) + await httpClient.waitForInitialised() }, - ] - let count = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - ({ request }) => { - const url = new URL(request.url) - if (url.searchParams.get('audiences') === 'test-audience') { - const newLastModified = new Date().toISOString() - return HttpResponse.json(newValues, { - headers: { etag: newLastModified }, - }) - } - if (count > 0) { - lastModified = new Date().toISOString() - } + ), + ) - count++ - return HttpResponse.json(values, { - headers: { etag: lastModified }, - }) - }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) + it( + 'subscribe and unsubscribe to initialised changes', + featureBoardFixture( + { lastModified: new Date().toISOString(), count: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/effective', + ({ request }) => { + const url = new URL(request.url) + if ( + url.searchParams.get('audiences') === + 'test-audience' + ) { + const newLastModified = new Date().toISOString() + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) + } + if (testContext.count > 0) { + testContext.lastModified = new Date().toISOString() + } + + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + { + headers: { etag: testContext.lastModified }, + }, + ) + }, + ), + ], + async ({ testContext }) => { + expect.assertions(3) - try { - expect.assertions(4) + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) + await httpClient.waitForInitialised() - await httpClient.waitForInitialised() + expect(httpClient.initialised).toEqual(true) - expect(httpClient.initialised).toEqual(true) + const unsubscribe = httpClient.subscribeToInitialisedChanged( + (init: boolean) => { + if (!init) { + expect(httpClient.initialised).toEqual(false) + } else { + expect(httpClient.initialised).toEqual(true) + } + }, + ) + + await httpClient.updateAudiences(['test-audience']) + + await httpClient.waitForInitialised() + + unsubscribe() - const unsubscribe = httpClient.subscribeToInitialisedChanged( - (init: boolean) => { + await httpClient.updateAudiences(['test-audience-unsubscribe']) + + await httpClient.waitForInitialised() + + expect(testContext.count).equal(2) + }, + ), + ) + + it( + 'handles updating audience with initialised false', + featureBoardFixture( + { lastModified: new Date().toISOString() }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/effective', + ({ request }) => { + const url = new URL(request.url) + if ( + url.searchParams.get('audiences') === + 'test-audience' + ) { + const newLastModified = new Date().toISOString() + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) + } + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + { + headers: { etag: testContext.lastModified }, + }, + ) + }, + ), + ], + async () => { + expect.assertions(4) + + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + expect(httpClient.initialised).toEqual(false) + + httpClient.subscribeToInitialisedChanged((init) => { if (!init) { expect(httpClient.initialised).toEqual(false) } else { expect(httpClient.initialised).toEqual(true) + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('new-service-default-value') } - }, - ) - - await httpClient.updateAudiences(['test-audience']) + }) - await httpClient.waitForInitialised() + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('default-value') - unsubscribe() + httpClient.updateAudiences(['test-audience']) + await httpClient.waitForInitialised() + }, + ), + ) - await httpClient.updateAudiences(['test-audience-unsubscribe']) + it( + 'throw error updating audience when SDK connection fails', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => + HttpResponse.json( + { message: 'Test Server Request Error' }, + { status: 500 }, + ), + ), + ], + async () => { + expect.assertions(3) - await httpClient.waitForInitialised() + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - expect(count).equal(2) - } finally { - server.resetHandlers() - server.close() - } - }) + expect(httpClient.initialised).toEqual(false) - it('Handles updating audience with initialised false', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - const lastModified = new Date().toISOString() - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', - }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - ({ request }) => { - const url = new URL(request.url) - if (url.searchParams.get('audiences') === 'test-audience') { - const newLastModified = new Date().toISOString() - return HttpResponse.json(newValues, { - headers: { etag: newLastModified }, - }) - } - return HttpResponse.json(values, { - headers: { etag: lastModified }, - }) - }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - expect.assertions(5) - - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - expect(httpClient.initialised).toEqual(false) - - httpClient.subscribeToInitialisedChanged((init) => { - if (!init) { - expect(httpClient.initialised).toEqual(false) - } else { - expect(httpClient.initialised).toEqual(true) - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - } - }) - - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('default-value') - - httpClient.updateAudiences(['test-audience']) - await httpClient.waitForInitialised() - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Throw error updating audience when SDK connection fails', async () => { - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json( - { message: 'Test Server Request Error' }, - { status: 500 }, - ), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - expect.assertions(6) - - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - expect(httpClient.initialised).toEqual(false) - - httpClient.subscribeToInitialisedChanged((init) => { - if (!init) { - expect(httpClient.initialised).toEqual(false) - } else { + httpClient.subscribeToInitialisedChanged((init) => { expect(httpClient.initialised).toEqual(true) const value = httpClient.client.getFeatureValue( 'my-feature', 'default-value', ) expect(value).toEqual('default-value') - } - }) + }) - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('default-value') + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('default-value') + + httpClient.updateAudiences(['test-audience']) + await expect(async () => { + await httpClient.waitForInitialised() + }).rejects.toThrowError('500') + }, + ), + ) + + it( + 'initialisation fails and retries, no external state store', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/effective', + () => + HttpResponse.json( + { message: 'Test Server Request Error' }, + { status: 500 }, + ), + { once: true }, + ), + http.get('https://client.featureboard.app/effective', () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ]), + ), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - httpClient.updateAudiences(['test-audience']) - await expect(async () => { await httpClient.waitForInitialised() - }).rejects.toThrowError('500') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Initialisation fails and retries, no external state store', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-value', + expect(httpClient.initialised).toEqual(true) + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-value') }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => - HttpResponse.json( - { message: 'Test Server Request Error' }, - { status: 500 }, - ), - { once: true }, - ), - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - expect(httpClient.initialised).toEqual(true) - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-value') - } finally { - server.resetHandlers() - server.close() - } - }) + ), + ) it( - 'Initialisation retries 5 times then throws an error', - async () => { - let count = 0 - const server = setupServer( + 'initialisation retries 5 times then throws an error', + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/effective', () => { - count++ + testContext.count++ return HttpResponse.json( { message: 'Test Server Request Error' }, { status: 500 }, ) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async ({ testContext }) => { const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', audiences: [], @@ -645,247 +648,366 @@ describe('http client', () => { await expect(async () => { await httpClient.waitForInitialised() }).rejects.toThrowError('500') - expect(count).toEqual(5 + 1) // initial request and 5 retry + expect(testContext.count).toEqual(2 + 1) // initial request and 2 retry const value = httpClient.client.getFeatureValue( 'my-feature', 'default-value', ) expect(value).toEqual('default-value') - } finally { - server.resetHandlers() - server.close() - } - }, - { timeout: 60000 }, + }, + ), ) - it('Feature value subscription called during initialisation', async () => { - let count = 0 - expect.assertions(2) - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-value', + it( + 'feature value subscription called during initialisation', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ]), + ), + ], + async () => { + let count = 0 + expect.assertions(2) + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + httpClient.client.subscribeToFeatureValue( + 'my-feature', + 'default-value', + (value) => { + if (count == 0) { + count++ + expect(value).toEqual('default-value') + } else { + expect(value).toEqual('service-value') + } + }, + ) + + await httpClient.waitForInitialised() }, - ] - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - httpClient.client.subscribeToFeatureValue( - 'my-feature', - 'default-value', - (value) => { - if (count == 0) { - count++ - expect(value).toEqual('default-value') - } else { - expect(value).toEqual('service-value') + ), + ) + + it( + 'initialisation retries when Too Many Requests (429) returned from Client HTTP API', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/effective', () => { + testContext.count++ + if (testContext.count <= 2) { + return new Response(null, { + status: 429, + headers: { 'Retry-After': '1' }, + }) } - }, - ) - - await httpClient.waitForInitialised() - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Retry to connect when received 429 from HTTP Client API during initialization', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-value', + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + await httpClient.waitForInitialised() + httpClient.close() + + expect(testContext.count).toBe(3) }, - ] - - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => { - count++ - if (count <= 2) { - return new Response(null, { - status: 429, - headers: { 'Retry-After': '1' }, - }) - } - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), + ), + ) + + it( + 'update features blocked after Too Many Requests (429) received with retry-after header using seconds', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/effective', + () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) }, + { once: true }, + ), + http.get( + 'https://client.featureboard.app/effective', + () => { + testContext.count++ + return new Response(null, { + status: 429, + headers: { 'Retry-After': '1' }, + }) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/effective', () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - await httpClient.waitForInitialised() - httpClient.close() - } finally { - server.resetHandlers() - server.close() - } - expect(count).toBe(3) - }) - - it('Block HTTP client API call after 429 response from HTTP Client API according to retry-after seconds', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-value', + + await httpClient.waitForInitialised() + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await new Promise((resolve) => setTimeout(resolve, 1000)) + await httpClient.updateFeatures() + + httpClient.close() + + expect(testContext.count).toBe(3) }, - ] - - let count = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), + ), + ) + + it( + 'update features blocked after Too Many Requests (429) received with retry-after header using UTC date time', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/effective', + () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }, + { once: true }, + ), + http.get( + 'https://client.featureboard.app/effective', + () => { + testContext.count++ + const retryAfter = new Date( + new Date().getTime() + 1000, + ).toUTCString() + return new Response(null, { + status: 429, + headers: { 'Retry-After': retryAfter }, + }) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/effective', () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, }, - }) - }, - { once: true }, - ), - http.get( - 'https://client.featureboard.app/effective', - () => { - count++ - return new Response(null, { - status: 429, - headers: { 'Retry-After': '1' }, - }) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/effective', () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), + ) + }), + ], + async ({ testContext }) => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await httpClient.waitForInitialised() + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await new Promise((resolve) => setTimeout(resolve, 1000)) + await httpClient.updateFeatures() + + httpClient.close() + + expect(testContext.count).toBe(3) + }, + ), + ) + + it( + 'update features blocked after Too Many Requests (429) received with undefined retry-after header', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/effective', + () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }, + { once: true }, + ), + http.get( + 'https://client.featureboard.app/effective', + () => { + testContext.count++ + return new Response(null, { + status: 429, + }) }, + { once: true }, + ), + http.get('https://client.featureboard.app/effective', () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await new Promise((resolve) => setTimeout(resolve, 1000)) - await httpClient.updateFeatures() - - httpClient.close() - } finally { - server.resetHandlers() - server.close() - } - expect(count).toBe(3) - }) - - it('Block HTTP client API call after 429 response from HTTP Client API according to retry-after date', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-value', + + await httpClient.waitForInitialised() + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + httpClient.close() + expect(testContext.count).toBe(2) }, - ] - - let count = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), + ), + ) + + it( + 'otel disabled should not impact functionality', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => { + const values: EffectiveFeatureValue[] = [ + { + featureKey: 'my-feature', + value: 'service-default-value', }, - }) - }, - { once: true }, - ), - http.get( - 'https://client.featureboard.app/effective', - () => { - count++ - const retryAfter = new Date( - new Date().getTime() + 1000, - ).toUTCString() - return new Response(null, { - status: 429, - headers: { 'Retry-After': retryAfter }, - }) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/effective', () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), - }, + ] + return HttpResponse.json(values) + }), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + options: { disableOTel: true }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await new Promise((resolve) => setTimeout(resolve, 1000)) - await httpClient.updateFeatures() - - httpClient.close() - } finally { - server.resetHandlers() - server.close() - } - expect(count).toBe(3) - }) + + await httpClient.waitForInitialised() + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(httpClient.initialised).toEqual(true) + expect(value).toEqual('service-default-value') + }, + ), + ) }) diff --git a/libs/js-sdk/src/tests/manual-client.spec.ts b/libs/js-sdk/src/tests/manual-client.spec.ts index 04f29d35..8f70b27c 100644 --- a/libs/js-sdk/src/tests/manual-client.spec.ts +++ b/libs/js-sdk/src/tests/manual-client.spec.ts @@ -1,28 +1,45 @@ -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { createManualClient } from '../create-manual-client' +import { featureBoardFixture } from '../featureboard-fixture' -it('can be intialised with initial values', () => { - const client = createManualClient({ - audiences: [], - values: { - foo: 'bar', - }, - }) +describe('manual client', () => { + it( + 'can be intialised with initial values', + featureBoardFixture( + {}, + () => [], + () => { + const client = createManualClient({ + audiences: [], + values: { + foo: 'bar', + }, + }) - expect(client.getFeatureValue('foo', 'default')).toBe('bar') -}) + expect(client.getFeatureValue('foo', 'default')).toBe('bar') + }, + ), + ) -it('can set value', () => { - const client = createManualClient({ - audiences: [], - values: { - foo: 'bar', - }, - }) + it( + 'can set value', + featureBoardFixture( + {}, + () => [], + () => { + const client = createManualClient({ + audiences: [], + values: { + foo: 'bar', + }, + }) - client.set('foo', 'baz') + client.set('foo', 'baz') - expect(client.getFeatureValue('foo', 'default')).toBe('baz') + expect(client.getFeatureValue('foo', 'default')).toBe('baz') + }, + ), + ) }) declare module '@featureboard/js-sdk' { diff --git a/libs/js-sdk/src/tests/mode-manual.spec.ts b/libs/js-sdk/src/tests/mode-manual.spec.ts index fee71b4f..c8ad1675 100644 --- a/libs/js-sdk/src/tests/mode-manual.spec.ts +++ b/libs/js-sdk/src/tests/mode-manual.spec.ts @@ -1,124 +1,115 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' import { describe, expect, it } from 'vitest' import { createBrowserClient } from '../create-browser-client' +import { featureBoardFixture } from '../featureboard-fixture' -describe('Manual update mode', () => { - it('fetches initial values', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: 'manual', - }) - await client.waitForInitialised() +describe('manual update mode', () => { + it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/effective', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const client = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'manual', + }) + await client.waitForInitialised() - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('can manually update values', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', + const value = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-default-value') }, - ] + ), + ) - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', - }, - ] - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => { - if (count > 0) { - return HttpResponse.json(newValues) - } + it( + 'can manually update values', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/effective', () => { + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ]) + } - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]) + }), + ], + async () => { + const client = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'manual', + }) + await client.waitForInitialised() + await client.updateFeatures() - try { - const client = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: 'manual', - }) - await client.waitForInitialised() - await client.updateFeatures() - - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('close', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', + const value = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('new-service-default-value') }, - ] + ), + ) - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - try { - const client = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: 'manual', - }) + it( + 'close', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/effective', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const client = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'manual', + }) - client.close() - } finally { - server.resetHandlers() - server.close() - } - }) + client.close() + }, + ), + ) }) - declare module '@featureboard/js-sdk' { interface Features extends Record {} } diff --git a/libs/js-sdk/src/tests/mode-polling.spec.ts b/libs/js-sdk/src/tests/mode-polling.spec.ts index 02b9a449..76567b39 100644 --- a/libs/js-sdk/src/tests/mode-polling.spec.ts +++ b/libs/js-sdk/src/tests/mode-polling.spec.ts @@ -1,8 +1,8 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createBrowserClient } from '../create-browser-client' +import { featureBoardFixture } from '../featureboard-fixture' import { interval } from '../interval' beforeEach(() => { @@ -10,194 +10,179 @@ beforeEach(() => { interval.clear = clearInterval }) -describe('Polling update mode', () => { - it('fetches initial values', async () => { - interval.set = vi.fn(() => {}) as any - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const connection = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: 'polling', - }) - - await connection.waitForInitialised() - - const value = connection.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) +describe('polling update mode', () => { + it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/effective', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + interval.set = vi.fn(() => {}) as any + + const connection = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'polling', + }) - it('sets up interval correctly', async () => { - const handle = {} - interval.set = vi.fn(() => { - return handle - }) as any - interval.clear = vi.fn(() => {}) + await connection.waitForInitialised() - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', + const value = connection.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-default-value') }, - ] - - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const connection = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: 'polling', - }) - connection.close() - - expect(interval.set).toBeCalled() - expect(interval.clear).toBeCalledWith(handle) - } finally { - server.resetHandlers() - server.close() - } - }) - - it('fetches updates when interval fires', async () => { - const setMock = vi.fn(() => {}) - interval.set = setMock as any + ), + ) + + it( + 'sets up interval correctly', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), + ), + ], + async () => { + const handle = {} + interval.set = vi.fn(() => { + return handle + }) as any + interval.clear = vi.fn(() => {}) + + const connection = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'polling', + }) + connection.close() - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', + expect(interval.set).toBeCalled() + expect(interval.clear).toBeCalledWith(handle) }, - ] - - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => { - if (count > 0) { - return HttpResponse.json(newValues) - } - - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: 'polling', - }) - await client.waitForInitialised() - - const pollCallback = (setMock.mock.calls[0] as any)[0] - await pollCallback() + ), + ) + + it( + 'fetches updates when interval fires', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/effective', () => { + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ]) + } + + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]) + }), + ], + async () => { + const setMock = vi.fn(() => {}) + interval.set = setMock as any + + const client = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'polling', + }) + await client.waitForInitialised() - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) + const pollCallback = (setMock.mock.calls[0] as any)[0] + await pollCallback() - it('Do NOT throw error or make call to HTTP Client API when Too Many Requests (429) has been returned', async () => { - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', + const value = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('new-service-default-value') }, - ] - - let countAPICalls = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/effective', - () => { - countAPICalls++ - return HttpResponse.json(values) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/effective', () => { - countAPICalls++ - return new Response(null, { - status: 429, - headers: { 'Retry-After': '2' }, + ), + ) + it( + 'suppress errors received during feature updates', + featureBoardFixture( + { countAPICalls: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/effective', + () => { + testContext.countAPICalls++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/effective', () => { + testContext.countAPICalls++ + return new Response(null, { + status: 429, + headers: { 'Retry-After': '2' }, + }) + }), + ], + async ({ testContext }) => { + const client = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: { + kind: 'polling', + options: { intervalMs: 100 }, + }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - try { - const client = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: { - kind: 'polling', - options: { intervalMs: 100 }, - }, - }) - - await client.waitForInitialised() - // Wait for the interval to expire - await new Promise((resolve) => setTimeout(resolve, 100)) - const value1 = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value1).toEqual('service-default-value') - // Wait for interval to expire - await new Promise((resolve) => setTimeout(resolve, 100)) - const value2 = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value2).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect(countAPICalls).toBe(2) - expect.assertions(3) - }) + await client.waitForInitialised() + // Wait for the interval to expire + await new Promise((resolve) => setTimeout(resolve, 100)) + const value1 = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value1).toEqual('service-default-value') + // Wait for interval to expire + await new Promise((resolve) => setTimeout(resolve, 100)) + const value2 = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value2).toEqual('service-default-value') + expect(testContext.countAPICalls).toBe(2) + }, + ), + ) }) declare module '@featureboard/js-sdk' { diff --git a/libs/js-sdk/src/update-strategies/createLiveUpdateStrategy.ts b/libs/js-sdk/src/update-strategies/createLiveUpdateStrategy.ts index c7b915c6..b395ae43 100644 --- a/libs/js-sdk/src/update-strategies/createLiveUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createLiveUpdateStrategy.ts @@ -2,9 +2,6 @@ import type { NotificationType } from '@featureboard/contracts' import type { LiveOptions } from '@featureboard/live-connection' import type { EffectiveFeatureStateStore } from '../effective-feature-state-store' import type { EffectiveConfigUpdateStrategy } from './update-strategies' -import { updatesLog } from './updates-log' - -export const liveDebugLog = updatesLog.extend('live') export function createLiveUpdateStrategy( environmentApiKey: string, @@ -26,6 +23,7 @@ export function createLiveUpdateStrategy( let connectionState: 'connected' | 'disconnected' = 'disconnected' return { + name: 'live', async connect(stateStore: EffectiveFeatureStateStore) { const liveConnection = await liveConnectionAsync diff --git a/libs/js-sdk/src/update-strategies/createManualUpdateStrategy.ts b/libs/js-sdk/src/update-strategies/createManualUpdateStrategy.ts index 73d7ee84..1bda7c07 100644 --- a/libs/js-sdk/src/update-strategies/createManualUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createManualUpdateStrategy.ts @@ -1,9 +1,6 @@ import { createEnsureSingleWithBackoff } from '../ensure-single' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' import type { EffectiveConfigUpdateStrategy } from './update-strategies' -import { updatesLog } from './updates-log' - -export const manualUpdatesDebugLog = updatesLog.extend('manual') export function createManualUpdateStrategy( environmentApiKey: string, @@ -15,6 +12,7 @@ export function createManualUpdateStrategy( | (() => Promise) return { + name: 'manual', async connect(stateStore) { // Force update etag = undefined diff --git a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts index c7372d0d..c093ee66 100644 --- a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -1,11 +1,10 @@ -import { error } from 'console' +import { trace } from '@opentelemetry/api' import { createEnsureSingleWithBackoff } from '../ensure-single' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' import { pollingUpdates } from '../utils/pollingUpdates' +import { resolveError } from '../utils/resolve-error' +import { startActiveSpan } from '../utils/start-active-span' import type { EffectiveConfigUpdateStrategy } from './update-strategies' -import { updatesLog } from './updates-log' - -export const pollingUpdatesDebugLog = updatesLog.extend('polling') export function createPollingUpdateStrategy( environmentApiKey: string, @@ -15,8 +14,10 @@ export function createPollingUpdateStrategy( let stopPolling: undefined | (() => void) let etag: undefined | string let fetchUpdatesSingle: undefined | (() => Promise) + const parentSpan = trace.getActiveSpan() return { + name: 'polling', async connect(stateStore) { // Force update etag = undefined @@ -37,13 +38,22 @@ export function createPollingUpdateStrategy( stopPolling() } stopPolling = pollingUpdates(() => { - if (fetchUpdatesSingle) { - pollingUpdatesDebugLog('Polling for updates (%o)', etag) - // Catch errors here to ensure no unhandled promise rejections after a poll - return fetchUpdatesSingle().catch((error: Error) => { - updatesLog(error) - }) - } + return startActiveSpan({ + name: 'fbsdk-polling-updates', + options: { attributes: { etag }, root: !parentSpan }, + parentSpan, + fn: async (span) => { + // Catch errors here to ensure no unhandled promise rejections after a poll + if (fetchUpdatesSingle) { + return await fetchUpdatesSingle() + .catch((err) => { + span.recordException(resolveError(err)) + }) + .finally(() => span.end()) + } + span.end() + }, + }) }, intervalMs) return await fetchUpdatesSingle() diff --git a/libs/js-sdk/src/update-strategies/update-strategies.ts b/libs/js-sdk/src/update-strategies/update-strategies.ts index 7ba96c42..0b9167ed 100644 --- a/libs/js-sdk/src/update-strategies/update-strategies.ts +++ b/libs/js-sdk/src/update-strategies/update-strategies.ts @@ -48,6 +48,7 @@ export interface OnRequestOptions { } export interface EffectiveConfigUpdateStrategy { + name: string state: 'connected' | 'disconnected' connect(stateStore: EffectiveFeatureStateStore): Promise close(): Promise diff --git a/libs/js-sdk/src/update-strategies/updates-log.ts b/libs/js-sdk/src/update-strategies/updates-log.ts deleted file mode 100644 index c39092cc..00000000 --- a/libs/js-sdk/src/update-strategies/updates-log.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { debugLog } from '../log' - -export const updatesLog = debugLog.extend('updates') diff --git a/libs/js-sdk/src/utils/add-debug-event.ts b/libs/js-sdk/src/utils/add-debug-event.ts new file mode 100644 index 00000000..88fbdacd --- /dev/null +++ b/libs/js-sdk/src/utils/add-debug-event.ts @@ -0,0 +1,6 @@ +import type { Attributes } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' + +export function addDebugEvent(event: string, attributes: Attributes = {}) { + trace.getActiveSpan()?.addEvent(event, attributes) +} diff --git a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts index aa9cd1e4..2d035494 100644 --- a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -2,10 +2,12 @@ import { TooManyRequestsError, type EffectiveFeatureValue, } from '@featureboard/contracts' +import { SpanStatusCode } from '@opentelemetry/api' import type { EffectiveFeatureStateStore } from '../effective-feature-state-store' import { getEffectiveEndpoint } from '../update-strategies/getEffectiveEndpoint' import { compareArrays } from './compare-arrays' -import { httpClientDebug } from './http-log' +import { getTracer } from './get-tracer' +import { resolveError } from './resolve-error' export async function fetchFeaturesConfigurationViaHttp( featureBoardEndpoint: string, @@ -19,85 +21,105 @@ export async function fetchFeaturesConfigurationViaHttp( featureBoardEndpoint, audiences, ) - httpClientDebug('Fetching effective values (%o)', { - etag, - audiences, - }) - const response = await fetch(effectiveEndpoint, { - method: 'GET', - headers: { - 'x-environment-key': environmentApiKey, - ...(etag ? { 'if-none-match': etag } : {}), - }, - }) + return getTracer().startActiveSpan( + 'fbsdk-fetch-effective-features-http', + { attributes: { audiences, etag } }, + async (span) => { + try { + const response = await fetch(effectiveEndpoint, { + method: 'GET', + headers: { + 'x-environment-key': environmentApiKey, + ...(etag ? { 'if-none-match': etag } : {}), + }, + }) - if (response.status === 429) { - // Too many requests - const retryAfterHeader = response.headers.get('Retry-After') - const retryAfterInt = retryAfterHeader - ? parseInt(retryAfterHeader, 10) - : 60 - const retryAfter = - retryAfterHeader && !retryAfterInt - ? new Date(retryAfterHeader) - : new Date() + if (response.status === 429) { + // Too many requests + const retryAfterHeader = response.headers.get('Retry-After') + const retryAfterInt = retryAfterHeader + ? parseInt(retryAfterHeader, 10) + : 60 + const retryAfter = + retryAfterHeader && !retryAfterInt + ? new Date(retryAfterHeader) + : new Date() - if (retryAfterInt) { - const retryAfterTime = retryAfter.getTime() + retryAfterInt * 1000 - retryAfter.setTime(retryAfterTime) - } + if (retryAfterInt) { + const retryAfterTime = + retryAfter.getTime() + retryAfterInt * 1000 + retryAfter.setTime(retryAfterTime) + } - throw new TooManyRequestsError( - `Failed to get latest features: Service returned ${ - response.status - }${response.statusText ? ' ' + response.statusText : ''}. ${ - retryAfterHeader - ? 'Retry after: ' + retryAfter.toUTCString() - : '' - } `, - retryAfter, - ) - } + throw new TooManyRequestsError( + `Failed to get latest features: Service returned ${ + response.status + }${ + response.statusText ? ' ' + response.statusText : '' + }. ${ + retryAfterHeader + ? 'Retry after: ' + retryAfter.toUTCString() + : '' + }`, + retryAfter, + ) + } - if (response.status !== 200 && response.status !== 304) { - httpClientDebug( - `Failed to fetch updates (%o): ${ - response.status - } (${await response.text()})`, - audiences, - ) - throw new Error( - `Failed to get latest flags: Service returned error ${response.status} (${response.statusText})`, - ) - } + if (response.status !== 200 && response.status !== 304) { + throw new Error( + `Failed to get latest flags: Service returned error ${response.status} (${response.statusText})`, + ) + } - // Expect most times will just get a response from the HEAD request saying no updates - if (response.status === 304) { - httpClientDebug('No changes (%o)', audiences) - return etag - } + // Expect most times will just get a response from the HEAD request saying no updates + if (response.status === 304) { + span.addEvent('Fetch succeeded without changes') + return etag + } - const currentEffectiveValues: EffectiveFeatureValue[] = - await response.json() + const currentEffectiveValues: EffectiveFeatureValue[] = + await response.json() - if (!compareArrays(getCurrentAudiences(), audiences)) { - httpClientDebug('Audiences changed while fetching (%o)', audiences) - return etag - } - const existing = { ...stateStore.all() } + const newAudiences = getCurrentAudiences() + if (!compareArrays(newAudiences, audiences)) { + span.addEvent( + 'Audiences changed while fetching, ignoring response', + { + audiences, + newAudiences, + }, + ) + return etag + } + const existing = { ...stateStore.all() } - for (const featureValue of currentEffectiveValues) { - stateStore.set(featureValue.featureKey, featureValue.value) - delete existing[featureValue.featureKey] - } - const unavailableFeatures = Object.keys(existing) - unavailableFeatures.forEach((unavailableFeature) => { - stateStore.set(unavailableFeature, undefined) - }) - httpClientDebug(`Updates (%o), %o`, audiences, { - effectiveValues: currentEffectiveValues, - unavailableFeatures, - }) + for (const featureValue of currentEffectiveValues) { + stateStore.set(featureValue.featureKey, featureValue.value) + delete existing[featureValue.featureKey] + } + const unavailableFeatures = Object.keys(existing) + unavailableFeatures.forEach((unavailableFeature) => { + stateStore.set(unavailableFeature, undefined) + }) + const newEtag = response.headers.get('etag') || undefined + span.addEvent('Fetch succeeded with updates', { + audiences, + unavailableFeatures, + newEtag, + }) - return response.headers.get('etag') || undefined + return newEtag + } catch (error) { + const err = resolveError(error) + span.recordException(err) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }) + throw err + } finally { + span.end() + } + }, + ) } diff --git a/libs/js-sdk/src/utils/get-tracer.ts b/libs/js-sdk/src/utils/get-tracer.ts new file mode 100644 index 00000000..2edbbae4 --- /dev/null +++ b/libs/js-sdk/src/utils/get-tracer.ts @@ -0,0 +1,6 @@ +import { version } from '../version' +import { traceProvider } from './trace-provider' + +export function getTracer() { + return traceProvider.getTracer('featureboard-js-sdk', version) +} diff --git a/libs/js-sdk/src/utils/http-log.ts b/libs/js-sdk/src/utils/http-log.ts deleted file mode 100644 index 3a3b08f1..00000000 --- a/libs/js-sdk/src/utils/http-log.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { debugLog } from '../log' - -export const httpClientDebug = debugLog.extend('http-client') diff --git a/libs/js-sdk/src/utils/openTelemetryTracePassthrough.ts b/libs/js-sdk/src/utils/openTelemetryTracePassthrough.ts new file mode 100644 index 00000000..71747f34 --- /dev/null +++ b/libs/js-sdk/src/utils/openTelemetryTracePassthrough.ts @@ -0,0 +1,6 @@ +import { http, passthrough } from 'msw' + +export const openTelemetryTracePassthrough = http.post( + 'http://localhost:4318/v1/traces', + () => passthrough(), +) diff --git a/libs/js-sdk/src/utils/resolve-error.ts b/libs/js-sdk/src/utils/resolve-error.ts new file mode 100644 index 00000000..a1eabdc9 --- /dev/null +++ b/libs/js-sdk/src/utils/resolve-error.ts @@ -0,0 +1,14 @@ +export function resolveError(error: unknown): Error { + if (error instanceof Error) { + return error + } + + if (typeof error === 'string') { + return new Error(error) + } + + console.error('Unknown error type: %o', error) + return new Error('Unknown error', { + cause: error, + }) +} diff --git a/libs/js-sdk/src/utils/retry.spec.ts b/libs/js-sdk/src/utils/retry.spec.ts new file mode 100644 index 00000000..9fced70d --- /dev/null +++ b/libs/js-sdk/src/utils/retry.spec.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest' +import { retry } from './retry' + +it('should retry the function and succeed', async () => { + let attempt = 0 + const mockFn = async () => { + if (attempt < 1) { + attempt++ + throw new Error('Temporary failure') + } + return 'Success' + } + + const result = await retry(mockFn) + + expect(result).toBe('Success') +}) + +it('should retry the function and fail', async () => { + const mockFn = async () => { + throw new Error('Temporary failure') + } + + await expect(retry(mockFn)).rejects.toThrow('Temporary failure') +}) diff --git a/libs/js-sdk/src/utils/retry.ts b/libs/js-sdk/src/utils/retry.ts index cc4380d5..5617d6cd 100644 --- a/libs/js-sdk/src/utils/retry.ts +++ b/libs/js-sdk/src/utils/retry.ts @@ -1,45 +1,111 @@ import { TooManyRequestsError } from '@featureboard/contracts' -import { debugLog } from '../log' +import type { Tracer } from '@opentelemetry/api' +import { SpanStatusCode } from '@opentelemetry/api' +import { getTracer } from './get-tracer' +import { resolveError } from './resolve-error' -const maxRetries = 5 +/** Not including initial execution */ +const maxRetries = process.env.TEST === 'true' ? 2 : 5 const initialDelayMs = process.env.TEST === 'true' ? 1 : 1000 const backoffFactor = 2 export async function retry( fn: () => Promise, cancellationToken = { cancel: false }, - retryAttempt = 0, ): Promise { - try { - return await fn() - } catch (error) { - let retryAfterMs = 0 - if (cancellationToken?.cancel) { - debugLog('Cancel retry function') - return Promise.resolve() - } - if (retryAttempt >= maxRetries) { - // Max retries - throw error - } - if ( - error instanceof TooManyRequestsError && - error.retryAfter > new Date() - ) { - retryAfterMs = error.retryAfter.getTime() - new Date().getTime() - } - const delayMs = - retryAfterMs === 0 - ? initialDelayMs * Math.pow(backoffFactor, retryAttempt) - : retryAfterMs - if (delayMs > 180000) { - // If delay is longer than 3 min throw error - // Todo: Replace with cancellation token with timeout - throw error + const tracer = getTracer() + return tracer.startActiveSpan(`fbsdk-retry`, async (span) => { + let retryAttempt = 0 + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await retryAttemptFn(tracer, retryAttempt, fn) + } catch (error) { + let retryAfterMs = 0 + const err = resolveError(error) + if (cancellationToken?.cancel) { + return Promise.resolve() + } + + if (retryAttempt >= maxRetries) { + span.recordException( + new Error( + 'Operation failed after max retries exceeded', + { cause: err }, + ), + ) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: + 'Operation failed after max retries exceeded', + }) + // Max retries + throw error + } + + if ( + error instanceof TooManyRequestsError && + error.retryAfter > new Date() + ) { + retryAfterMs = + error.retryAfter.getTime() - new Date().getTime() + } + + const delayMs = + retryAfterMs === 0 + ? initialDelayMs * + Math.pow(backoffFactor, retryAttempt) + : retryAfterMs + + if (delayMs > 180000) { + // If delay is longer than 3 min throw error + // Todo: Replace with cancellation token with timeout + span.recordException( + new Error('Operation failed, retry timed out.', { + cause: err, + }), + ) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: 'Operation failed, retry timed out.', + }) + throw error + } + + await tracer.startActiveSpan( + 'fbsdk-trigger-retry-delay', + { attributes: { delayMs } }, + (delaySpan) => + delay(delayMs).finally(() => delaySpan.end()), + ) + + retryAttempt++ + } + } + } finally { + span.end() } - await delay(delayMs) // Wait for the calculated delay - return retry(fn, cancellationToken, retryAttempt + 1) // Retry the operation recursively - } + }) +} + +async function retryAttemptFn( + tracer: Tracer, + retryAttempt: number, + fn: () => Promise, +) { + return await tracer.startActiveSpan( + `fbsdk-retry-attempt-${retryAttempt}`, + { attributes: { retryAttempt } }, + async (attemptSpan) => { + try { + return await fn() + } finally { + attemptSpan.end() + } + }, + ) } function delay(ms: number): Promise { diff --git a/libs/js-sdk/src/utils/start-active-span.ts b/libs/js-sdk/src/utils/start-active-span.ts new file mode 100644 index 00000000..2849a862 --- /dev/null +++ b/libs/js-sdk/src/utils/start-active-span.ts @@ -0,0 +1,23 @@ +import { context, trace, type Span, type SpanOptions } from '@opentelemetry/api' +import { getTracer } from './get-tracer' + +export function startActiveSpan unknown>({ + name, + options, + parentSpan, + fn, +}: { + name: string + options: SpanOptions + parentSpan?: Span + fn: F +}): ReturnType { + // Get context from parent span + const ctx = parentSpan + ? trace.setSpan(context.active(), parentSpan) + : undefined + + return ctx + ? getTracer().startActiveSpan(name, options, ctx, fn) + : getTracer().startActiveSpan(name, options, fn) +} diff --git a/libs/js-sdk/src/utils/trace-provider.ts b/libs/js-sdk/src/utils/trace-provider.ts new file mode 100644 index 00000000..1489c993 --- /dev/null +++ b/libs/js-sdk/src/utils/trace-provider.ts @@ -0,0 +1,10 @@ +import { ProxyTracerProvider, trace } from '@opentelemetry/api' + +export const traceProvider = new ProxyTracerProvider() +const noopTraceProvider = traceProvider.getDelegate() + +export function setTracingEnabled(enabled: boolean) { + traceProvider.setDelegate( + enabled ? trace.getTracerProvider() : noopTraceProvider, + ) +} diff --git a/libs/js-sdk/src/version.ts b/libs/js-sdk/src/version.ts new file mode 100644 index 00000000..b7190d27 --- /dev/null +++ b/libs/js-sdk/src/version.ts @@ -0,0 +1 @@ +export const version = '0.16.0'; diff --git a/libs/js-sdk/test-setup.ts b/libs/js-sdk/test-setup.ts new file mode 100644 index 00000000..d8b3a41c --- /dev/null +++ b/libs/js-sdk/test-setup.ts @@ -0,0 +1,34 @@ +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' +import { NodeSDK } from '@opentelemetry/sdk-node' +import { + ConsoleSpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-node' +import { afterAll, beforeAll } from 'vitest' + +let sdk: NodeSDK | undefined +let spanProcessor: SimpleSpanProcessor | undefined + +beforeAll(({ suite }) => { + const exporter = process.env.OTEL_EXPORTER_OTLP_ENDPOINT + ? new OTLPTraceExporter() + : process.env.OTEL_EXPORTER_OTLP_CONSOLE + ? new ConsoleSpanExporter() + : undefined + spanProcessor = exporter ? new SimpleSpanProcessor(exporter) : undefined + + sdk = spanProcessor + ? new NodeSDK({ + serviceName: 'featureboard-js-sdk-test', + spanProcessor: spanProcessor, + instrumentations: [], + }) + : undefined + + sdk?.start() +}) + +afterAll(async () => { + await spanProcessor?.forceFlush() + await sdk?.shutdown() +}) diff --git a/libs/js-sdk/tsconfig.json b/libs/js-sdk/tsconfig.json index 1b95046f..018f5ffd 100644 --- a/libs/js-sdk/tsconfig.json +++ b/libs/js-sdk/tsconfig.json @@ -3,18 +3,10 @@ "compilerOptions": { "outDir": "./tsc-out", "rootDir": "./src", - "lib": [ - "ES2020", - "DOM" - ], - "types": [ - "node", - ], + "lib": ["ES2022", "DOM"], + "types": ["node"] }, - "include": [ - "src/**/*.ts", - "src/**/*.tsx" - ], + "include": ["src/**/*.ts", "src/**/*.tsx"], "references": [ { "path": "../contracts" diff --git a/libs/js-sdk/vitest.config.ts b/libs/js-sdk/vitest.config.ts index ecf2b903..c109e1e6 100644 --- a/libs/js-sdk/vitest.config.ts +++ b/libs/js-sdk/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ plugins: [tsconfigPaths()], test: { include: ['src/**/*.{test,spec}.{ts,mts,cts,tsx}'], + setupFiles: ['./test-setup.ts'], }, }) diff --git a/libs/node-sdk/.eslintrc.cjs b/libs/node-sdk/.eslintrc.cjs index 49756231..8b70d917 100644 --- a/libs/node-sdk/.eslintrc.cjs +++ b/libs/node-sdk/.eslintrc.cjs @@ -16,5 +16,11 @@ module.exports = { }, }, ], - ignorePatterns: ['!**/*', 'node_modules', 'out-tsc'], + ignorePatterns: [ + '!**/*', + 'node_modules', + 'tsc-out', + 'test-setup.ts', + 'vitest.config.ts', + ], } diff --git a/libs/node-sdk/package.json b/libs/node-sdk/package.json index 95a34f18..77e24996 100644 --- a/libs/node-sdk/package.json +++ b/libs/node-sdk/package.json @@ -33,7 +33,13 @@ }, "dependencies": { "@featureboard/js-sdk": "workspace:*", - "debug": "^4.3.4", + "@opentelemetry/api": "^1.0.0", "promise-completion-source": "^1.0.0" + }, + "devDependencies": { + "@opentelemetry/exporter-trace-otlp-http": "^0.45.1", + "@opentelemetry/sdk-node": "^0.45.1", + "@opentelemetry/sdk-trace-base": "^1.18.1", + "@opentelemetry/sdk-trace-node": "^1.18.1" } } diff --git a/libs/node-sdk/project.json b/libs/node-sdk/project.json index b0ab606f..d6d49d60 100644 --- a/libs/node-sdk/project.json +++ b/libs/node-sdk/project.json @@ -17,6 +17,15 @@ "cwd": "libs/node-sdk" } }, + "version": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "echo \"export const version = '$(jq -r '.version' package.json)';\" > src/version.ts" + ], + "cwd": "libs/node-sdk" + } + }, "package": { "executor": "nx:run-commands", "options": { @@ -28,7 +37,8 @@ ], "cwd": "libs/node-sdk", "parallel": false - } + }, + "dependsOn": ["version"] } } } diff --git a/libs/node-sdk/src/feature-state-store.ts b/libs/node-sdk/src/feature-state-store.ts index 5716fd76..552171b3 100644 --- a/libs/node-sdk/src/feature-state-store.ts +++ b/libs/node-sdk/src/feature-state-store.ts @@ -1,10 +1,17 @@ import type { FeatureConfiguration } from '@featureboard/contracts' +import { resolveError } from '@featureboard/js-sdk' +import { SpanStatusCode } from '@opentelemetry/api' import type { ExternalStateStore } from './external-state-store' -import { debugLog } from './log' +import { getTracer } from './utils/get-tracer' -const stateStoreDebug = debugLog.extend('state-store') +export interface IFeatureStateStore { + all(): Record + get(featureKey: string): FeatureConfiguration | undefined + set(featureKey: string, value: FeatureConfiguration | undefined): void + initialiseFromExternalStateStore(): Promise +} -export class AllFeatureStateStore { +export class AllFeatureStateStore implements IFeatureStateStore { private _store: Record = {} private _externalStateStore: ExternalStateStore | undefined private featureUpdatedCallbacks: Array< @@ -19,44 +26,51 @@ export class AllFeatureStateStore { if (!this._externalStateStore) { return Promise.resolve(false) } - stateStoreDebug('Initialising external state store') + const tracer = getTracer() - try { - const externalStore = await this._externalStateStore.all() + await tracer.startActiveSpan( + 'fbsdk-initialise-from-external-store', + async (externalStoreSpan) => { + try { + const externalStore = await this._externalStateStore!.all() + this._store = { ...externalStore } + Object.keys(externalStore).forEach((key) => { + this.featureUpdatedCallbacks.forEach((valueUpdated) => + valueUpdated(key, externalStore[key]), + ) + }) + } catch (error) { + const err = resolveError(error) + externalStoreSpan.recordException(err) + externalStoreSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }) - this._store = { ...externalStore } - Object.keys(externalStore).forEach((key) => { - this.featureUpdatedCallbacks.forEach((valueUpdated) => - valueUpdated(key, externalStore[key]), - ) - }) - } catch (error: any) { - stateStoreDebug( - 'Failed to initialise all feature state store with external state store', - error, - ) - console.error( - 'Failed to initialise from external state store', - error, - ) - throw error - } + console.error( + 'Failed to initialise from external state store', + error, + ) + throw error + } finally { + externalStoreSpan.end() + } + }, + ) return Promise.resolve(true) } all(): Record { - stateStoreDebug('all: %o', this._store) return { ...this._store } } get(featureKey: string): FeatureConfiguration | undefined { const value = this._store[featureKey] - stateStoreDebug("get '%s': %o", featureKey, value) + return value } set(featureKey: string, value: FeatureConfiguration | undefined) { - stateStoreDebug("set '%s': %o", featureKey, value) this._store[featureKey] = value this.featureUpdatedCallbacks.forEach((valueUpdated) => valueUpdated(featureKey, value), diff --git a/libs/node-sdk/src/index.ts b/libs/node-sdk/src/index.ts index f9647dd6..482db6c7 100644 --- a/libs/node-sdk/src/index.ts +++ b/libs/node-sdk/src/index.ts @@ -2,6 +2,7 @@ export type { FeatureBoardApiConfig, FeatureBoardClient, Features, + createManualClient, } from '@featureboard/js-sdk' export type { ExternalStateStore } from './external-state-store' export { createManualServerClient } from './manual-server-client' diff --git a/libs/node-sdk/src/log.ts b/libs/node-sdk/src/log.ts deleted file mode 100644 index 1b0573ef..00000000 --- a/libs/node-sdk/src/log.ts +++ /dev/null @@ -1,3 +0,0 @@ -import debug from 'debug' - -export const debugLog = debug('@featureboard/node-sdk') diff --git a/libs/node-sdk/src/server-client.ts b/libs/node-sdk/src/server-client.ts index 1d654a20..2db06e3c 100644 --- a/libs/node-sdk/src/server-client.ts +++ b/libs/node-sdk/src/server-client.ts @@ -1,18 +1,25 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import type { + ClientOptions, FeatureBoardApiConfig, FeatureBoardClient, Features, } from '@featureboard/js-sdk' -import { featureBoardHostedService, retry } from '@featureboard/js-sdk' +import { + featureBoardHostedService, + resolveError, + retry, +} from '@featureboard/js-sdk' +import { SpanStatusCode } from '@opentelemetry/api' import { PromiseCompletionSource } from 'promise-completion-source' import type { ExternalStateStore, ServerClient } from '.' +import type { IFeatureStateStore } from './feature-state-store' import { AllFeatureStateStore } from './feature-state-store' -import { debugLog } from './log' import { resolveUpdateStrategy } from './update-strategies/resolveUpdateStrategy' import type { UpdateStrategies } from './update-strategies/update-strategies' - -const serverConnectionDebug = debugLog.extend('server-connection') +import { DebugFeatureStateStore } from './utils/debug-store' +import { getTracer } from './utils/get-tracer' +import { setTracingEnabled } from './utils/trace-provider' export interface CreateServerClientOptions { /** Connect to a self hosted instance of FeatureBoard */ @@ -36,6 +43,8 @@ export interface CreateServerClientOptions { updateStrategy?: UpdateStrategies | UpdateStrategies['kind'] environmentApiKey: string + + options?: ClientOptions } export function createServerClient({ @@ -43,11 +52,22 @@ export function createServerClient({ externalStateStore, updateStrategy, environmentApiKey, + options, }: CreateServerClientOptions): ServerClient { + // enable or disable OpenTelemetry, enabled by default + setTracingEnabled(!options || !options.disableOTel) + const tracer = getTracer() + const initialisedPromise = new PromiseCompletionSource() // Ensure that the init promise doesn't cause an unhandled promise rejection initialisedPromise.promise.catch(() => {}) - const stateStore = new AllFeatureStateStore(externalStateStore) + let stateStore: IFeatureStateStore = new AllFeatureStateStore( + externalStateStore, + ) + if (process.env.FEATUREBOARD_SDK_DEBUG) { + stateStore = new DebugFeatureStateStore(stateStore) + } + const updateStrategyImplementation = resolveUpdateStrategy( updateStrategy, environmentApiKey, @@ -55,41 +75,51 @@ export function createServerClient({ ) const retryCancellationToken = { cancel: false } - retry(async () => { - try { - serverConnectionDebug('Connecting to SDK...') - return await updateStrategyImplementation.connect(stateStore) - } catch (error) { - serverConnectionDebug( - 'Failed to connect to SDK, try to initialise form external state store', - error, - ) - // Try initialise external state store - const result = await stateStore.initialiseFromExternalStateStore() - if (!result) { - // No external state store, throw original error - console.error('Failed to connect to SDK', error) - throw error - } - serverConnectionDebug('Initialised from external state store') - return Promise.resolve() - } - }, retryCancellationToken) - .then(() => { - if (!initialisedPromise.completed) { - serverConnectionDebug('Server client is initialised') - initialisedPromise.resolve(true) - } - }) - .catch((err) => { - if (!initialisedPromise.completed) { - console.error( - 'FeatureBoard SDK failed to connect after 5 retries', - err, - ) - initialisedPromise.reject(err) - } - }) + + void tracer.startActiveSpan( + 'fbsdk-connect-with-retry', + { + attributes: {}, + }, + (connectWithRetrySpan) => + retry(async () => { + try { + return await updateStrategyImplementation.connect( + stateStore, + ) + } catch (error) { + const err = resolveError(error) + connectWithRetrySpan.recordException(err) + + // Try initialise external state store + const result = + await stateStore.initialiseFromExternalStateStore() + + if (!result) { + // No external state store, throw original error + console.error('Failed to connect to SDK', error) + throw error + } + + return Promise.resolve() + } + }, retryCancellationToken) + .then(() => { + initialisedPromise.resolve(true) + }) + .catch((err) => { + console.error( + 'FeatureBoard SDK failed to connect after 5 retries', + err, + ) + initialisedPromise.reject(err) + connectWithRetrySpan.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }) + }) + .finally(() => connectWithRetrySpan.end()), + ) return { get initialised() { @@ -100,20 +130,44 @@ export function createServerClient({ return updateStrategyImplementation.close() }, request: (audienceKeys: string[]) => { - const request = updateStrategyImplementation.onRequest() + return tracer.startActiveSpan( + 'fbsdk-get-request-client', + { attributes: { audiences: audienceKeys } }, + (span) => { + const request = updateStrategyImplementation.onRequest() + + if (request) { + return addUserWarnings( + request.then(() => { + span.end() + return syncRequest(stateStore, audienceKeys) + }), + ) + } - serverConnectionDebug( - 'Creating request client for audiences: %o', - audienceKeys, + try { + return makeRequestClient( + syncRequest(stateStore, audienceKeys), + ) + } finally { + span.end() + } + }, ) - return request - ? addUserWarnings( - request.then(() => syncRequest(stateStore, audienceKeys)), - ) - : makeRequestClient(syncRequest(stateStore, audienceKeys)) }, updateFeatures() { - return updateStrategyImplementation.updateFeatures() + return tracer.startActiveSpan( + 'fbsdk-update-features-manually', + (span) => { + try { + return updateStrategyImplementation + .updateFeatures() + .then(() => span.end()) + } finally { + span.end() + } + }, + ) }, waitForInitialised() { return initialisedPromise.promise @@ -147,7 +201,7 @@ export function makeRequestClient( } function syncRequest( - stateStore: AllFeatureStateStore, + stateStore: IFeatureStateStore, audienceKeys: string[], ): FeatureBoardClient { // Shallow copy the feature state so requests are stable @@ -155,18 +209,28 @@ function syncRequest( const client: FeatureBoardClient = { getEffectiveValues: () => { - return { - audiences: audienceKeys, - effectiveValues: Object.keys(featuresState) - .map((key) => ({ - featureKey: key, - // We will filter the invalid undefined in the next filter - value: getFeatureValue(key, undefined!), - })) - .filter( - (effectiveValue) => effectiveValue.value !== undefined, - ), - } + return getTracer().startActiveSpan( + 'fbsdk-get-effective-values', + (span) => { + try { + return { + audiences: audienceKeys, + effectiveValues: Object.keys(featuresState) + .map((key) => ({ + featureKey: key, + // We will filter the invalid undefined in the next filter + value: getFeatureValue(key, undefined!), + })) + .filter( + (effectiveValue) => + effectiveValue.value !== undefined, + ), + } + } finally { + span.end() + } + }, + ) }, getFeatureValue, subscribeToFeatureValue: ( @@ -183,24 +247,40 @@ function syncRequest( featureKey: T, defaultValue: Features[T], ) { - const featureValues = featuresState[featureKey as string] - if (!featureValues) { - serverConnectionDebug( - 'getFeatureValue - no value, returning user fallback: %o', - audienceKeys, - ) - return defaultValue - } - const audienceException = featureValues.audienceExceptions.find((a) => - audienceKeys.includes(a.audienceKey), + return getTracer().startActiveSpan( + 'fbsdk-get-feature-value', + (span) => { + try { + const featureValues = featuresState[featureKey as string] + if (!featureValues) { + span.addEvent( + 'getFeatureValue - no value, returning user fallback: ', + { + audienceKeys, + 'feature.key': featureKey, + 'feature.defaultValue': defaultValue, + }, + ) + + return defaultValue + } + const audienceException = + featureValues.audienceExceptions.find((a) => + audienceKeys.includes(a.audienceKey), + ) + const value = + audienceException?.value ?? featureValues.defaultValue + span.setAttributes({ + 'feature.key': featureKey, + 'feature.value': value, + 'feature.defaultValue': defaultValue, + }) + return value + } finally { + span.end() + } + }, ) - const value = audienceException?.value ?? featureValues.defaultValue - serverConnectionDebug('getFeatureValue: %o', { - audienceExceptionValue: audienceException?.value, - defaultValue: featureValues.defaultValue, - value, - }) - return value } return client diff --git a/libs/node-sdk/src/tests/http-client.spec.ts b/libs/node-sdk/src/tests/http-client.spec.ts index 85003ec6..77934982 100644 --- a/libs/node-sdk/src/tests/http-client.spec.ts +++ b/libs/node-sdk/src/tests/http-client.spec.ts @@ -4,478 +4,290 @@ import { } from '@featureboard/contracts' import { featureBoardHostedService } from '@featureboard/js-sdk' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' import { describe, expect, it } from 'vitest' import { createServerClient } from '../server-client' +import { featureBoardFixture } from '../utils/featureboard-fixture' import { MockExternalStateStore } from './mock-external-state-store' -describe('http client', () => { - it('calls featureboard /all endpoint on creation', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - const valueBeforeInit = httpClient - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(httpClient.initialised).toBe(false) - expect(valueBeforeInit).toBe('default-value') - - await httpClient.waitForInitialised() - - const valueAfterInit = httpClient - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(httpClient.initialised).toBe(true) - expect(valueAfterInit).toBe('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('can wait for initialisation', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - await httpClient.waitForInitialised() - - const value = httpClient - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(httpClient.initialised).toBe(true) - expect(value).toBe('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Gets value using audience exceptions', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [ - { - audienceKey: 'my-audience', - value: 'audience-exception-value', - }, - ], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - await httpClient.waitForInitialised() - - const value = httpClient - .request(['my-audience']) - .getFeatureValue('my-feature', 'default-value') - expect(httpClient.initialised).toBe(true) - expect(value).toBe('audience-exception-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('can manually fetch updates', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - { - featureKey: 'my-feature-2', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const newValues: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'new-service-default-value', - }, - { - featureKey: 'my-feature-3', - audienceExceptions: [], - defaultValue: 'new-service-default-value', +describe('Http client', () => { + it( + 'calls featureboard /all endpoint on creation', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + const valueBeforeInit = httpClient + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(httpClient.initialised).toBe(false) + expect(valueBeforeInit).toBe('default-value') + + await httpClient.waitForInitialised() + + const valueAfterInit = httpClient + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(httpClient.initialised).toBe(true) + expect(valueAfterInit).toBe('service-default-value') }, - ] - - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) - } - - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - await httpClient.waitForInitialised() - await httpClient.updateFeatures() - - const value = httpClient - .request([]) - .getFeatureValue('my-feature', 'default-value') - - const value2 = httpClient - .request([]) - .getFeatureValue('my-feature-2', 'default-value') - - const value3 = httpClient - .request([]) - .getFeatureValue('my-feature-3', 'default-value') - expect(httpClient.initialised).toBe(true) - expect(value).toBe('new-service-default-value') - // This was removed from the server - expect(value2).toBe('default-value') - expect(value3).toBe('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Retry to connect when received 429 from HTTP Client API during initialization', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + ), + ) + + it( + 'can wait for initialisation', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + await httpClient.waitForInitialised() + + const value = httpClient + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(httpClient.initialised).toBe(true) + expect(value).toBe('service-default-value') }, - ] - - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - count++ - if (count <= 2) { - return new Response(null, { - status: 429, - headers: { 'Retry-After': '1' }, - }) - } - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), - }, + ), + ) + + it( + 'Gets value using audience exceptions', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [ + { + audienceKey: 'my-audience', + value: 'audience-exception-value', + }, + ], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - await httpClient.waitForInitialised() - httpClient.close() - } finally { - server.resetHandlers() - server.close() - } - expect(count).toBe(3) - }) - - it('Block HTTP client API call after 429 response from HTTP Client API according to retry-after seconds', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + await httpClient.waitForInitialised() + + const value = httpClient + .request(['my-audience']) + .getFeatureValue('my-feature', 'default-value') + expect(httpClient.initialised).toBe(true) + expect(value).toBe('audience-exception-value') }, - ] - - let count = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), + ), + ) + + it( + 'can manually fetch updates', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + { + featureKey: 'my-feature-3', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) + } + + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', }, - }) - }, - { once: true }, - ), - http.get( - 'https://client.featureboard.app/all', - () => { - count++ - return new Response(null, { - status: 429, - headers: { 'Retry-After': '1' }, - }) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/all', () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), - }, + { + featureKey: 'my-feature-2', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) + }), + ], + async () => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await new Promise((resolve) => setTimeout(resolve, 1000)) - await httpClient.updateFeatures() - - httpClient.close() - } finally { - server.resetHandlers() - server.close() - } - expect(count).toBe(3) - }) - - it('Block HTTP client API call after 429 response from HTTP Client API according to retry-after date', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + await httpClient.waitForInitialised() + await httpClient.updateFeatures() + + const value = httpClient + .request([]) + .getFeatureValue('my-feature', 'default-value') + + const value2 = httpClient + .request([]) + .getFeatureValue('my-feature-2', 'default-value') + + const value3 = httpClient + .request([]) + .getFeatureValue('my-feature-3', 'default-value') + expect(httpClient.initialised).toBe(true) + expect(value).toBe('new-service-default-value') + // This was removed from the server + expect(value2).toBe('default-value') + expect(value3).toBe('new-service-default-value') }, - ] - - let count = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), - }, - }) - }, - { once: true }, - ), - http.get( - 'https://client.featureboard.app/all', - () => { - count++ - const retryAfter = new Date(new Date().getTime() + 1000).toUTCString() - return new Response(null, { - status: 429, - headers: { 'Retry-After': retryAfter }, - }) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/all', () => { - count++ - return HttpResponse.json(values, { - headers: { - etag: new Date().toISOString(), + ), + ) + + // Below tests are testing behavior around https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match + it( + 'Attaches etag header to update requests', + featureBoardFixture( + { lastModified: new Date().toISOString() }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/all', + ({ request }) => { + if ( + request.headers.get('if-none-match') === + testContext.lastModified + ) { + return new Response(null, { status: 304 }) + } + + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: testContext.lastModified, + }, + }, + ) }, + ), + ], + async () => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.waitForInitialised() - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await expect(() => - httpClient.updateFeatures(), - ).rejects.toThrowError(TooManyRequestsError) - await new Promise((resolve) => setTimeout(resolve, 1000)) - await httpClient.updateFeatures() - - httpClient.close() - } finally { - server.resetHandlers() - server.close() - } - expect(count).toBe(3) - }) - // Below tests are testing behavior around https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match - it('Attaches etag header to update requests', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + await httpClient.updateFeatures() }, - ] - const lastModified = new Date().toISOString() - - const server = setupServer( - http.get('https://client.featureboard.app/all', ({ request }) => { - if (request.headers.get('if-none-match') === lastModified) { - return new Response(null, { status: 304 }) - } - - return HttpResponse.json(values, { - headers: { - etag: lastModified, - }, + ), + ) + + it( + 'Initialisation fails, reties and succeeds, no external state store', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json( + { message: 'Test FeatureBoard API Error' }, + { status: 500 }, + ), + { once: true }, + ), + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.updateFeatures() - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Initialisation fails, reties and succeeds, no external state store', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value', + + await client.waitForInitialised() + expect(client.initialised).toEqual(true) + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('service-value') }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json( - { message: 'Test FeatureBoard API Error' }, - { status: 500 }, - ), - { once: true }, - ), - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await client.waitForInitialised() - expect(client.initialised).toEqual(true) - const value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('service-value') - } finally { - server.resetHandlers() - server.close() - } - }) + ), + ) it( 'Initialisation retries 5 time then throws an error, no external state store', - async () => { - let count = 0 - const server = setupServer( + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/all', () => { - count++ + testContext.count++ return HttpResponse.json( { message: 'Test FeatureBoard API Error', @@ -483,10 +295,8 @@ describe('http client', () => { { status: 500 }, ) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async ({ testContext }) => { const client = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -496,75 +306,68 @@ describe('http client', () => { await expect(async () => { await client.waitForInitialised() }).rejects.toThrowError('500') - expect(count).toEqual(5 + 1) // initial request and 5 retry - } finally { - server.resetHandlers() - server.close() - } - }, - { timeout: 60000 }, + expect(testContext.count).toEqual(2 + 1) // initial request and 5 retry + }, + ), ) - it('Use external state store when API request fails', async () => { - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - return HttpResponse.json( - { message: 'Test FeatureBoard API Error' }, - { status: 500 }, - ) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - externalStateStore: new MockExternalStateStore( - () => - Promise.resolve({ - 'my-feature': { - featureKey: 'my-feature', - defaultValue: 'external-state-store-value', - audienceExceptions: [], - }, - }), - () => { - return Promise.resolve() - }, - ), - }) - - await client.waitForInitialised() - expect(client.initialised).toEqual(true) - const value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('external-state-store-value') - } finally { - server.resetHandlers() - server.close() - } - }) + it( + 'Use external state store when API request fails', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/all', () => { + return HttpResponse.json( + { message: 'Test FeatureBoard API Error' }, + { status: 500 }, + ) + }), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + externalStateStore: new MockExternalStateStore( + () => + Promise.resolve({ + 'my-feature': { + featureKey: 'my-feature', + defaultValue: 'external-state-store-value', + audienceExceptions: [], + }, + }), + () => { + return Promise.resolve() + }, + ), + }) + + await client.waitForInitialised() + expect(client.initialised).toEqual(true) + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('external-state-store-value') + }, + ), + ) it( 'Initialisation retries 5 time then throws an error with external state store', - async () => { - let countAPIRequest = 0 - let countExternalStateStoreRequest = 0 - const server = setupServer( + featureBoardFixture( + { countAPIRequest: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/all', () => { - countAPIRequest++ + testContext.countAPIRequest++ return HttpResponse.json( { message: 'Test FeatureBoard API Error' }, { status: 500 }, ) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async ({ testContext }) => { + let countExternalStateStoreRequest = 0 const client = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -585,171 +388,440 @@ describe('http client', () => { await expect(async () => { await client.waitForInitialised() }).rejects.toThrowError('Test External State Store Error') - expect(countAPIRequest).toEqual(5 + 1) // initial request and 5 retry - expect(countExternalStateStoreRequest).toEqual(5 + 1) // initial request and 5 retry - } finally { - server.resetHandlers() - server.close() - } - }, - { timeout: 60000 }, + expect(testContext.countAPIRequest).toEqual(2 + 1) // initial request and 5 retry + expect(countExternalStateStoreRequest).toEqual(2 + 1) // initial request and 5 retry + }, + ), ) - it('Update external state store when internal store updates', async () => { - expect.assertions(1) + it( + 'Update external state store when internal store updates', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value', + }, + ]), + { once: true }, + ), + ], + async () => { + expect.assertions(1) - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value', + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + externalStateStore: new MockExternalStateStore( + () => + Promise.resolve({ + 'my-feature': { + featureKey: 'my-feature', + defaultValue: 'external-state-store-value', + audienceExceptions: [], + }, + }), + (store) => { + expect(store['my-feature']?.defaultValue).toEqual( + 'service-value', + ) + return Promise.resolve() + }, + ), + }) + await client.waitForInitialised() }, - ] - - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - externalStateStore: new MockExternalStateStore( + ), + ) + + it( + 'Catch error when update external state store throws error', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', () => - Promise.resolve({ - 'my-feature': { + HttpResponse.json([ + { featureKey: 'my-feature', - defaultValue: 'external-state-store-value', audienceExceptions: [], + defaultValue: 'service-value', }, - }), - (store) => { - expect(store['my-feature']?.defaultValue).toEqual( - 'service-value', - ) - return Promise.resolve() - }, + ]), + { once: true }, ), - }) - await client.waitForInitialised() - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Catch error when update external state store throws error', async () => { - expect.assertions(1) - - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value', + ], + async () => { + expect.assertions(1) + + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + externalStateStore: new MockExternalStateStore( + () => + Promise.resolve({ + 'my-feature': { + featureKey: 'my-feature', + defaultValue: 'external-state-store-value', + audienceExceptions: [], + }, + }), + (store) => { + expect(store['my-feature']?.defaultValue).toEqual( + 'service-value', + ) + return Promise.reject( + 'Error occurred in external state store', + ) + }, + ), + }) + await client.waitForInitialised() }, - ] - - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - externalStateStore: new MockExternalStateStore( + ), + ) + + it( + 'Subscription to feature value immediately return current value but will not be called again', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', () => - Promise.resolve({ - 'my-feature': { + HttpResponse.json([ + { featureKey: 'my-feature', - defaultValue: 'external-state-store-value', audienceExceptions: [], + defaultValue: 'service-value', + }, + ]), + { once: true }, + ), + http.get('https://client.featureboard.app/all', () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value2', + }, + ]), + ), + ], + async () => { + let count = 0 + expect.assertions(2) + + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await client.waitForInitialised() + + client + .request([]) + .subscribeToFeatureValue( + 'my-feature', + 'default-value', + (value) => { + count++ + expect(value).toEqual('service-value') + }, + ) + + await client.updateFeatures() + + expect(count).toEqual(1) + }, + ), + ) + + it( + 'Initialisation retries when Too Many Requests (429) returned from Client HTTP API', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + testContext.count++ + if (testContext.count <= 2) { + return new Response(null, { + status: 429, + headers: { 'Retry-After': '1' }, + }) + } + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + await httpClient.waitForInitialised() + httpClient.close() + + expect(testContext.count).toBe(3) + }, + ), + ) + + it( + 'Update features blocked after Too Many Requests (429) received with retry-after header using seconds', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, }, - }), - (store) => { - expect(store['my-feature']?.defaultValue).toEqual( - 'service-value', ) - return Promise.reject() }, + { once: true }, ), - }) - await client.waitForInitialised() - } finally { - server.resetHandlers() - server.close() - } - }) - - it('Subscription to feature value immediately return current value but will not be called again', async () => { - let count = 0 - expect.assertions(2) - - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value', - }, - ] + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.count++ + return new Response(null, { + status: 429, + headers: { 'Retry-After': '1' }, + }) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/all', () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - const values2ndRequest: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value2', + await httpClient.waitForInitialised() + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await new Promise((resolve) => setTimeout(resolve, 1000)) + await httpClient.updateFeatures() + + httpClient.close() + + expect(testContext.count).toBe(3) }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - http.get('https://client.featureboard.app/all', () => - HttpResponse.json(values2ndRequest), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await client.waitForInitialised() - - client - .request([]) - .subscribeToFeatureValue( - 'my-feature', - 'default-value', - (value) => { - count++ - expect(value).toEqual('service-value') + ), + ) + + it( + 'Update features blocked after Too Many Requests (429) received with retry-after header using UTC date time', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) }, - ) + { once: true }, + ), + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.count++ + const retryAfter = new Date( + new Date().getTime() + 1000, + ).toUTCString() + return new Response(null, { + status: 429, + headers: { 'Retry-After': retryAfter }, + }) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/all', () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await httpClient.waitForInitialised() + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await new Promise((resolve) => setTimeout(resolve, 1000)) + await httpClient.updateFeatures() - await client.updateFeatures() + httpClient.close() - expect(count).toEqual(1) - } finally { - server.resetHandlers() - server.close() - } - }) + expect(testContext.count).toBe(3) + }, + ), + ) + + it( + 'Update features blocked after Too Many Requests (429) received with undefined retry-after header', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }, + { once: true }, + ), + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.count++ + return new Response(null, { + status: 429, + }) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/all', () => { + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: new Date().toISOString(), + }, + }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await httpClient.waitForInitialised() + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + await expect(() => + httpClient.updateFeatures(), + ).rejects.toThrowError(TooManyRequestsError) + httpClient.close() + expect(testContext.count).toBe(2) + }, + ), + ) }) diff --git a/libs/node-sdk/src/tests/mode-manual.spec.ts b/libs/node-sdk/src/tests/mode-manual.spec.ts index 89ceedff..f63889ff 100644 --- a/libs/node-sdk/src/tests/mode-manual.spec.ts +++ b/libs/node-sdk/src/tests/mode-manual.spec.ts @@ -1,173 +1,163 @@ import type { FeatureConfiguration } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' import { describe, expect, it } from 'vitest' import { createServerClient } from '../server-client' - -describe('Manual update mode', () => { - it('fetches initial values', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'manual', - }) - expect(client.initialised).toBe(false) - await client.waitForInitialised() - - const value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('can manually update values', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const newValues: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'new-service-default-value', - }, - ] - - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) - } - - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'manual', - }) - await client.waitForInitialised() - await client.updateFeatures() - - expect( - client +import { featureBoardFixture } from '../utils/featureboard-fixture' + +describe('manual update mode', () => { + it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'manual', + }) + expect(client.initialised).toBe(false) + await client.waitForInitialised() + + const value = client .request([]) - .getFeatureValue('my-feature', 'default-value'), - ).toEqual('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('can manually update audience exception values', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [ - { audienceKey: 'aud', value: 'aud-value' }, - ], - defaultValue: 'service-default-value', + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('service-default-value') }, - ] - const newValues: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [ - { audienceKey: 'aud', value: 'new-aud-value' }, - ], - defaultValue: 'new-service-default-value', + ), + ) + + it( + 'can manually update values', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) + } + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) + }), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'manual', + }) + await client.waitForInitialised() + await client.updateFeatures() + + expect( + client + .request([]) + .getFeatureValue('my-feature', 'default-value'), + ).toEqual('new-service-default-value') }, - ] - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) - } - - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'manual', - }) - await client.waitForInitialised() - await client.updateFeatures() - - expect( - client - .request(['aud']) - .getFeatureValue('my-feature', 'default-value'), - ).toEqual('new-aud-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('close', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + ), + ) + + it( + 'can manually update audience exception values', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [ + { + audienceKey: 'aud', + value: 'new-aud-value', + }, + ], + defaultValue: 'new-service-default-value', + }, + ]) + } + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [ + { audienceKey: 'aud', value: 'aud-value' }, + ], + defaultValue: 'service-default-value', + }, + ]) + }), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'manual', + }) + await client.waitForInitialised() + await client.updateFeatures() + + expect( + client + .request(['aud']) + .getFeatureValue('my-feature', 'default-value'), + ).toEqual('new-aud-value') }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'manual', - }) - - client.close() - } finally { - server.resetHandlers() - server.close() - } - }) + ), + ) + + it( + 'close', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'manual', + }) + + client.close() + }, + ), + ) }) declare module '@featureboard/js-sdk' { diff --git a/libs/node-sdk/src/tests/mode-on-request.spec.ts b/libs/node-sdk/src/tests/mode-on-request.spec.ts index 68329c20..6ec23539 100644 --- a/libs/node-sdk/src/tests/mode-on-request.spec.ts +++ b/libs/node-sdk/src/tests/mode-on-request.spec.ts @@ -1,241 +1,223 @@ import type { FeatureConfiguration } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' import { describe, expect, it } from 'vitest' import { createServerClient } from '../server-client' - -describe('On request update mode', () => { - it('fetches initial values', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', +import { featureBoardFixture } from '../utils/featureboard-fixture' + +describe('on request update mode', () => { + it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'on-request', + }) + await client.waitForInitialised() + + const requestClient = await client.request([]) + const value = requestClient.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-default-value') }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'on-request', - }) - await client.waitForInitialised() + ), + ) + + it( + 'throws if request() is not awaited in request mode', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'on-request', + }) - const requestClient = await client.request([]) - const value = requestClient.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect.assertions(1) - }) + await client.waitForInitialised() - it('throws if request() is not awaited in request mode', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + expect(() => + client + .request([]) + .getFeatureValue('my-feature', 'default-value'), + ).toThrow( + 'request() must be awaited when using on-request update strategy', + ) }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'on-request', - }) - - await client.waitForInitialised() - - expect(() => - client - .request([]) - .getFeatureValue('my-feature', 'default-value'), - ).toThrow( - 'request() must be awaited when using on-request update strategy', - ) - } finally { - server.resetHandlers() - server.close() - } - expect.assertions(1) - }) + ), + ) // To reduce load on the FeatureBoard server, we only fetch the values once they are considered old // The maxAge can be configured in the client to be 0 to always check for updates - it('does not fetch update when response is not expired', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const newValues: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'new-service-default-value', - }, - ] - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) - } - - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const connection = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'on-request', - }) - - const client = await connection.request([]) + it( + 'does not fetch update when response is not expired', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) + } + + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) + }), + ], + async () => { + const connection = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'on-request', + }) - expect( - client.getFeatureValue('my-feature', 'default-value'), - ).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect.assertions(1) - }) + const client = await connection.request([]) - it('fetches update when response is expired', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + expect( + client.getFeatureValue('my-feature', 'default-value'), + ).toEqual('service-default-value') }, - ] - const newValues: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'new-service-default-value', - }, - ] - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) - } - - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - try { - const connection = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: { - kind: 'on-request', - options: { maxAgeMs: 1 }, - }, - }) - await connection.waitForInitialised() - - // Ensure response has expired - await new Promise((resolve) => setTimeout(resolve, 10)) + ), + ) + + it( + 'fetches update when response is expired', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) + } + + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) + }), + ], + async () => { + const connection = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: { + kind: 'on-request', + options: { maxAgeMs: 1 }, + }, + }) + await connection.waitForInitialised() - const client = await connection.request([]) - expect( - client.getFeatureValue('my-feature', 'default-value'), - ).toEqual('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect.assertions(1) - }) + // Ensure response has expired + await new Promise((resolve) => setTimeout(resolve, 10)) - it('Do NOT throw error or make call to HTTP Client API when Too Many Requests (429) has been returned', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + const client = await connection.request([]) + expect( + client.getFeatureValue('my-feature', 'default-value'), + ).toEqual('new-service-default-value') }, - ] - - let countAPICalls = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => { - countAPICalls++ - return HttpResponse.json(values) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/all', () => { - countAPICalls++ - return new Response(null, { - status: 429, - headers: { 'Retry-After': '2' }, + ), + ) + + it( + 'suppress errors during feature updates', + featureBoardFixture( + { countAPICalls: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.countAPICalls++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/all', () => { + testContext.countAPICalls++ + return new Response(null, { + status: 429, + headers: { 'Retry-After': '2' }, + }) + }), + ], + async ({ testContext }) => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: { + kind: 'on-request', + options: { maxAgeMs: 100 }, + }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: { - kind: 'on-request', - options: { maxAgeMs: 100 }, - }, - }) - await client.waitForInitialised() - // Wait for the on-request max age to expire - await new Promise((resolve) => setTimeout(resolve, 100)) - await client.request([]) - // Wait for the on-request max age to expire - await new Promise((resolve) => setTimeout(resolve, 100)) - const requestClient = await client.request([]) - const value = requestClient.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect(countAPICalls).toBe(2) - expect.assertions(2) - }) + await client.waitForInitialised() + // Wait for the on-request max age to expire + await new Promise((resolve) => setTimeout(resolve, 100)) + await client.request([]) + // Wait for the on-request max age to expire + await new Promise((resolve) => setTimeout(resolve, 100)) + const requestClient = await client.request([]) + const value = requestClient.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-default-value') + expect(testContext.countAPICalls).toBe(2) + }, + ), + ) }) declare module '@featureboard/js-sdk' { diff --git a/libs/node-sdk/src/tests/mode-polling.spec.ts b/libs/node-sdk/src/tests/mode-polling.spec.ts index 48f68c31..f383d374 100644 --- a/libs/node-sdk/src/tests/mode-polling.spec.ts +++ b/libs/node-sdk/src/tests/mode-polling.spec.ts @@ -1,209 +1,195 @@ import type { FeatureConfiguration } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' import { beforeEach, describe, expect, it, vi } from 'vitest' import { interval } from '../interval' import { createServerClient } from '../server-client' +import { featureBoardFixture } from '../utils/featureboard-fixture' beforeEach(() => { interval.set = setInterval interval.clear = clearInterval }) -describe('Polling update mode', () => { - it('fetches initial values', async () => { - interval.set = vi.fn(() => {}) as any - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'polling', - }) - await client.waitForInitialised() - - const value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect.assertions(1) - }) - - it('sets up interval correctly', async () => { - const handle = {} - interval.set = vi.fn(() => { - return handle - }) as any - interval.clear = vi.fn(() => {}) +describe('polling update mode', () => { + it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + interval.set = vi.fn(() => {}) as any + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'polling', + }) + await client.waitForInitialised() - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('service-default-value') }, - ] - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => HttpResponse.json(values), - { once: true }, - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'polling', - }) - client.close() - - expect(interval.set).toBeCalled() - expect(interval.clear).toBeCalledWith(handle) - } finally { - server.resetHandlers() - server.close() - } - expect.assertions(2) - }) - - it('fetches updates when interval fires', async () => { - const setMock = vi.fn(() => {}) - interval.set = setMock as any + ), + ) + + it( + 'sets up interval correctly', + featureBoardFixture( + {}, + () => [ + http.get( + 'https://client.featureboard.app/all', + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), + { once: true }, + ), + ], + async () => { + const handle = {} + interval.set = vi.fn(() => { + return handle + }) as any + interval.clear = vi.fn(() => {}) + + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'polling', + }) + client.close() - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + expect(interval.set).toBeCalled() + expect(interval.clear).toBeCalledWith(handle) }, - ] - const newValues: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'new-service-default-value', - }, - ] - let count = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - if (count > 1) { - throw new Error('Too many requests') - } - if (count > 0) { - return HttpResponse.json(newValues) - } - - count++ - return HttpResponse.json(values) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'polling', - }) - await client.waitForInitialised() - - const pollCallback = (setMock.mock.calls[0] as any)[0] - await pollCallback() + ), + ) + + it( + 'fetches updates when interval fires', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + if (testContext.count > 1) { + throw new Error('Too many requests') + } + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) + } + + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) + }), + ], + async () => { + const setMock = vi.fn(() => {}) + interval.set = setMock as any + + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'polling', + }) + await client.waitForInitialised() - const value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect.assertions(1) - }) + const pollCallback = (setMock.mock.calls[0] as any)[0] + await pollCallback() - it('Do NOT throw error or make call to HTTP Client API when Too Many Requests (429) has been returned', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('new-service-default-value') }, - ] - - let countAPICalls = 0 - const server = setupServer( - http.get( - 'https://client.featureboard.app/all', - () => { - countAPICalls++ - return HttpResponse.json(values) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/all', () => { - countAPICalls++ - return new Response(null, { - status: 429, - headers: { 'Retry-After': '2' }, + ), + ) + + it( + 'suppresses errors during feature updates', + featureBoardFixture( + { countAPICalls: 0 }, + (testContext) => [ + http.get( + 'https://client.featureboard.app/all', + () => { + testContext.countAPICalls++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) + }, + { once: true }, + ), + http.get('https://client.featureboard.app/all', () => { + testContext.countAPICalls++ + return new Response(null, { + status: 429, + headers: { 'Retry-After': '2' }, + }) + }), + ], + async ({ testContext }) => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: { + kind: 'polling', + options: { intervalMs: 100 }, + }, }) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: { - kind: 'polling', - options: { intervalMs: 100 }, - }, - }) - await client.waitForInitialised() - // Wait for the interval to expire - await new Promise((resolve) => setTimeout(resolve, 100)) - const requestClient1 = await client.request([]) - const value1 = requestClient1.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value1).toEqual('service-default-value') - // Wait for interval to expire - await new Promise((resolve) => setTimeout(resolve, 100)) - const requestClient2 = await client.request([]) - const value2 = requestClient2.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value2).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - expect(countAPICalls).toBe(2) - expect.assertions(3) - }) + await client.waitForInitialised() + // Wait for the interval to expire + await new Promise((resolve) => setTimeout(resolve, 100)) + const requestClient1 = await client.request([]) + const value1 = requestClient1.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value1).toEqual('service-default-value') + // Wait for interval to expire + await new Promise((resolve) => setTimeout(resolve, 100)) + const requestClient2 = await client.request([]) + const value2 = requestClient2.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value2).toEqual('service-default-value') + expect(testContext.countAPICalls).toBe(2) + expect.assertions(3) + }, + ), + ) }) - declare module '@featureboard/js-sdk' { interface Features extends Record {} } diff --git a/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts index 005a4938..d49b62ba 100644 --- a/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts @@ -19,7 +19,6 @@ export function createManualUpdateStrategy( environmentApiKey, stateStore, etag, - 'manual', ) }) diff --git a/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts index 2d0aa958..01feaf59 100644 --- a/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts @@ -1,8 +1,12 @@ -import { createEnsureSingleWithBackoff } from '@featureboard/js-sdk' +import { + createEnsureSingleWithBackoff, + resolveError, +} from '@featureboard/js-sdk' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' +import { getTracer } from '../utils/get-tracer' import { getAllEndpoint } from './getAllEndpoint' -import type { AllConfigUpdateStrategy } from './update-strategies' -import { updatesLog } from './updates-log' +import { type AllConfigUpdateStrategy } from './update-strategies' +import { addDebugEvent } from '../utils/add-debug-event' export function createOnRequestUpdateStrategy( environmentApiKey: string, @@ -23,7 +27,6 @@ export function createOnRequestUpdateStrategy( environmentApiKey, stateStore, etag, - 'on-request', ) }) @@ -43,31 +46,42 @@ export function createOnRequestUpdateStrategy( } }, async onRequest() { - if (fetchUpdatesSingle) { - const now = Date.now() - if (!responseExpires || now >= responseExpires) { - return fetchUpdatesSingle() - .then(() => { - responseExpires = now + maxAgeMs - updatesLog( - 'Response expired, fetching updates: %o', - { - maxAgeMs, - newExpiry: responseExpires, - }, - ) - }) - .catch((error: Error) => { - updatesLog(error) + return getTracer().startActiveSpan( + 'fbsdk-on-request', + { attributes: { etag } }, + async (span) => { + if (fetchUpdatesSingle) { + const now = Date.now() + if (!responseExpires || now >= responseExpires) { + span.addEvent('Response expired, fetching update', { + maxAgeMs, + expiry: responseExpires, + }) + return fetchUpdatesSingle() + .then(() => { + responseExpires = now + maxAgeMs + span.addEvent( + 'Successfully updated features', + { + maxAgeMs, + newExpiry: responseExpires, + }, + ) + }) + .catch((error) => { + span.recordException(resolveError(error)) + }) + .finally(() => span.end()) + } + span.addEvent('Response not expired', { + maxAgeMs, + expiry: responseExpires, + now, }) - } - - updatesLog('Response not expired: %o', { - responseExpires, - now, - }) - return Promise.resolve() - } + return Promise.resolve().finally(() => span.end()) + } + }, + ) }, } } diff --git a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts index 2aabe854..e682f0a5 100644 --- a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -1,9 +1,13 @@ -import { createEnsureSingleWithBackoff } from '@featureboard/js-sdk' +import { + createEnsureSingleWithBackoff, + resolveError, +} from '@featureboard/js-sdk' +import { trace } from '@opentelemetry/api' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' import { pollingUpdates } from '../utils/pollingUpdates' +import { startActiveSpan } from '../utils/start-active-span' import { getAllEndpoint } from './getAllEndpoint' import type { AllConfigUpdateStrategy } from './update-strategies' -import { updatesLog } from './updates-log' export function createPollingUpdateStrategy( environmentApiKey: string, @@ -14,6 +18,8 @@ export function createPollingUpdateStrategy( let etag: undefined | string let fetchUpdatesSingle: undefined | (() => Promise) + const parentSpan = trace.getActiveSpan() + return { async connect(stateStore) { // Ensure that we don't trigger another request while one is in flight @@ -24,7 +30,6 @@ export function createPollingUpdateStrategy( environmentApiKey, stateStore, etag, - 'polling', ) }) @@ -32,12 +37,22 @@ export function createPollingUpdateStrategy( stopPolling() } stopPolling = pollingUpdates(() => { - if (fetchUpdatesSingle) { - // Catch errors here to ensure no unhandled promise rejections after a poll - return fetchUpdatesSingle().catch((error: Error) => { - updatesLog(error) - }) - } + return startActiveSpan({ + name: 'fbsdk-polling-updates', + options: { attributes: { etag }, root: !parentSpan }, + parentSpan, + fn: async (span) => { + if (fetchUpdatesSingle) { + // Catch errors here to ensure no unhandled promise rejections after a poll + return await fetchUpdatesSingle() + .catch((error) => { + span.recordException(resolveError(error)) + }) + .finally(() => span.end()) + } + span.end() + }, + }) }, intervalMs) return fetchUpdatesSingle() diff --git a/libs/node-sdk/src/update-strategies/update-strategies.ts b/libs/node-sdk/src/update-strategies/update-strategies.ts index 36ddd41d..1337fc30 100644 --- a/libs/node-sdk/src/update-strategies/update-strategies.ts +++ b/libs/node-sdk/src/update-strategies/update-strategies.ts @@ -8,7 +8,7 @@ */ import type { LiveOptions } from '@featureboard/live-connection' -import type { AllFeatureStateStore } from '../feature-state-store' +import type { IFeatureStateStore } from '../feature-state-store' export interface ManualUpdateStrategy { kind: 'manual' @@ -56,7 +56,7 @@ export interface OnRequestOptions { export interface AllConfigUpdateStrategy { state: 'connected' | 'disconnected' - connect(state: AllFeatureStateStore): Promise + connect(state: IFeatureStateStore): Promise close(): Promise updateFeatures(): PromiseLike /** To be called when creating a request client */ diff --git a/libs/node-sdk/src/update-strategies/updates-log.ts b/libs/node-sdk/src/update-strategies/updates-log.ts deleted file mode 100644 index c39092cc..00000000 --- a/libs/node-sdk/src/update-strategies/updates-log.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { debugLog } from '../log' - -export const updatesLog = debugLog.extend('updates') diff --git a/libs/node-sdk/src/utils/add-debug-event.ts b/libs/node-sdk/src/utils/add-debug-event.ts new file mode 100644 index 00000000..31320f5f --- /dev/null +++ b/libs/node-sdk/src/utils/add-debug-event.ts @@ -0,0 +1,8 @@ +import type { Attributes } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' + +export function addDebugEvent(event: string, attributes: Attributes = {}) { + if (process.env.FEATUREBOARD_SDK_DEBUG) { + trace.getActiveSpan()?.addEvent(event, attributes) + } +} diff --git a/libs/node-sdk/src/utils/debug-store.ts b/libs/node-sdk/src/utils/debug-store.ts new file mode 100644 index 00000000..6d7c6381 --- /dev/null +++ b/libs/node-sdk/src/utils/debug-store.ts @@ -0,0 +1,29 @@ +import type { FeatureConfiguration } from '@featureboard/contracts' +import type { IFeatureStateStore } from '../feature-state-store' +import { addDebugEvent } from './add-debug-event' + +export class DebugFeatureStateStore implements IFeatureStateStore { + constructor(private store: IFeatureStateStore) {} + + initialiseFromExternalStateStore(): Promise { + return this.store.initialiseFromExternalStateStore() + } + + all(): Record { + const allValues = this.store.all() + addDebugEvent('Feature store: all features', { allValues: JSON.stringify(allValues) }) + return { ...allValues } + } + + get(featureKey: string): FeatureConfiguration | undefined { + const value = this.store.get(featureKey) + addDebugEvent('Feature store: get feature', { featureKey, value: JSON.stringify(value) }) + + return value + } + + set(featureKey: string, value: FeatureConfiguration | undefined) { + addDebugEvent('Feature store: set feature', { featureKey, value: JSON.stringify(value) }) + this.store.set(featureKey, value) + } +} diff --git a/libs/node-sdk/src/utils/featureboard-fixture.ts b/libs/node-sdk/src/utils/featureboard-fixture.ts new file mode 100644 index 00000000..37c028e7 --- /dev/null +++ b/libs/node-sdk/src/utils/featureboard-fixture.ts @@ -0,0 +1,38 @@ +import { trace } from '@opentelemetry/api' +import { type RequestHandler } from 'msw' +import { setupServer, type SetupServer } from 'msw/node' +import { type TestFunction } from 'vitest' +import { openTelemetryTracePassthrough } from './openTelemetryTracePassthrough' + +export function featureBoardFixture( + testContext: Context, + handlers: (context: Context) => Array, + testFn: TestFunction<{ testContext: Context; server: SetupServer }>, +): TestFunction { + return async (context) => { + const { task } = context + const tracer = trace.getTracer(task.suite.name) + const server = setupServer( + openTelemetryTracePassthrough, + ...handlers(testContext), + ) + server.listen({ onUnhandledRequest: 'error' }) + + await tracer.startActiveSpan( + `${task.suite.name ? task.suite.name + ': ' : ''}${task.name}`, + { + root: true, + attributes: {}, + }, + async (span) => { + try { + await testFn({ ...context, testContext, server }) + } finally { + server.resetHandlers() + server.close() + span.end() + } + }, + ) + } +} diff --git a/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts b/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts index 517755e0..608711e2 100644 --- a/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -2,84 +2,101 @@ import { TooManyRequestsError, type FeatureConfiguration, } from '@featureboard/contracts' -import type { AllFeatureStateStore } from '../feature-state-store' -import { httpClientDebug } from './http-log' +import { resolveError } from '@featureboard/js-sdk' +import { SpanStatusCode } from '@opentelemetry/api' +import type { IFeatureStateStore } from '../feature-state-store' +import { getTracer } from './get-tracer' export async function fetchFeaturesConfigurationViaHttp( allEndpoint: string, environmentApiKey: string, - stateStore: AllFeatureStateStore, + stateStore: IFeatureStateStore, etag: string | undefined, - updateTrigger: string, ): Promise { - httpClientDebug( - 'Fetching updates: trigger=%s, lastModified=%s', - updateTrigger, - etag, - ) + return getTracer().startActiveSpan( + 'fbsdk-fetch-features-http', + { attributes: { etag } }, + async (span) => { + try { + const response = await fetch(allEndpoint, { + method: 'GET', + headers: { + 'x-environment-key': environmentApiKey, + ...(etag ? { 'if-none-match': etag } : {}), + }, + }) - const response = await fetch(allEndpoint, { - method: 'GET', - headers: { - 'x-environment-key': environmentApiKey, - ...(etag ? { 'if-none-match': etag } : {}), - }, - }) + if (response.status === 429) { + // Too many requests + const retryAfterHeader = response.headers.get('Retry-After') + const retryAfterInt = retryAfterHeader + ? parseInt(retryAfterHeader, 10) + : 60 + const retryAfter = + retryAfterHeader && !retryAfterInt + ? new Date(retryAfterHeader) + : new Date() - if (response.status === 429) { - // Too many requests - const retryAfterHeader = response.headers.get('Retry-After') - const retryAfterInt = retryAfterHeader - ? parseInt(retryAfterHeader, 10) - : 60 - const retryAfter = - retryAfterHeader && !retryAfterInt - ? new Date(retryAfterHeader) - : new Date() + if (retryAfterInt) { + const retryAfterTime = + retryAfter.getTime() + retryAfterInt * 1000 + retryAfter.setTime(retryAfterTime) + } - if (retryAfterInt) { - const retryAfterTime = retryAfter.getTime() + retryAfterInt * 1000 - retryAfter.setTime(retryAfterTime) - } + throw new TooManyRequestsError( + `Failed to get latest features: Service returned ${ + response.status + }${ + response.statusText ? ' ' + response.statusText : '' + }. ${ + retryAfterHeader + ? 'Retry after: ' + retryAfter.toUTCString() + : '' + } `, + retryAfter, + ) + } - throw new TooManyRequestsError( - `Failed to get latest features: Service returned ${ - response.status - }${response.statusText ? ' ' + response.statusText : ''}. ${ - retryAfterHeader - ? 'Retry after: ' + retryAfter.toUTCString() - : '' - } `, - retryAfter, - ) - } + if (response.status !== 200 && response.status !== 304) { + throw new Error( + `Failed to get latest flags: Service returned error ${response.status} (${response.statusText})`, + ) + } - if (response.status !== 200 && response.status !== 304) { - throw new Error( - `Failed to get latest flags: Service returned error ${response.status} (${response.statusText})`, - ) - } + // Expect most times will just get a response from the HEAD request saying no updates + if (response.status === 304) { + span.addEvent('Fetch succeeded without changes') + return etag + } - // Expect most times will just get a response from the HEAD request saying no updates - if (response.status === 304) { - httpClientDebug('No changes') - return etag - } + const allValues: FeatureConfiguration[] = await response.json() - const allValues: FeatureConfiguration[] = await response.json() + for (const featureValue of allValues) { + stateStore.set(featureValue.featureKey, featureValue) + } - for (const featureValue of allValues) { - stateStore.set(featureValue.featureKey, featureValue) - } + const removed = Object.keys(stateStore.all()).filter( + (existing) => + allValues.every((v) => v.featureKey !== existing), + ) + for (const removedFeature of removed) { + stateStore.set(removedFeature, undefined) + } - const removed = Object.keys(stateStore.all()).filter((existing) => - allValues.every((v) => v.featureKey !== existing), + const newEtag = response.headers.get('etag') || undefined + span.addEvent('Fetch succeeded with updates', { newEtag }) + return newEtag + } catch (error) { + const err = resolveError(error) + span.recordException(err) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }) + throw err + } finally { + span.end() + } + }, ) - for (const removedFeature of removed) { - stateStore.set(removedFeature, undefined) - } - - const newEtag = response.headers.get('etag') || undefined - httpClientDebug('Fetching updates done, newEtag=%s', newEtag) - return newEtag } diff --git a/libs/node-sdk/src/utils/get-tracer.ts b/libs/node-sdk/src/utils/get-tracer.ts new file mode 100644 index 00000000..6d7341f4 --- /dev/null +++ b/libs/node-sdk/src/utils/get-tracer.ts @@ -0,0 +1,6 @@ +import { trace } from '@opentelemetry/api' +import { version } from '../version' + +export function getTracer() { + return trace.getTracer('featureboard-node-sdk', version) +} diff --git a/libs/node-sdk/src/utils/http-log.ts b/libs/node-sdk/src/utils/http-log.ts deleted file mode 100644 index 3a3b08f1..00000000 --- a/libs/node-sdk/src/utils/http-log.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { debugLog } from '../log' - -export const httpClientDebug = debugLog.extend('http-client') diff --git a/libs/node-sdk/src/utils/openTelemetryTracePassthrough.ts b/libs/node-sdk/src/utils/openTelemetryTracePassthrough.ts new file mode 100644 index 00000000..71747f34 --- /dev/null +++ b/libs/node-sdk/src/utils/openTelemetryTracePassthrough.ts @@ -0,0 +1,6 @@ +import { http, passthrough } from 'msw' + +export const openTelemetryTracePassthrough = http.post( + 'http://localhost:4318/v1/traces', + () => passthrough(), +) diff --git a/libs/node-sdk/src/utils/start-active-span.ts b/libs/node-sdk/src/utils/start-active-span.ts new file mode 100644 index 00000000..2849a862 --- /dev/null +++ b/libs/node-sdk/src/utils/start-active-span.ts @@ -0,0 +1,23 @@ +import { context, trace, type Span, type SpanOptions } from '@opentelemetry/api' +import { getTracer } from './get-tracer' + +export function startActiveSpan unknown>({ + name, + options, + parentSpan, + fn, +}: { + name: string + options: SpanOptions + parentSpan?: Span + fn: F +}): ReturnType { + // Get context from parent span + const ctx = parentSpan + ? trace.setSpan(context.active(), parentSpan) + : undefined + + return ctx + ? getTracer().startActiveSpan(name, options, ctx, fn) + : getTracer().startActiveSpan(name, options, fn) +} diff --git a/libs/node-sdk/src/utils/trace-provider.ts b/libs/node-sdk/src/utils/trace-provider.ts new file mode 100644 index 00000000..1489c993 --- /dev/null +++ b/libs/node-sdk/src/utils/trace-provider.ts @@ -0,0 +1,10 @@ +import { ProxyTracerProvider, trace } from '@opentelemetry/api' + +export const traceProvider = new ProxyTracerProvider() +const noopTraceProvider = traceProvider.getDelegate() + +export function setTracingEnabled(enabled: boolean) { + traceProvider.setDelegate( + enabled ? trace.getTracerProvider() : noopTraceProvider, + ) +} diff --git a/libs/node-sdk/src/version.ts b/libs/node-sdk/src/version.ts new file mode 100644 index 00000000..0aa658f3 --- /dev/null +++ b/libs/node-sdk/src/version.ts @@ -0,0 +1 @@ +export const version = '0.18.0'; diff --git a/libs/node-sdk/test-setup.ts b/libs/node-sdk/test-setup.ts new file mode 100644 index 00000000..4b9bb06a --- /dev/null +++ b/libs/node-sdk/test-setup.ts @@ -0,0 +1,34 @@ +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' +import { NodeSDK } from '@opentelemetry/sdk-node' +import { + ConsoleSpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-node' +import { afterAll, beforeAll } from 'vitest' + +let sdk: NodeSDK | undefined +let spanProcessor: SimpleSpanProcessor | undefined + +beforeAll(({ suite }) => { + const exporter = process.env.OTEL_EXPORTER_OTLP_ENDPOINT + ? new OTLPTraceExporter() + : process.env.OTEL_EXPORTER_OTLP_CONSOLE + ? new ConsoleSpanExporter() + : undefined + spanProcessor = exporter ? new SimpleSpanProcessor(exporter) : undefined + + sdk = spanProcessor + ? new NodeSDK({ + serviceName: 'featureboard-node-sdk-test', + spanProcessor: spanProcessor, + instrumentations: [], + }) + : undefined + + sdk?.start() +}) + +afterAll(async () => { + await spanProcessor?.forceFlush() + await sdk?.shutdown() +}) diff --git a/libs/node-sdk/vitest.config.ts b/libs/node-sdk/vitest.config.ts index e6721929..58c903f7 100644 --- a/libs/node-sdk/vitest.config.ts +++ b/libs/node-sdk/vitest.config.ts @@ -11,5 +11,6 @@ export default defineConfig({ ], test: { include: ['src/**/*.{test,spec}.{ts,mts,cts,tsx}'], + setupFiles: ['./test-setup.ts'], }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a06b9929..ddb926ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,12 +221,25 @@ importers: '@featureboard/contracts': specifier: workspace:* version: link:../contracts - debug: - specifier: ^4.3.4 - version: 4.3.4 promise-completion-source: specifier: ^1.0.0 version: 1.0.0 + devDependencies: + '@opentelemetry/api': + specifier: ^1.7.0 + version: 1.7.0 + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.45.1 + version: 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-node': + specifier: ^0.45.1 + version: 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': + specifier: ^1.18.1 + version: 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-node': + specifier: ^1.18.1 + version: 1.18.1(@opentelemetry/api@1.7.0) libs/live-connection: dependencies: @@ -248,12 +261,25 @@ importers: '@featureboard/js-sdk': specifier: workspace:* version: link:../js-sdk - debug: - specifier: ^4.3.4 - version: 4.3.4 + '@opentelemetry/api': + specifier: ^1.0.0 + version: 1.7.0 promise-completion-source: specifier: ^1.0.0 version: 1.0.0 + devDependencies: + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.45.1 + version: 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-node': + specifier: ^0.45.1 + version: 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': + specifier: ^1.18.1 + version: 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-node': + specifier: ^1.18.1 + version: 1.18.1(@opentelemetry/api@1.7.0) libs/nx-plugin: dependencies: @@ -2245,6 +2271,25 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@grpc/grpc-js@1.9.12: + resolution: {integrity: sha512-Um5MBuge32TS3lAKX02PGCnFM4xPT996yLgZNb5H03pn6NyJ4Iwn5YcPq6Jj9yxGRk7WOgaZFtVRH5iTdYBeUg==} + engines: {node: ^8.13.0 || >=10.10.0} + dependencies: + '@grpc/proto-loader': 0.7.10 + '@types/node': 20.8.9 + dev: true + + /@grpc/proto-loader@0.7.10: + resolution: {integrity: sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ==} + engines: {node: '>=6'} + hasBin: true + dependencies: + lodash.camelcase: 4.3.0 + long: 5.2.3 + protobufjs: 7.2.5 + yargs: 17.7.2 + dev: true + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} @@ -3269,6 +3314,271 @@ packages: resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} dev: true + /@opentelemetry/api-logs@0.45.1: + resolution: {integrity: sha512-zVGq/k70l+kB/Wuv3O/zhptP2hvDhEbhDu9EtHde1iWZJf3FedeYS/nWVcMBkkyPAjS/JKNk86WN4CBQLGUuOw==} + engines: {node: '>=14'} + dependencies: + '@opentelemetry/api': 1.7.0 + dev: true + + /@opentelemetry/api@1.7.0: + resolution: {integrity: sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw==} + engines: {node: '>=8.0.0'} + + /@opentelemetry/context-async-hooks@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-HHfJR32NH2x0b69CACCwH8m1dpNALoCTtpgmIWMNkeMGNUeKT48d4AX4xsF4uIRuUoRTbTgtSBRvS+cF97qwCQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + dev: true + + /@opentelemetry/core@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-kvnUqezHMhsQvdsnhnqTNfAJs3ox/isB0SVrM1dhVFw7SsB7TstuVa6fgWnN2GdPyilIFLUvvbTZoVRmx6eiRg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/semantic-conventions': 1.18.1 + dev: true + + /@opentelemetry/exporter-trace-otlp-grpc@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-c/Wrn6LUqPiRgKhvMydau6kPz4ih6b/uwospiavjXju98ZfVv+KjaIF13cblW+4cQ6ZR3lm7t66umQfXrGBhPQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + dependencies: + '@grpc/grpc-js': 1.9.12 + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-transformer': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/exporter-trace-otlp-http@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-a6CGqSG66n5R1mghzLMzyzn3iGap1b0v+0PjKFjfYuwLtpHQBxh2PHxItu+m2mXSwnM4R0GJlk9oUW5sQkCE0w==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-exporter-base': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-transformer': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/exporter-trace-otlp-proto@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-8QI6QARxNP4y9RUpuQxXjw2HyRNyeuD9CWEhS5ON44Mt+XP7YbOZR3GLx2Ml2JZ8uzB5dd2EGlMgaMuZe36D5Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-exporter-base': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-proto-exporter-base': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-transformer': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/exporter-zipkin@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-RmoWVFXFhvIh3q4szUe8I+/vxuMR0HNsOm39zNxnWJcK7JDwnPra9cLY/M78u6bTgB6Fte8GKgU128vvDzz0Iw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/semantic-conventions': 1.18.1 + dev: true + + /@opentelemetry/instrumentation@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-V1Cr0g8hSg35lpW3G/GYVZurrhHrQZJdmP68WyJ83f1FDn3iru+/Vnlto9kiOSm7PHhW+pZGdb9Fbv+mkQ31CA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + dependencies: + '@opentelemetry/api': 1.7.0 + '@types/shimmer': 1.0.5 + import-in-the-middle: 1.4.2 + require-in-the-middle: 7.2.0 + semver: 7.5.4 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@opentelemetry/otlp-exporter-base@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-Jvd6x8EwWGKEPWF4tkP4LpTPXiIkkafMNMvMJUfJd5DyNAftL1vAz+48jmi3URL2LMPkGryrvWPz8Tdu917gQw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/otlp-grpc-exporter-base@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-81X4mlzaAFoQCSXCgvYoMFyTy3mBhf8DD3J8bjW6/PH/rGZPJJkyYW0/YzepMrmBZXqlKZpTOU1aJ8sebVvDvw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + dependencies: + '@grpc/grpc-js': 1.9.12 + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-exporter-base': 0.45.1(@opentelemetry/api@1.7.0) + protobufjs: 7.2.5 + dev: true + + /@opentelemetry/otlp-proto-exporter-base@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-jtDkly6EW8TZHpbPpwJV9YT5PgbtL5B2UU8zcyGDiLT1wkIAYjFJZ1AqWmROIpydu8ohMq0dRwe4u0izNMdHpA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/otlp-exporter-base': 0.45.1(@opentelemetry/api@1.7.0) + protobufjs: 7.2.5 + dev: true + + /@opentelemetry/otlp-transformer@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-FhIHgfC0b0XtoBrS5ISfva939yWffNl47ypXR8I7Ru+dunlySpmf2TLocKHYLHGcWiuoeSNO5O4dZCmSKOtpXw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/api-logs': 0.45.1 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-logs': 0.45.1(@opentelemetry/api-logs@0.45.1)(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-metrics': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/propagator-b3@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-oSTUOsnt31JDx5SoEy27B5jE1/tiPvvE46w7CDKj0R5oZhCCfYH2bbSGa7NOOyDXDNqQDkgqU1DIV/xOd3f8pw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/propagator-jaeger@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-Kh4M1Qewv0Tbmts6D8LgNzx99IjdE18LCmY/utMkgVyU7Bg31Yuj+X6ZyoIRKPcD2EV4rVkuRI16WVMRuGbhWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/resources@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-JjbcQLYMttXcIabflLRuaw5oof5gToYV9fuXbcsoOeQ0BlbwUn6DAZi++PNsSz2jjPeASfDls10iaO/8BRIPRA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/semantic-conventions': 1.18.1 + dev: true + + /@opentelemetry/sdk-logs@0.45.1(@opentelemetry/api-logs@0.45.1)(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-z0RRgW4LeKEKnhXS4F/HnqB6+7gsy63YK47F4XAJYHs4s1KKg8XnQ2RkbuL31i/a9nXkylttYtvsT50CGr487g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.8.0' + '@opentelemetry/api-logs': '>=0.39.1' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/api-logs': 0.45.1 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + dev: true + + /@opentelemetry/sdk-metrics@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-TEFgeNFhdULBYiCoHbz31Y4PDsfjjxRp8Wmdp6ybLQZPqMNEb+dRq+XN8Xw3ivIgTaf9gYsomgV5ensX99RuEQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + lodash.merge: 4.6.2 + dev: true + + /@opentelemetry/sdk-node@0.45.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-VtYvlz2ydfJLuOUhCnGER69mz2KUYk3/kpbqI1FWlUP+kzTwivMuy7hIPPv6KmuOIMYWmW4lM+WyJACHqNvROw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/api-logs': 0.45.1 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/exporter-trace-otlp-http': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/exporter-zipkin': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/instrumentation': 0.45.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-logs': 0.45.1(@opentelemetry/api-logs@0.45.1)(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-metrics': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-node': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/semantic-conventions': 1.18.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@opentelemetry/sdk-trace-base@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-tRHfDxN5dO+nop78EWJpzZwHsN1ewrZRVVwo03VJa3JQZxToRDH29/+MB24+yoa+IArerdr7INFJiX/iN4gjqg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/semantic-conventions': 1.18.1 + dev: true + + /@opentelemetry/sdk-trace-node@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-ML0l9TNlfLoplLF1F8lb95NGKgdm6OezDS3Ymqav9sYxMd5bnH2LZVzd4xEF+ov5vpZJOGdWxJMs2nC9no7+xA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/context-async-hooks': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/propagator-b3': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/propagator-jaeger': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) + semver: 7.5.4 + dev: true + + /@opentelemetry/semantic-conventions@1.18.1: + resolution: {integrity: sha512-+NLGHr6VZwcgE/2lw8zDIufOCGnzsA5CbQIMleXZTrgkBd0TanCX+MiDYJ1TOS4KL/Tqk0nFRxawnaYr6pkZkA==} + engines: {node: '>=14'} + dev: true + /@phenomnomnominal/tsquery@5.0.1(typescript@5.1.6): resolution: {integrity: sha512-3nVv+e2FQwsW8Aw6qTU6f+1rfcJ3hrcnvH/mu9i8YhxO+9sqbOfpL8m6PbET5+xKOlz/VSbp0RoYWYCtIsnmuA==} peerDependencies: @@ -3303,6 +3613,49 @@ packages: resolution: {integrity: sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==} dev: true + /@protobufjs/aspromise@1.1.2: + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + dev: true + + /@protobufjs/base64@1.1.2: + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + dev: true + + /@protobufjs/codegen@2.0.4: + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + dev: true + + /@protobufjs/eventemitter@1.1.0: + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + dev: true + + /@protobufjs/fetch@1.1.0: + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + dev: true + + /@protobufjs/float@1.0.2: + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + dev: true + + /@protobufjs/inquire@1.1.0: + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + dev: true + + /@protobufjs/path@1.1.2: + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + dev: true + + /@protobufjs/pool@1.1.0: + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + dev: true + + /@protobufjs/utf8@1.1.0: + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + dev: true + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3737,6 +4090,10 @@ packages: resolution: {integrity: sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==} dev: true + /@types/shimmer@1.0.5: + resolution: {integrity: sha512-9Hp0ObzwwO57DpLFF0InUjUm/II8GmKAvzbefxQTihCb7KI6yc9yzf0nLc4mVdby5N4DRCgQM2wCup9KTieeww==} + dev: true + /@types/stack-utils@2.0.2: resolution: {integrity: sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==} dev: true @@ -4283,6 +4640,14 @@ packages: negotiator: 0.6.3 dev: true + /acorn-import-assertions@1.9.0(acorn@8.10.0): + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.10.0 + dev: true + /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -6980,6 +7345,15 @@ packages: resolve-from: 4.0.0 dev: true + /import-in-the-middle@1.4.2: + resolution: {integrity: sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==} + dependencies: + acorn: 8.10.0 + acorn-import-assertions: 1.9.0(acorn@8.10.0) + cjs-module-lexer: 1.2.3 + module-details-from-path: 1.0.3 + dev: true + /imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -8012,6 +8386,10 @@ packages: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: true + /lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + dev: true + /lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true @@ -8068,6 +8446,10 @@ packages: is-unicode-supported: 0.1.0 dev: true + /long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + dev: true + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -8326,6 +8708,10 @@ packages: ufo: 1.3.1 dev: true + /module-details-from-path@1.0.3: + resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} + dev: true + /mrmime@1.0.1: resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} engines: {node: '>=10'} @@ -9124,6 +9510,25 @@ packages: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} dev: true + /protobufjs@7.2.5: + resolution: {integrity: sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==} + engines: {node: '>=12.0.0'} + requiresBuild: true + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 20.8.9 + long: 5.2.3 + dev: true + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -9450,6 +9855,17 @@ packages: engines: {node: '>=0.10.0'} dev: true + /require-in-the-middle@7.2.0: + resolution: {integrity: sha512-3TLx5TGyAY6AOqLBoXmHkNql0HIf2RGbuMgCDT2WO/uGVAPJs6h7Kl+bN6TIZGd9bWhWPwnDnTHGtW8Iu77sdw==} + engines: {node: '>=8.6.0'} + dependencies: + debug: 4.3.4 + module-details-from-path: 1.0.3 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + dev: true + /require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} dev: true @@ -9724,6 +10140,10 @@ packages: engines: {node: '>=8'} dev: true + /shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + dev: true + /side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: