From 35ebeef4b51f13287a92c3b606a2bde01e802f49 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Mon, 11 Dec 2023 07:50:42 +0800 Subject: [PATCH 01/28] Instrumented js-sdk with open telemetry --- libs/js-sdk/package.json | 2 + libs/js-sdk/project.json | 1 + libs/js-sdk/src/create-browser-client.ts | 219 ++++++++++-------- libs/js-sdk/src/create-client.ts | 25 +- .../src/effective-feature-state-store.ts | 11 +- libs/js-sdk/src/log.ts | 3 - .../createLiveUpdateStrategy.ts | 4 +- .../createManualUpdateStrategy.ts | 4 +- .../createPollingUpdateStrategy.ts | 25 +- .../update-strategies/update-strategies.ts | 1 + .../src/update-strategies/updates-log.ts | 3 - .../src/utils/fetchFeaturesConfiguration.ts | 120 ++++++---- libs/js-sdk/src/utils/get-tracer.ts | 6 + libs/js-sdk/src/utils/http-log.ts | 3 - libs/js-sdk/src/utils/resolve-error.ts | 14 ++ libs/js-sdk/src/utils/retry.spec.ts | 55 +++++ libs/js-sdk/src/utils/retry.ts | 59 +++-- libs/js-sdk/src/version.ts | 1 + libs/js-sdk/tsconfig.json | 14 +- pnpm-lock.yaml | 49 ++++ 20 files changed, 412 insertions(+), 207 deletions(-) delete mode 100644 libs/js-sdk/src/log.ts delete mode 100644 libs/js-sdk/src/update-strategies/updates-log.ts create mode 100644 libs/js-sdk/src/utils/get-tracer.ts delete mode 100644 libs/js-sdk/src/utils/http-log.ts create mode 100644 libs/js-sdk/src/utils/resolve-error.ts create mode 100644 libs/js-sdk/src/utils/retry.spec.ts create mode 100644 libs/js-sdk/src/version.ts diff --git a/libs/js-sdk/package.json b/libs/js-sdk/package.json index 27ba0cab..2d0d48cd 100644 --- a/libs/js-sdk/package.json +++ b/libs/js-sdk/package.json @@ -33,6 +33,8 @@ }, "dependencies": { "@featureboard/contracts": "workspace:*", + "@opentelemetry/api": "^1.0.0", + "@opentelemetry/sdk-trace-base": "^1.18.1", "debug": "^4.3.4", "promise-completion-source": "^1.0.0" } diff --git a/libs/js-sdk/project.json b/libs/js-sdk/project.json index 4b568998..fbe71797 100644 --- a/libs/js-sdk/project.json +++ b/libs/js-sdk/project.json @@ -21,6 +21,7 @@ "executor": "nx:run-commands", "options": { "commands": [ + "echo \"export const version = '$(jq -r '.version' package.json)';\" > src/version.ts", "tsup src/index.ts -d dist --sourcemap --format esm --legacy-output --external @featureboard/contracts --env.TEST false", "tsup src/index.ts -d dist/legacycjs --sourcemap --format cjs --legacy-output --external @featureboard/contracts --env.TEST false", "tsup src/index.ts -d dist --sourcemap --format esm,cjs --external @featureboard/contracts --env.TEST false", diff --git a/libs/js-sdk/src/create-browser-client.ts b/libs/js-sdk/src/create-browser-client.ts index fcbda911..4612a6d8 100644 --- a/libs/js-sdk/src/create-browser-client.ts +++ b/libs/js-sdk/src/create-browser-client.ts @@ -1,14 +1,16 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' +import { SpanStatusCode, trace } from '@opentelemetry/api' import { PromiseCompletionSource } from 'promise-completion-source' import type { BrowserClient } from './client-connection' 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' /** @@ -41,6 +43,8 @@ export function createBrowserClient({ environmentApiKey: string }): BrowserClient { + const tracer = getTracer() + const initialPromise = new PromiseCompletionSource() const initialisedState: { initialisedCallbacks: Array<(initialised: boolean) => void> @@ -74,41 +78,55 @@ export function createBrowserClient({ ) const retryCancellationToken = { cancel: false } - retry(async () => { - debugLog('SDK connecting in background (%o)', { - audiences, - }) - return await updateStrategyImplementation.connect(stateStore) - }, retryCancellationToken) - .then(() => { - if (initialPromise !== initialisedState.initialisedPromise) { - return - } - - if (!initialPromise.completed) { - debugLog('SDK connected (%o)', { - audiences, - }) - initialPromise.resolve(true) - } - }) - .catch((err) => { - if (!initialisedState.initialisedPromise.completed) { - debugLog( - 'SDK failed to connect (%o): %o', + tracer.startActiveSpan( + 'connect-with-retry', + { + attributes: { audiences }, + // This is asynchronous so we don't want it nested in the current span + root: true, + }, + (span) => + retry(async () => { + return await tracer.startActiveSpan( + 'connect', { - audiences, + attributes: { + audiences, + updateStrategy: updateStrategyImplementation.name, + }, }, - err, + (operationSpan) => + updateStrategyImplementation + .connect(stateStore) + .finally(() => operationSpan.end()), ) - console.error( - 'FeatureBoard SDK failed to connect after 5 retries', - err, - ) - initialisedState.initialisedError = err - initialisedState.initialisedPromise.resolve(true) - } - }) + }, retryCancellationToken) + .then(() => { + if ( + initialPromise !== initialisedState.initialisedPromise + ) { + return + } + + if (!initialPromise.completed) { + span.end() + initialPromise.resolve(true) + } + }) + .catch((err) => { + if (!initialisedState.initialisedPromise.completed) { + span.setStatus({ code: SpanStatusCode.ERROR }) + span.end() + console.error( + 'FeatureBoard SDK failed to connect after 5 retries', + err, + ) + initialisedState.initialisedError = err + initialisedState.initialisedPromise.resolve(true) + } + }), + ) + const isInitialised = () => { return initialisedState.initialisedPromise.completed } @@ -132,10 +150,6 @@ export function createBrowserClient({ }) }, subscribeToInitialisedChanged(callback) { - debugLog('Subscribing to initialised changed: %o', { - initialised: isInitialised(), - }) - initialisedState.initialisedCallbacks.push(callback) return () => { initialisedState.initialisedCallbacks.splice( @@ -145,66 +159,83 @@ export function createBrowserClient({ } }, 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(), - }) - - // Close connection and cancel retry - updateStrategyImplementation.close() - retryCancellationToken.cancel = true + await tracer.startActiveSpan( + 'connect', + { + attributes: { + audiences, + updateStrategy: updateStrategyImplementation.name, + }, + }, + (updateAudiencesSpan) => { + if (compareArrays(stateStore.audiences, updatedAudiences)) { + trace + .getActiveSpan() + ?.addEvent('Skipped update audiences', { + updatedAudiences, + currentAudiences: stateStore.audiences, + initialised: isInitialised(), + }) + updateAudiencesSpan.end() + + // No need to update audiences + return Promise.resolve() + } - 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 - } - - // 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() + 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 + } + + // Get the value from the function, just incase it has changed + const initialised = isInitialised() + initialisedState.initialisedCallbacks.forEach((c) => + c(initialised), + ) + }) - stateStore.audiences = updatedAudiences - debugLog( - 'updateAudiences: Audiences updated (%o), getting new effective values', - updatedAudiences, + initialisedState.initialisedCallbacks.forEach((c) => + c(false), + ) + + stateStore.audiences = updatedAudiences + + updateStrategyImplementation + .connect(stateStore) + .then(() => { + newPromise?.resolve(true) + trace + .getActiveSpan() + ?.addEvent('Updated audiences', { + updatedAudiences, + currentAudiences: stateStore.audiences, + initialised: isInitialised(), + }) + }) + .catch((error) => { + const err = resolveError(error) + updateAudiencesSpan.recordException(err) + updateAudiencesSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: 'Failed to update audiences', + }) + initialisedState.initialisedError = error + newPromise?.resolve(true) + }) + .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() diff --git a/libs/js-sdk/src/create-client.ts b/libs/js-sdk/src/create-client.ts index df8aa694..ae43d356 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 { trace } from '@opentelemetry/api' import type { EffectiveFeatureStateStore } from './effective-feature-state-store' import type { FeatureBoardClient } from './features-client' -import { debugLog } from './log' /** Designed for internal SDK use */ export function createClientInternal( @@ -10,6 +10,8 @@ export function createClientInternal( return { getEffectiveValues() { const all = stateStore.all() + trace.getActiveSpan()?.addEvent('getEffectiveValues', {}) + return { audiences: [...stateStore.audiences], effectiveValues: Object.keys(all) @@ -22,7 +24,7 @@ export function createClientInternal( }, getFeatureValue: (featureKey, defaultValue) => { const value = stateStore.get(featureKey as string) - debugLog('getFeatureValue: %o', { + trace.getActiveSpan()?.addEvent('getFeatureValue', { featureKey, value, defaultValue, @@ -35,19 +37,20 @@ export function createClientInternal( defaultValue: any, onValue: (value: any) => void, ) { - debugLog('subscribeToFeatureValue: %s', featureKey) + trace.getActiveSpan()?.addEvent('subscribeToFeatureValue', { + featureKey, + defaultValue, + }) const callback = (updatedFeatureKey: string, value: any): void => { if (featureKey === updatedFeatureKey) { - debugLog( - 'subscribeToFeatureValue: %s update: %o', - featureKey, - { + trace + .getActiveSpan() + ?.addEvent('subscribeToFeatureValue:update', { featureKey, value, defaultValue, - }, - ) + }) onValue(value ?? defaultValue) } } @@ -56,7 +59,9 @@ export function createClientInternal( onValue(stateStore.get(featureKey) ?? defaultValue) return () => { - debugLog('unsubscribeToFeatureValue: %s', featureKey) + trace.getActiveSpan()?.addEvent('unsubscribeToFeatureValue', { + featureKey, + }) stateStore.off('feature-updated', callback) } }, diff --git a/libs/js-sdk/src/effective-feature-state-store.ts b/libs/js-sdk/src/effective-feature-state-store.ts index 2d9e89eb..1bb39b2a 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 { trace } from '@opentelemetry/api' export type FeatureValue = EffectiveFeatureValue['value'] | undefined -const stateStoreDebug = debugLog.extend('state-store') - export class EffectiveFeatureStateStore { private _audiences: string[] = [] private _store: Record = {} @@ -57,7 +55,9 @@ export class EffectiveFeatureStateStore { } set(featureKey: string, value: FeatureValue) { - stateStoreDebug("set '%s': %o", featureKey, value) + const activeSpan = trace.getActiveSpan() + activeSpan?.addEvent('Set', { featureKey, value }) + this._store[featureKey] = value this.valueUpdatedCallbacks.forEach((valueUpdated) => @@ -67,7 +67,8 @@ export class EffectiveFeatureStateStore { get(featureKey: string): FeatureValue { const value = this._store[featureKey] - stateStoreDebug("get '%s': %o", featureKey, value) + const activeSpan = trace.getActiveSpan() + activeSpan?.addEvent('Get', { featureKey, value }) return value } } 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/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 1c293cc7..2184d636 100644 --- a/libs/js-sdk/src/update-strategies/createManualUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createManualUpdateStrategy.ts @@ -1,9 +1,6 @@ import { createEnsureSingle } 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, @@ -13,6 +10,7 @@ export function createManualUpdateStrategy( let fetchUpdatesSingle: undefined | (() => 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 d5d8778f..14425f16 100644 --- a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -1,10 +1,9 @@ import { createEnsureSingle } from '../ensure-single' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' +import { getTracer } from '../utils/get-tracer' import { pollingUpdates } from '../utils/pollingUpdates' +import { resolveError } from '../utils/resolve-error' import type { EffectiveConfigUpdateStrategy } from './update-strategies' -import { updatesLog } from './updates-log' - -export const pollingUpdatesDebugLog = updatesLog.extend('polling') export function createPollingUpdateStrategy( environmentApiKey: string, @@ -16,6 +15,7 @@ export function createPollingUpdateStrategy( let fetchUpdatesSingle: undefined | (() => Promise) return { + name: 'polling', async connect(stateStore) { // Force update etag = undefined @@ -36,11 +36,20 @@ 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(() => {}) - } + getTracer().startActiveSpan( + 'Polling update', + { attributes: { etag }, root: true }, + async (span) => { + // Catch errors here to ensure no unhandled promise rejections after a poll + if (fetchUpdatesSingle) { + await fetchUpdatesSingle() + .catch((err) => { + span.recordException(resolveError(err)) + }) + .finally(() => 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/fetchFeaturesConfiguration.ts b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts index 85b20228..a25f3cf0 100644 --- a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -1,8 +1,11 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' +import { SpanStatusCode, trace } from '@opentelemetry/api' import type { EffectiveFeatureStateStore } from '../effective-feature-state-store' import { getEffectiveEndpoint } from '../update-strategies/getEffectiveEndpoint' +import { version } from '../version' 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, @@ -16,57 +19,78 @@ 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( + 'fetchEffectiveFeatures(http)', + { attributes: { audiences, etag } }, + async (span) => { + try { + const response = await fetch(effectiveEndpoint, { + method: 'GET', + headers: { + 'x-environment-key': environmentApiKey, + 'x-sdk-version': version, + ...(etag ? { 'if-none-match': etag } : {}), + }, + }) + + if (response.status !== 200 && response.status !== 304) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `Failed to get latest flags: Service returned error ${response.status} (${response.statusText})`, + }) - 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})`, - ) - } + 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) { + 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)) { + trace + .getActiveSpan() + ?.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) + }) + span.addEvent('Feature updates received', { + audiences, + unavailableFeatures, + }) - return response.headers.get('etag') || undefined + return response.headers.get('etag') || undefined + } catch (error) { + const err = resolveError(error) + span.recordException(err) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }) + } 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..16c3d12b --- /dev/null +++ b/libs/js-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-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/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..90f7da3f --- /dev/null +++ b/libs/js-sdk/src/utils/retry.spec.ts @@ -0,0 +1,55 @@ +import { trace } from '@opentelemetry/api' +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { retry } from './retry' + +describe('retry function with OpenTelemetry', () => { + let exporter: InMemorySpanExporter + + beforeEach(() => { + // Set up in-memory exporter + exporter = new InMemorySpanExporter() + const provider = new BasicTracerProvider() + provider.addSpanProcessor(new SimpleSpanProcessor(exporter)) + trace.setGlobalTracerProvider(provider) + }) + + afterEach(() => { + // Clear the spans and reset the tracer + exporter.reset() + }) + + it('should retry the function and succeed', async () => { + let attempt = 0 + const mockFn = async () => { + if (attempt < 2) { + attempt++ + throw new Error('Temporary failure') + } + return 'Success' + } + + const result = await retry(mockFn) + + expect(result).toBe('Success') + const spans = exporter.getFinishedSpans() + expect(spans.length).toEqual(5) + expect(spans[0].attributes?.retryAttempt).toBe(0) + expect(spans[1].name).toEqual('delay') + expect(spans[2].attributes?.retryAttempt).toBe(1) + expect(spans[3].name).toEqual('delay') + expect(spans[4].attributes?.retryAttempt).toBe(2) + }) + + 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 01c594fa..7be450ac 100644 --- a/libs/js-sdk/src/utils/retry.ts +++ b/libs/js-sdk/src/utils/retry.ts @@ -1,4 +1,5 @@ -import { debugLog } from '../log' +import { getTracer } from './get-tracer' +import { resolveError } from './resolve-error' const maxRetries = 5 const initialDelayMs = process.env.TEST === 'true' ? 1 : 1000 @@ -9,21 +10,47 @@ export async function retry( cancellationToken = { cancel: false }, retryAttempt = 0, ): Promise { - try { - return await fn() - } catch (error) { - if (cancellationToken?.cancel) { - debugLog('Cancel retry function') - return Promise.resolve() - } - if (retryAttempt >= maxRetries) { - // Max retries - throw error - } - const delayMs = initialDelayMs * Math.pow(backoffFactor, retryAttempt) - await delay(delayMs) // Wait for the calculated delay - return retry(fn, cancellationToken, retryAttempt + 1) // Retry the operation recursively - } + const tracer = getTracer() + return tracer.startActiveSpan( + 'retry', + { attributes: { retryAttempt } }, + async (span) => { + try { + const result = await fn() + span.end() + return result + } catch (error) { + const err = resolveError(error) + span.recordException(err) + + if (cancellationToken?.cancel) { + span.end() + return Promise.resolve() + } + + if (retryAttempt >= maxRetries) { + span.recordException( + new Error( + 'Operation failed after max retries exceeded', + { cause: err }, + ), + ) + span.end() + // Max retries + throw error + } + span.end() + + const delayMs = + initialDelayMs * Math.pow(backoffFactor, retryAttempt) + + await tracer.startActiveSpan('delay', (delaySpan) => + delay(delayMs).finally(() => delaySpan.end()), + ) // Wait for the calculated delay + return retry(fn, cancellationToken, retryAttempt + 1) // Retry the operation recursively + } + }, + ) } function delay(ms: number): Promise { 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 1c8d2d4e..f3430f00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,12 @@ importers: '@featureboard/contracts': specifier: workspace:* version: link:../contracts + '@opentelemetry/api': + specifier: ^1.0.0 + version: 1.7.0 + '@opentelemetry/sdk-trace-base': + specifier: ^1.18.1 + version: 1.18.1(@opentelemetry/api@1.7.0) debug: specifier: ^4.3.4 version: 4.3.4 @@ -3260,6 +3266,49 @@ packages: resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} dev: true + /@opentelemetry/api@1.7.0: + resolution: {integrity: sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw==} + engines: {node: '>=8.0.0'} + dev: false + + /@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: false + + /@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: false + + /@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: false + + /@opentelemetry/semantic-conventions@1.18.1: + resolution: {integrity: sha512-+NLGHr6VZwcgE/2lw8zDIufOCGnzsA5CbQIMleXZTrgkBd0TanCX+MiDYJ1TOS4KL/Tqk0nFRxawnaYr6pkZkA==} + engines: {node: '>=14'} + dev: false + /@phenomnomnominal/tsquery@5.0.1(typescript@5.1.6): resolution: {integrity: sha512-3nVv+e2FQwsW8Aw6qTU6f+1rfcJ3hrcnvH/mu9i8YhxO+9sqbOfpL8m6PbET5+xKOlz/VSbp0RoYWYCtIsnmuA==} peerDependencies: From 15439b1535b186340416654e11db3859bc5e5376 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Mon, 11 Dec 2023 17:01:05 +0800 Subject: [PATCH 02/28] Optimised and fixed http-client tests --- .gitignore | 3 + FeatureBoardSdks.sln.DotSettings | 1 - README.md | 12 + libs/js-sdk/.eslintrc.cjs | 8 +- libs/js-sdk/package.json | 8 +- libs/js-sdk/src/create-browser-client.ts | 275 +++---- libs/js-sdk/src/featureboard-fixture.ts | 41 + libs/js-sdk/src/tests/http-client.spec.ts | 766 +++++++++--------- libs/js-sdk/src/tests/mode-manual.spec.ts | 4 + libs/js-sdk/src/tests/mode-polling.spec.ts | 4 + .../createPollingUpdateStrategy.ts | 4 +- .../src/utils/fetchFeaturesConfiguration.ts | 1 + .../utils/openTelemetryTracePassthrough.ts | 6 + libs/js-sdk/src/utils/retry.ts | 51 +- libs/js-sdk/test-setup.ts | 31 + libs/js-sdk/vitest.config.ts | 1 + pnpm-lock.yaml | 380 ++++++++- 17 files changed, 1033 insertions(+), 563 deletions(-) delete mode 100644 FeatureBoardSdks.sln.DotSettings create mode 100644 libs/js-sdk/src/featureboard-fixture.ts create mode 100644 libs/js-sdk/src/utils/openTelemetryTracePassthrough.ts create mode 100644 libs/js-sdk/test-setup.ts 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/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/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 2d0d48cd..639820e3 100644 --- a/libs/js-sdk/package.json +++ b/libs/js-sdk/package.json @@ -34,8 +34,12 @@ "dependencies": { "@featureboard/contracts": "workspace:*", "@opentelemetry/api": "^1.0.0", - "@opentelemetry/sdk-trace-base": "^1.18.1", - "debug": "^4.3.4", "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/js-sdk/src/create-browser-client.ts b/libs/js-sdk/src/create-browser-client.ts index 4612a6d8..2f793bda 100644 --- a/libs/js-sdk/src/create-browser-client.ts +++ b/libs/js-sdk/src/create-browser-client.ts @@ -1,4 +1,5 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' +import type { Span } from '@opentelemetry/api' import { SpanStatusCode, trace } from '@opentelemetry/api' import { PromiseCompletionSource } from 'promise-completion-source' import type { BrowserClient } from './client-connection' @@ -45,29 +46,16 @@ export function createBrowserClient({ }): BrowserClient { const tracer = getTracer() - const initialPromise = new PromiseCompletionSource() + const waitingForInitialization: Array> = [] + const initializedCallbacks: Array<(initialised: boolean) => void> = [] + const initialisedState: { - initialisedCallbacks: Array<(initialised: boolean) => void> initialisedPromise: PromiseCompletionSource - initialisedError: Error | undefined + initializedCancellationToken: { cancel: boolean } } = { - initialisedCallbacks: [], - initialisedPromise: initialPromise, - initialisedError: undefined, + initialisedPromise: new PromiseCompletionSource(), + initializedCancellationToken: { 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) @@ -77,16 +65,22 @@ export function createBrowserClient({ api || featureBoardHostedService, ) - const retryCancellationToken = { cancel: false } - tracer.startActiveSpan( - 'connect-with-retry', - { - attributes: { audiences }, - // This is asynchronous so we don't want it nested in the current span - root: true, - }, - (span) => - retry(async () => { + async function initializeWithAudiences( + initializeSpan: Span, + audiences: string[], + ) { + const initialPromise = new PromiseCompletionSource() + const cancellationToken = { cancel: false } + initialPromise.promise.catch(() => {}) + initialisedState.initialisedPromise = initialPromise + initialisedState.initializedCancellationToken = cancellationToken + + try { + await retry(async () => { + if (cancellationToken.cancel) { + return + } + return await tracer.startActiveSpan( 'connect', { @@ -95,72 +89,103 @@ export function createBrowserClient({ updateStrategy: updateStrategyImplementation.name, }, }, - (operationSpan) => + (connectSpan) => updateStrategyImplementation .connect(stateStore) - .finally(() => operationSpan.end()), + .finally(() => connectSpan.end()), ) - }, retryCancellationToken) - .then(() => { - if ( - initialPromise !== initialisedState.initialisedPromise - ) { - return - } - - if (!initialPromise.completed) { - span.end() - initialPromise.resolve(true) - } - }) - .catch((err) => { - if (!initialisedState.initialisedPromise.completed) { - span.setStatus({ code: SpanStatusCode.ERROR }) - span.end() - console.error( - 'FeatureBoard SDK failed to connect after 5 retries', - err, - ) - initialisedState.initialisedError = err - initialisedState.initialisedPromise.resolve(true) - } - }), - ) + }, cancellationToken) + } catch (error) { + if (initialPromise !== initialisedState.initialisedPromise) { + initializeSpan.addEvent( + "Ignoring initialization error as it's out of date", + ) + initializeSpan.end() + return + } + const err = resolveError(error) + + initializeSpan.setStatus({ + code: SpanStatusCode.ERROR, + }) + console.error( + 'FeatureBoard SDK failed to connect after 5 retries', + err, + ) + initialisedState.initialisedPromise.reject(err) + + waitingForInitialization.forEach((w) => w.reject(err)) + waitingForInitialization.length = 0 + initializeSpan.end() + return + } + + // Successfully completed + if (initialPromise !== initialisedState.initialisedPromise) { + initializeSpan.addEvent( + "Ignoring initialization event as it's out of date", + ) + initializeSpan.end() + return + } - const isInitialised = () => { - return initialisedState.initialisedPromise.completed + initialisedState.initialisedPromise.resolve(true) + + notifyWaitingForInitialization(initializedCallbacks, initializeSpan) + waitingForInitialization.forEach((w) => w.resolve(true)) + waitingForInitialization.length = 0 + initializeSpan.end() } + void tracer.startActiveSpan( + 'connect-with-retry', + { + attributes: { audiences }, + }, + (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 initialized = new PromiseCompletionSource() + waitingForInitialization.push(initialized) + return initialized.promise }, subscribeToInitialisedChanged(callback) { - initialisedState.initialisedCallbacks.push(callback) + initializedCallbacks.push(callback) return () => { - initialisedState.initialisedCallbacks.splice( - initialisedState.initialisedCallbacks.indexOf(callback), + initializedCallbacks.splice( + initializedCallbacks.indexOf(callback), 1, ) } }, async updateAudiences(updatedAudiences: string[]) { + if (compareArrays(stateStore.audiences, updatedAudiences)) { + trace.getActiveSpan()?.addEvent('Skipped update audiences', { + updatedAudiences, + currentAudiences: stateStore.audiences, + }) + + // No need to update audiences + return Promise.resolve() + } + + // Close connection and cancel retry + updateStrategyImplementation.close() + initialisedState.initializedCancellationToken.cancel = true + await tracer.startActiveSpan( - 'connect', + 'update-audiences', { attributes: { audiences, @@ -168,81 +193,47 @@ export function createBrowserClient({ }, }, (updateAudiencesSpan) => { - if (compareArrays(stateStore.audiences, updatedAudiences)) { - trace - .getActiveSpan() - ?.addEvent('Skipped update audiences', { - updatedAudiences, - currentAudiences: stateStore.audiences, - initialised: isInitialised(), - }) - updateAudiencesSpan.end() - - // No need to update audiences - return Promise.resolve() - } - - // 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 - } - - // Get the value from the function, just incase it has changed - const initialised = isInitialised() - initialisedState.initialisedCallbacks.forEach((c) => - c(initialised), - ) - }) - - initialisedState.initialisedCallbacks.forEach((c) => - c(false), - ) - stateStore.audiences = updatedAudiences - - updateStrategyImplementation - .connect(stateStore) - .then(() => { - newPromise?.resolve(true) - trace - .getActiveSpan() - ?.addEvent('Updated audiences', { - updatedAudiences, - currentAudiences: stateStore.audiences, - initialised: isInitialised(), - }) - }) - .catch((error) => { - const err = resolveError(error) - updateAudiencesSpan.recordException(err) - updateAudiencesSpan.setStatus({ - code: SpanStatusCode.ERROR, - message: 'Failed to update audiences', - }) - initialisedState.initialisedError = error - newPromise?.resolve(true) - }) - .finally(() => updateAudiencesSpan.end()) + return initializeWithAudiences( + updateAudiencesSpan, + updatedAudiences, + ) }, ) }, updateFeatures() { - return updateStrategyImplementation.updateFeatures() + return tracer.startActiveSpan('manual-update', (span) => + updateStrategyImplementation + .updateFeatures() + .then(() => span.end()), + ) }, close() { - retryCancellationToken.cancel = true + initialisedState.initializedCancellationToken.cancel = true return updateStrategyImplementation.close() }, } } +function notifyWaitingForInitialization( + initializedCallbacks: ((initialised: boolean) => void)[], + initializeSpan: Span, +) { + const errors: Error[] = [] + initializedCallbacks.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') + } + initializedCallbacks.length = 0 +} diff --git a/libs/js-sdk/src/featureboard-fixture.ts b/libs/js-sdk/src/featureboard-fixture.ts new file mode 100644 index 00000000..28393cee --- /dev/null +++ b/libs/js-sdk/src/featureboard-fixture.ts @@ -0,0 +1,41 @@ +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.name, + { + root: true, + attributes: {}, + }, + async (span) => { + try { + await testFn({ ...context, testContext, server }) + } finally { + tracer.startActiveSpan('msw cleanup', (cleanupSpan) => { + server.resetHandlers() + server.close() + cleanupSpan.end() + }) + span.end() + } + }, + ) + } +} diff --git a/libs/js-sdk/src/tests/http-client.spec.ts b/libs/js-sdk/src/tests/http-client.spec.ts index 3fe18abf..2a19bf2a 100644 --- a/libs/js-sdk/src/tests/http-client.spec.ts +++ b/libs/js-sdk/src/tests/http-client.spec.ts @@ -1,26 +1,27 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' -import { describe, expect, it } from 'vitest' +import { 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 { +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: [], @@ -37,27 +38,26 @@ describe('http client', () => { 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', - }, - ] - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + }, + ), +) + +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: [], @@ -73,41 +73,39 @@ describe('http client', () => { ) 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', - }, - ] - - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', - }, - ] - const server = setupServer( + }, + ), +) + +it( + 'can trigger manual update', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), { once: true }, ), http.get( 'https://client.featureboard.app/effective', - () => HttpResponse.json(newValues), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', audiences: [], @@ -123,48 +121,55 @@ describe('http client', () => { '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( + }, + ), +) + +// 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( + { matched: false, lastModified: new Date().toISOString() }, + (context) => [ http.get( 'https://client.featureboard.app/effective', - () => - HttpResponse.json(values, { - headers: { etag: lastModified }, - }), + () => { + 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') === lastModified) { - matched = true + if ( + request.headers.get('if-none-match') === + context.lastModified + ) { + context.matched = true return new Response(null, { status: 304 }) } - console.warn('Request Mismatch', request.url, lastModified) + console.warn( + 'Request Mismatch', + request.url, + request.headers.get('if-none-match'), + context.lastModified, + ) return HttpResponse.json({}, { status: 500 }) }, { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async ({ testContext }) => { const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', audiences: [], @@ -174,56 +179,58 @@ describe('http client', () => { 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', - }, - ] - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', - }, - ] - const lastModified = new Date().toISOString() - const server = setupServer( + expect(testContext.matched).toEqual(true) + }, + ), +) + +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 === lastModified) { + if (ifNoneMatchHeader === testContext.lastModified) { const newLastModified = new Date().toISOString() - return HttpResponse.json(newValues, { - headers: { etag: newLastModified }, - }) + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) } - return HttpResponse.json(values, { - headers: { - etag: lastModified, + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + { + featureKey: 'my-feature-2', + value: 'service-default-value', + }, + ], + { + headers: { + etag: testContext.lastModified, + }, }, - }) + ) }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', audiences: [], @@ -245,90 +252,88 @@ describe('http client', () => { 'default-value', ) expect(value2).toEqual('default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it( - 'can start with last known good config', - async () => { - const server = setupServer( - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json({}, { status: 500 }), - ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const client = createBrowserClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - audiences: ['audience1'], - initialValues: [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ], - updateStrategy: { kind: 'manual' }, - }) + }, + ), +) - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(client.initialised).toEqual(false) - expect(value).toEqual('service-default-value') - await expect(async () => { - await client.waitForInitialised() - }).rejects.toThrowError('500') - expect(client.initialised).toEqual(true) - } finally { - server.resetHandlers() - server.close() - } +it( + 'can start with last known good config', + featureBoardFixture( + {}, + + () => [ + http.get('https://client.featureboard.app/effective', () => + HttpResponse.json({}, { status: 500 }), + ), + ], + async ({}) => { + const client = createBrowserClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + audiences: ['audience1'], + initialValues: [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + updateStrategy: { kind: 'manual' }, + }) + + const value = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(client.initialised).toEqual(false) + expect(value).toEqual('service-default-value') + await expect(async () => { + await client.waitForInitialised() + }).rejects.toThrowError('500') + expect(client.initialised).toEqual(true) }, - { 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( + ), +) + +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(newValues, { - headers: { etag: newLastModified }, - }) + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) } - return HttpResponse.json(values, { - headers: { etag: lastModified }, - }) + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + { + headers: { etag: lastModified }, + }, + ) }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - expect.assertions(5) + ], + async () => { + expect.assertions(4) const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', @@ -342,16 +347,12 @@ describe('http client', () => { expect(httpClient.initialised).toEqual(true) 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') - } + 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( @@ -362,53 +363,54 @@ describe('http client', () => { 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', - }, - ] - let count = 0 - const server = setupServer( + }, + ), +) + +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(newValues, { - headers: { etag: newLastModified }, - }) + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) } - if (count > 0) { - lastModified = new Date().toISOString() + if (testContext.count > 0) { + testContext.lastModified = new Date().toISOString() } - count++ - return HttpResponse.json(values, { - headers: { etag: lastModified }, - }) + testContext.count++ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + { + headers: { etag: testContext.lastModified }, + }, + ) }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - expect.assertions(4) + ], + async ({ testContext }) => { + expect.assertions(3) const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', @@ -441,48 +443,50 @@ describe('http client', () => { await httpClient.waitForInitialised() - expect(count).equal(2) - } finally { - server.resetHandlers() - server.close() - } - }) - - 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( + 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(newValues, { - headers: { etag: newLastModified }, - }) + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ], + { + headers: { etag: newLastModified }, + }, + ) } - return HttpResponse.json(values, { - headers: { etag: lastModified }, - }) + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + { + headers: { etag: testContext.lastModified }, + }, + ) }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - expect.assertions(5) + ], + async () => { + expect.assertions(4) const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', @@ -514,25 +518,24 @@ describe('http client', () => { 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( + }, + ), +) + +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 }, ), ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - expect.assertions(6) + ], + async () => { + expect.assertions(3) const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', @@ -544,16 +547,12 @@ describe('http client', () => { 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('default-value') - } + expect(httpClient.initialised).toEqual(true) + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('default-value') }) const value = httpClient.client.getFeatureValue( @@ -566,20 +565,15 @@ describe('http client', () => { 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', - }, - ] - const server = setupServer( + }, + ), +) + +it( + 'Initialisation fails and retries, no external state store', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/effective', () => @@ -590,12 +584,15 @@ describe('http client', () => { { once: true }, ), http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ]), ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', audiences: [], @@ -610,69 +607,61 @@ describe('http client', () => { '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( - http.get('https://client.featureboard.app/effective', () => { - count++ - return HttpResponse.json( - { message: 'Test Server Request Error' }, - { status: 500 }, - ) - }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await expect(async () => { - await httpClient.waitForInitialised() - }).rejects.toThrowError('500') - expect(count).toEqual(5 + 1) // initial request and 5 retry - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', + }, + ), +) + +it( + 'Initialisation retries 5 times then throws an error', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/effective', () => { + testContext.count++ + return HttpResponse.json( + { message: 'Test Server Request Error' }, + { status: 500 }, ) - expect(value).toEqual('default-value') - } finally { - server.resetHandlers() - server.close() - } + }), + ], + async ({ testContext }) => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await expect(async () => { + await httpClient.waitForInitialised() + }).rejects.toThrowError('500') + 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') }, - { 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', - }, - ] - const server = setupServer( + ), +) + +it( + 'Feature value subscription called during initialisation', + featureBoardFixture( + {}, + () => [ http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ]), ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { + let count = 0 + expect.assertions(2) const httpClient = createBrowserClient({ environmentApiKey: 'env-api-key', audiences: [], @@ -694,9 +683,6 @@ describe('http client', () => { ) await httpClient.waitForInitialised() - } finally { - server.resetHandlers() - server.close() - } - }) -}) + }, + ), +) diff --git a/libs/js-sdk/src/tests/mode-manual.spec.ts b/libs/js-sdk/src/tests/mode-manual.spec.ts index fee71b4f..c74fb7eb 100644 --- a/libs/js-sdk/src/tests/mode-manual.spec.ts +++ b/libs/js-sdk/src/tests/mode-manual.spec.ts @@ -3,6 +3,7 @@ import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { describe, expect, it } from 'vitest' import { createBrowserClient } from '../create-browser-client' +import { openTelemetryTracePassthrough } from '../utils/openTelemetryTracePassthrough' describe('Manual update mode', () => { it('fetches initial values', async () => { @@ -14,6 +15,7 @@ describe('Manual update mode', () => { ] const server = setupServer( + openTelemetryTracePassthrough, http.get( 'https://client.featureboard.app/effective', () => HttpResponse.json(values), @@ -57,6 +59,7 @@ describe('Manual update mode', () => { ] let count = 0 const server = setupServer( + openTelemetryTracePassthrough, http.get('https://client.featureboard.app/effective', () => { if (count > 0) { return HttpResponse.json(newValues) @@ -97,6 +100,7 @@ describe('Manual update mode', () => { ] const server = setupServer( + openTelemetryTracePassthrough, http.get( 'https://client.featureboard.app/effective', () => HttpResponse.json(values), diff --git a/libs/js-sdk/src/tests/mode-polling.spec.ts b/libs/js-sdk/src/tests/mode-polling.spec.ts index 96aba7d1..a9970436 100644 --- a/libs/js-sdk/src/tests/mode-polling.spec.ts +++ b/libs/js-sdk/src/tests/mode-polling.spec.ts @@ -4,6 +4,7 @@ import { setupServer } from 'msw/node' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createBrowserClient } from '../create-browser-client' import { interval } from '../interval' +import { openTelemetryTracePassthrough } from '../utils/openTelemetryTracePassthrough' beforeEach(() => { interval.set = setInterval @@ -21,6 +22,7 @@ describe('Polling update mode', () => { ] const server = setupServer( + openTelemetryTracePassthrough, http.get( 'https://client.featureboard.app/effective', () => HttpResponse.json(values), @@ -64,6 +66,7 @@ describe('Polling update mode', () => { ] const server = setupServer( + openTelemetryTracePassthrough, http.get('https://client.featureboard.app/effective', () => HttpResponse.json(values), ), @@ -105,6 +108,7 @@ describe('Polling update mode', () => { let count = 0 const server = setupServer( + openTelemetryTracePassthrough, http.get('https://client.featureboard.app/effective', () => { if (count > 0) { return HttpResponse.json(newValues) diff --git a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts index 14425f16..e86de80c 100644 --- a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -36,13 +36,13 @@ export function createPollingUpdateStrategy( stopPolling() } stopPolling = pollingUpdates(() => { - getTracer().startActiveSpan( + return getTracer().startActiveSpan( 'Polling update', { attributes: { etag }, root: true }, async (span) => { // Catch errors here to ensure no unhandled promise rejections after a poll if (fetchUpdatesSingle) { - await fetchUpdatesSingle() + return await fetchUpdatesSingle() .catch((err) => { span.recordException(resolveError(err)) }) diff --git a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts index a25f3cf0..506319e5 100644 --- a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -88,6 +88,7 @@ export async function fetchFeaturesConfigurationViaHttp( code: SpanStatusCode.ERROR, message: err.message, }) + throw err } finally { span.end() } 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/retry.ts b/libs/js-sdk/src/utils/retry.ts index 7be450ac..df563155 100644 --- a/libs/js-sdk/src/utils/retry.ts +++ b/libs/js-sdk/src/utils/retry.ts @@ -1,28 +1,29 @@ +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 { const tracer = getTracer() - return tracer.startActiveSpan( - 'retry', - { attributes: { retryAttempt } }, - async (span) => { + return tracer.startActiveSpan('retry', async (span) => { + let retryAttempt = 0 + + // eslint-disable-next-line no-constant-condition + while (true) { try { - const result = await fn() - span.end() - return result + return await retryAttemptFn(tracer, retryAttempt, fn).then( + () => span.end(), + ) } catch (error) { const err = resolveError(error) - span.recordException(err) - if (cancellationToken?.cancel) { span.end() return Promise.resolve() @@ -35,19 +36,41 @@ export async function retry( { cause: err }, ), ) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: 'Operation failed after max retries exceeded', + }) span.end() // Max retries throw error } - span.end() const delayMs = initialDelayMs * Math.pow(backoffFactor, retryAttempt) await tracer.startActiveSpan('delay', (delaySpan) => delay(delayMs).finally(() => delaySpan.end()), - ) // Wait for the calculated delay - return retry(fn, cancellationToken, retryAttempt + 1) // Retry the operation recursively + ) + + retryAttempt++ + } + } + }) +} + +async function retryAttemptFn( + tracer: Tracer, + retryAttempt: number, + fn: () => Promise, +) { + return await tracer.startActiveSpan( + 'retry-attempt', + { attributes: { retryAttempt } }, + async (attemptSpan) => { + try { + return await fn() + } finally { + attemptSpan.end() } }, ) diff --git a/libs/js-sdk/test-setup.ts b/libs/js-sdk/test-setup.ts new file mode 100644 index 00000000..8d37d4a9 --- /dev/null +++ b/libs/js-sdk/test-setup.ts @@ -0,0 +1,31 @@ +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 +let spanProcessor: SimpleSpanProcessor + +beforeAll(({ suite }) => { + spanProcessor = new SimpleSpanProcessor( + process.env.OTEL_EXPORTER_OTLP_ENDPOINT + ? new OTLPTraceExporter() + : new ConsoleSpanExporter(), + ) + + sdk = new NodeSDK({ + serviceName: 'featureboard-js-sdk-test', + spanProcessor: spanProcessor, + instrumentations: [], + }) + + sdk.start() +}) + +afterAll(async () => { + await spanProcessor.forceFlush() + await sdk.shutdown() +}) 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/pnpm-lock.yaml b/pnpm-lock.yaml index f3430f00..3e6cbab9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,15 +222,22 @@ importers: '@opentelemetry/api': specifier: ^1.0.0 version: 1.7.0 - '@opentelemetry/sdk-trace-base': - specifier: ^1.18.1 - version: 1.18.1(@opentelemetry/api@1.7.0) - debug: - specifier: ^4.3.4 - version: 4.3.4 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/live-connection: dependencies: @@ -2242,6 +2249,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'} @@ -3266,10 +3292,25 @@ 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'} - dev: false + + /@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==} @@ -3279,7 +3320,150 @@ packages: dependencies: '@opentelemetry/api': 1.7.0 '@opentelemetry/semantic-conventions': 1.18.1 - dev: false + 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==} @@ -3290,7 +3474,56 @@ packages: '@opentelemetry/api': 1.7.0 '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) '@opentelemetry/semantic-conventions': 1.18.1 - dev: false + 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==} @@ -3302,12 +3535,27 @@ packages: '@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: false + 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: false + dev: true /@phenomnomnominal/tsquery@5.0.1(typescript@5.1.6): resolution: {integrity: sha512-3nVv+e2FQwsW8Aw6qTU6f+1rfcJ3hrcnvH/mu9i8YhxO+9sqbOfpL8m6PbET5+xKOlz/VSbp0RoYWYCtIsnmuA==} @@ -3343,6 +3591,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==} @@ -3777,6 +4068,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 @@ -4323,6 +4618,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: @@ -7020,6 +7323,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'} @@ -8052,6 +8364,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 @@ -8108,6 +8424,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 @@ -8366,6 +8686,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'} @@ -9164,6 +9488,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'} @@ -9490,6 +9833,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 @@ -9764,6 +10118,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: From 3894c40d0521b62bc042109fac63db2dc3b7586e Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Mon, 11 Dec 2023 17:21:48 +0800 Subject: [PATCH 03/28] Update manual fixture --- libs/js-sdk/src/featureboard-fixture.ts | 7 +- libs/js-sdk/src/tests/mode-manual.spec.ts | 134 ++++++++++------------ libs/js-sdk/test-setup.ts | 15 +-- 3 files changed, 70 insertions(+), 86 deletions(-) diff --git a/libs/js-sdk/src/featureboard-fixture.ts b/libs/js-sdk/src/featureboard-fixture.ts index 28393cee..6921113c 100644 --- a/libs/js-sdk/src/featureboard-fixture.ts +++ b/libs/js-sdk/src/featureboard-fixture.ts @@ -28,11 +28,8 @@ export function featureBoardFixture( try { await testFn({ ...context, testContext, server }) } finally { - tracer.startActiveSpan('msw cleanup', (cleanupSpan) => { - server.resetHandlers() - server.close() - cleanupSpan.end() - }) + server.resetHandlers() + server.close() span.end() } }, diff --git a/libs/js-sdk/src/tests/mode-manual.spec.ts b/libs/js-sdk/src/tests/mode-manual.spec.ts index c74fb7eb..9df4f5c5 100644 --- a/libs/js-sdk/src/tests/mode-manual.spec.ts +++ b/libs/js-sdk/src/tests/mode-manual.spec.ts @@ -1,30 +1,27 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' -import { describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' import { createBrowserClient } from '../create-browser-client' -import { openTelemetryTracePassthrough } from '../utils/openTelemetryTracePassthrough' +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( - openTelemetryTracePassthrough, +it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const client = createBrowserClient({ environmentApiKey: 'fake-key', audiences: [], @@ -37,41 +34,35 @@ describe('Manual update mode', () => { '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 newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', - }, - ] - let count = 0 - const server = setupServer( - openTelemetryTracePassthrough, +it( + 'can manually update values', + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/effective', () => { - if (count > 0) { - return HttpResponse.json(newValues) + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ]) } - count++ - return HttpResponse.json(values) + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const client = createBrowserClient({ environmentApiKey: 'fake-key', audiences: [], @@ -85,30 +76,28 @@ describe('Manual update mode', () => { '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 server = setupServer( - openTelemetryTracePassthrough, +it( + 'close', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - try { + ], + async () => { const client = createBrowserClient({ environmentApiKey: 'fake-key', audiences: [], @@ -116,12 +105,9 @@ describe('Manual update mode', () => { }) client.close() - } finally { - server.resetHandlers() - server.close() - } - }) -}) + }, + ), +) declare module '@featureboard/js-sdk' { interface Features extends Record {} diff --git a/libs/js-sdk/test-setup.ts b/libs/js-sdk/test-setup.ts index 8d37d4a9..bfbec11b 100644 --- a/libs/js-sdk/test-setup.ts +++ b/libs/js-sdk/test-setup.ts @@ -7,14 +7,15 @@ import { import { afterAll, beforeAll } from 'vitest' let sdk: NodeSDK -let spanProcessor: SimpleSpanProcessor +let spanProcessor: SimpleSpanProcessor | undefined beforeAll(({ suite }) => { - spanProcessor = new SimpleSpanProcessor( - process.env.OTEL_EXPORTER_OTLP_ENDPOINT - ? new OTLPTraceExporter() - : new ConsoleSpanExporter(), - ) + 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 = new NodeSDK({ serviceName: 'featureboard-js-sdk-test', @@ -26,6 +27,6 @@ beforeAll(({ suite }) => { }) afterAll(async () => { - await spanProcessor.forceFlush() + await spanProcessor?.forceFlush() await sdk.shutdown() }) From e32a99f556772a1a39543b22ebf1c2b70c2d47be Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Mon, 11 Dec 2023 17:25:13 +0800 Subject: [PATCH 04/28] Updated mode-polling.spec --- libs/js-sdk/src/tests/mode-polling.spec.ts | 153 ++++++++++----------- 1 file changed, 69 insertions(+), 84 deletions(-) diff --git a/libs/js-sdk/src/tests/mode-polling.spec.ts b/libs/js-sdk/src/tests/mode-polling.spec.ts index a9970436..a91e75ad 100644 --- a/libs/js-sdk/src/tests/mode-polling.spec.ts +++ b/libs/js-sdk/src/tests/mode-polling.spec.ts @@ -1,37 +1,35 @@ 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 { beforeEach, expect, it, vi } from 'vitest' import { createBrowserClient } from '../create-browser-client' +import { featureBoardFixture } from '../featureboard-fixture' import { interval } from '../interval' -import { openTelemetryTracePassthrough } from '../utils/openTelemetryTracePassthrough' beforeEach(() => { interval.set = setInterval 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( - openTelemetryTracePassthrough, +it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/effective', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + interval.set = vi.fn(() => {}) as any - try { const connection = createBrowserClient({ environmentApiKey: 'fake-key', audiences: [], @@ -45,35 +43,31 @@ describe('Polling update mode', () => { 'default-value', ) expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('sets up interval correctly', async () => { - const handle = {} - interval.set = vi.fn(() => { - return handle - }) as any - interval.clear = vi.fn(() => {}) - - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - - const server = setupServer( - openTelemetryTracePassthrough, + }, + ), +) + +it( + 'sets up interval correctly', + featureBoardFixture( + {}, + () => [ http.get('https://client.featureboard.app/effective', () => - HttpResponse.json(values), + HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]), ), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + const handle = {} + interval.set = vi.fn(() => { + return handle + }) as any + interval.clear = vi.fn(() => {}) - try { const connection = createBrowserClient({ environmentApiKey: 'fake-key', audiences: [], @@ -83,44 +77,38 @@ describe('Polling update mode', () => { 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 - - const values: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'service-default-value', - }, - ] - const newValues: EffectiveFeatureValue[] = [ - { - featureKey: 'my-feature', - value: 'new-service-default-value', - }, - ] - - let count = 0 - const server = setupServer( - openTelemetryTracePassthrough, + }, + ), +) + +it( + 'fetches updates when interval fires', + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/effective', () => { - if (count > 0) { - return HttpResponse.json(newValues) + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'new-service-default-value', + }, + ]) } - count++ - return HttpResponse.json(values) + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ]) }), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + const setMock = vi.fn(() => {}) + interval.set = setMock as any - try { const client = createBrowserClient({ environmentApiKey: 'fake-key', audiences: [], @@ -136,12 +124,9 @@ describe('Polling update mode', () => { 'default-value', ) expect(value).toEqual('new-service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) -}) + }, + ), +) declare module '@featureboard/js-sdk' { interface Features extends Record {} From 141d3cf0c81373a5e065abad58a89b1202c806b1 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Mon, 11 Dec 2023 17:28:40 +0800 Subject: [PATCH 05/28] Fixed retry tests --- libs/js-sdk/src/utils/retry.spec.ts | 14 ++++++++------ libs/js-sdk/src/utils/retry.ts | 8 +++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/libs/js-sdk/src/utils/retry.spec.ts b/libs/js-sdk/src/utils/retry.spec.ts index 90f7da3f..c8a9e64d 100644 --- a/libs/js-sdk/src/utils/retry.spec.ts +++ b/libs/js-sdk/src/utils/retry.spec.ts @@ -26,7 +26,7 @@ describe('retry function with OpenTelemetry', () => { it('should retry the function and succeed', async () => { let attempt = 0 const mockFn = async () => { - if (attempt < 2) { + if (attempt < 1) { attempt++ throw new Error('Temporary failure') } @@ -37,12 +37,14 @@ describe('retry function with OpenTelemetry', () => { expect(result).toBe('Success') const spans = exporter.getFinishedSpans() - expect(spans.length).toEqual(5) + console.log(spans) + expect(spans.length).toEqual(4) + expect(spans[0].name).toBe('retry-attempt') expect(spans[0].attributes?.retryAttempt).toBe(0) - expect(spans[1].name).toEqual('delay') - expect(spans[2].attributes?.retryAttempt).toBe(1) - expect(spans[3].name).toEqual('delay') - expect(spans[4].attributes?.retryAttempt).toBe(2) + expect(spans[1].name).toEqual('retry') + expect(spans[2].name).toEqual('delay') + expect(spans[3].name).toBe('retry-attempt') + expect(spans[3].attributes?.retryAttempt).toBe(1) }) it('should retry the function and fail', async () => { diff --git a/libs/js-sdk/src/utils/retry.ts b/libs/js-sdk/src/utils/retry.ts index df563155..51816d99 100644 --- a/libs/js-sdk/src/utils/retry.ts +++ b/libs/js-sdk/src/utils/retry.ts @@ -19,9 +19,11 @@ export async function retry( // eslint-disable-next-line no-constant-condition while (true) { try { - return await retryAttemptFn(tracer, retryAttempt, fn).then( - () => span.end(), - ) + return await retryAttemptFn( + tracer, + retryAttempt, + fn, + ).finally(() => span.end()) } catch (error) { const err = resolveError(error) if (cancellationToken?.cancel) { From be98aaebbe2b6041eaf818b481971a6768f19a09 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Mon, 11 Dec 2023 17:38:01 +0800 Subject: [PATCH 06/28] Cleanup failing test --- libs/js-sdk/src/utils/retry.spec.ts | 66 ++++++--------------- libs/node-sdk/src/tests/http-client.spec.ts | 6 +- 2 files changed, 20 insertions(+), 52 deletions(-) diff --git a/libs/js-sdk/src/utils/retry.spec.ts b/libs/js-sdk/src/utils/retry.spec.ts index c8a9e64d..9fced70d 100644 --- a/libs/js-sdk/src/utils/retry.spec.ts +++ b/libs/js-sdk/src/utils/retry.spec.ts @@ -1,57 +1,25 @@ -import { trace } from '@opentelemetry/api' -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' import { retry } from './retry' -describe('retry function with OpenTelemetry', () => { - let exporter: InMemorySpanExporter - - beforeEach(() => { - // Set up in-memory exporter - exporter = new InMemorySpanExporter() - const provider = new BasicTracerProvider() - provider.addSpanProcessor(new SimpleSpanProcessor(exporter)) - trace.setGlobalTracerProvider(provider) - }) - - afterEach(() => { - // Clear the spans and reset the tracer - exporter.reset() - }) - - 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' +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) + const result = await retry(mockFn) - expect(result).toBe('Success') - const spans = exporter.getFinishedSpans() - console.log(spans) - expect(spans.length).toEqual(4) - expect(spans[0].name).toBe('retry-attempt') - expect(spans[0].attributes?.retryAttempt).toBe(0) - expect(spans[1].name).toEqual('retry') - expect(spans[2].name).toEqual('delay') - expect(spans[3].name).toBe('retry-attempt') - expect(spans[3].attributes?.retryAttempt).toBe(1) - }) + expect(result).toBe('Success') +}) - it('should retry the function and fail', async () => { - const mockFn = async () => { - throw new Error('Temporary failure') - } +it('should retry the function and fail', async () => { + const mockFn = async () => { + throw new Error('Temporary failure') + } - await expect(retry(mockFn)).rejects.toThrow('Temporary failure') - }) + await expect(retry(mockFn)).rejects.toThrow('Temporary failure') }) diff --git a/libs/node-sdk/src/tests/http-client.spec.ts b/libs/node-sdk/src/tests/http-client.spec.ts index 90af9b80..63f4a02e 100644 --- a/libs/node-sdk/src/tests/http-client.spec.ts +++ b/libs/node-sdk/src/tests/http-client.spec.ts @@ -309,7 +309,7 @@ describe('http client', () => { await expect(async () => { await client.waitForInitialised() }).rejects.toThrowError('500') - expect(count).toEqual(5 + 1) // initial request and 5 retry + expect(count).toEqual(2 + 1) // initial request and 5 retry } finally { server.resetHandlers() server.close() @@ -398,8 +398,8 @@ 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 + expect(countAPIRequest).toEqual(2 + 1) // initial request and 5 retry + expect(countExternalStateStoreRequest).toEqual(2 + 1) // initial request and 5 retry } finally { server.resetHandlers() server.close() From f6b6497b847cfd962ec05a4dcaca7363625f5234 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 10:22:45 +0800 Subject: [PATCH 07/28] Fixed up otel deps --- README.md | 10 ++++++++++ libs/js-sdk/package.json | 5 ++++- libs/node-sdk/package.json | 8 +++++++- pnpm-lock.yaml | 21 +++++++++++++++++---- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 124dd23a..58e0dde0 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,13 @@ To configure the SDK to use a local Open Telemetry Collector, set the following OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 You can put this in a `.env` file in the root of the project. + +## Debugging + +To enable debug logging, set the following environment variable: + +``` +FEATUREBOARD_SDK_DEBUG=true +``` + +In the browser set `window.FEATUREBOARD_SDK_DEBUG = true` diff --git a/libs/js-sdk/package.json b/libs/js-sdk/package.json index 639820e3..5ad6cbea 100644 --- a/libs/js-sdk/package.json +++ b/libs/js-sdk/package.json @@ -33,10 +33,13 @@ }, "dependencies": { "@featureboard/contracts": "workspace:*", - "@opentelemetry/api": "^1.0.0", "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", diff --git a/libs/node-sdk/package.json b/libs/node-sdk/package.json index 4c07a6e4..ced6e15d 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/pnpm-lock.yaml b/pnpm-lock.yaml index 3e6cbab9..8ddceca2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,12 +259,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: @@ -4226,7 +4239,7 @@ packages: debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.3 + semver: 7.5.4 tsutils: 3.21.0(typescript@5.2.2) typescript: 5.2.2 transitivePeerDependencies: From 01294de320bb58d33537388b95bdc6bff2fcb735 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 10:36:40 +0800 Subject: [PATCH 08/28] Instrumented node-sdk --- libs/js-sdk/src/create-browser-client.ts | 11 +- libs/js-sdk/src/create-client.ts | 22 +- .../src/effective-feature-state-store.ts | 8 +- libs/js-sdk/src/index.ts | 1 + libs/js-sdk/src/utils/add-debug-event.ts | 12 + .../src/utils/fetchFeaturesConfiguration.ts | 21 +- libs/js-sdk/src/utils/retry.ts | 71 +- libs/node-sdk/.eslintrc.cjs | 8 +- libs/node-sdk/project.json | 1 + libs/node-sdk/src/feature-state-store.ts | 65 +- libs/node-sdk/src/index.ts | 1 + libs/node-sdk/src/log.ts | 3 - libs/node-sdk/src/server-client.ts | 156 +++-- libs/node-sdk/src/tests/http-client.spec.ts | 617 +++++++++--------- .../createManualUpdateStrategy.ts | 1 - .../createOnRequestUpdateStrategy.ts | 7 +- .../createPollingUpdateStrategy.ts | 1 - .../update-strategies/update-strategies.ts | 4 +- .../src/update-strategies/updates-log.ts | 3 - libs/node-sdk/src/utils/add-debug-event.ts | 8 + libs/node-sdk/src/utils/debug-store.ts | 29 + .../src/utils/featureboard-fixture.ts | 38 ++ .../src/utils/fetchFeaturesConfiguration.ts | 94 +-- libs/node-sdk/src/utils/get-tracer.ts | 6 + libs/node-sdk/src/utils/http-log.ts | 3 - .../utils/openTelemetryTracePassthrough.ts | 6 + libs/node-sdk/src/version.ts | 1 + libs/node-sdk/test-setup.ts | 32 + libs/node-sdk/vitest.config.ts | 1 + 29 files changed, 701 insertions(+), 530 deletions(-) create mode 100644 libs/js-sdk/src/utils/add-debug-event.ts delete mode 100644 libs/node-sdk/src/log.ts delete mode 100644 libs/node-sdk/src/update-strategies/updates-log.ts create mode 100644 libs/node-sdk/src/utils/add-debug-event.ts create mode 100644 libs/node-sdk/src/utils/debug-store.ts create mode 100644 libs/node-sdk/src/utils/featureboard-fixture.ts create mode 100644 libs/node-sdk/src/utils/get-tracer.ts delete mode 100644 libs/node-sdk/src/utils/http-log.ts create mode 100644 libs/node-sdk/src/utils/openTelemetryTracePassthrough.ts create mode 100644 libs/node-sdk/src/version.ts create mode 100644 libs/node-sdk/test-setup.ts diff --git a/libs/js-sdk/src/create-browser-client.ts b/libs/js-sdk/src/create-browser-client.ts index 2f793bda..e460047f 100644 --- a/libs/js-sdk/src/create-browser-client.ts +++ b/libs/js-sdk/src/create-browser-client.ts @@ -1,6 +1,6 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import type { Span } from '@opentelemetry/api' -import { SpanStatusCode, trace } from '@opentelemetry/api' +import { SpanStatusCode } from '@opentelemetry/api' import { PromiseCompletionSource } from 'promise-completion-source' import type { BrowserClient } from './client-connection' import { createClientInternal } from './create-client' @@ -9,6 +9,7 @@ import type { FeatureBoardApiConfig } from './featureboard-api-config' import { featureBoardHostedService } from './featureboard-service-urls' import { resolveUpdateStrategy } from './update-strategies/resolveUpdateStrategy' import type { UpdateStrategies } from './update-strategies/update-strategies' +import { addDebugEvent } from './utils/add-debug-event' import { compareArrays } from './utils/compare-arrays' import { getTracer } from './utils/get-tracer' import { resolveError } from './utils/resolve-error' @@ -97,7 +98,7 @@ export function createBrowserClient({ }, cancellationToken) } catch (error) { if (initialPromise !== initialisedState.initialisedPromise) { - initializeSpan.addEvent( + addDebugEvent( "Ignoring initialization error as it's out of date", ) initializeSpan.end() @@ -122,9 +123,7 @@ export function createBrowserClient({ // Successfully completed if (initialPromise !== initialisedState.initialisedPromise) { - initializeSpan.addEvent( - "Ignoring initialization event as it's out of date", - ) + addDebugEvent("Ignoring initialization event as it's out of date") initializeSpan.end() return } @@ -171,7 +170,7 @@ export function createBrowserClient({ }, async updateAudiences(updatedAudiences: string[]) { if (compareArrays(stateStore.audiences, updatedAudiences)) { - trace.getActiveSpan()?.addEvent('Skipped update audiences', { + addDebugEvent('Skipped update audiences', { updatedAudiences, currentAudiences: stateStore.audiences, }) diff --git a/libs/js-sdk/src/create-client.ts b/libs/js-sdk/src/create-client.ts index ae43d356..c90c1d0e 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 { trace } from '@opentelemetry/api' import type { EffectiveFeatureStateStore } from './effective-feature-state-store' import type { FeatureBoardClient } from './features-client' +import { addDebugEvent } from './utils/add-debug-event' /** Designed for internal SDK use */ export function createClientInternal( @@ -10,7 +10,7 @@ export function createClientInternal( return { getEffectiveValues() { const all = stateStore.all() - trace.getActiveSpan()?.addEvent('getEffectiveValues', {}) + addDebugEvent('getEffectiveValues', { store: JSON.stringify(all) }) return { audiences: [...stateStore.audiences], @@ -24,7 +24,7 @@ export function createClientInternal( }, getFeatureValue: (featureKey, defaultValue) => { const value = stateStore.get(featureKey as string) - trace.getActiveSpan()?.addEvent('getFeatureValue', { + addDebugEvent('getFeatureValue', { featureKey, value, defaultValue, @@ -37,20 +37,18 @@ export function createClientInternal( defaultValue: any, onValue: (value: any) => void, ) { - trace.getActiveSpan()?.addEvent('subscribeToFeatureValue', { + addDebugEvent('subscribeToFeatureValue', { featureKey, defaultValue, }) const callback = (updatedFeatureKey: string, value: any): void => { if (featureKey === updatedFeatureKey) { - trace - .getActiveSpan() - ?.addEvent('subscribeToFeatureValue:update', { - featureKey, - value, - defaultValue, - }) + addDebugEvent('subscribeToFeatureValue:update', { + featureKey, + value, + defaultValue, + }) onValue(value ?? defaultValue) } } @@ -59,7 +57,7 @@ export function createClientInternal( onValue(stateStore.get(featureKey) ?? defaultValue) return () => { - trace.getActiveSpan()?.addEvent('unsubscribeToFeatureValue', { + addDebugEvent('unsubscribeToFeatureValue', { featureKey, }) stateStore.off('feature-updated', callback) diff --git a/libs/js-sdk/src/effective-feature-state-store.ts b/libs/js-sdk/src/effective-feature-state-store.ts index 1bb39b2a..5b4a65fb 100644 --- a/libs/js-sdk/src/effective-feature-state-store.ts +++ b/libs/js-sdk/src/effective-feature-state-store.ts @@ -1,5 +1,5 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' -import { trace } from '@opentelemetry/api' +import { addDebugEvent } from './utils/add-debug-event' export type FeatureValue = EffectiveFeatureValue['value'] | undefined @@ -55,8 +55,7 @@ export class EffectiveFeatureStateStore { } set(featureKey: string, value: FeatureValue) { - const activeSpan = trace.getActiveSpan() - activeSpan?.addEvent('Set', { featureKey, value }) + addDebugEvent('Set', { featureKey, value }) this._store[featureKey] = value @@ -67,8 +66,7 @@ export class EffectiveFeatureStateStore { get(featureKey: string): FeatureValue { const value = this._store[featureKey] - const activeSpan = trace.getActiveSpan() - activeSpan?.addEvent('Get', { featureKey, value }) + addDebugEvent('Get', { featureKey, value }) return value } } diff --git a/libs/js-sdk/src/index.ts b/libs/js-sdk/src/index.ts index ccbba94e..30eaa3bf 100644 --- a/libs/js-sdk/src/index.ts +++ b/libs/js-sdk/src/index.ts @@ -8,6 +8,7 @@ export type { FeatureBoardClient } from './features-client' // Need to figure out how to do that with the current build setup export { createEnsureSingle } 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/utils/add-debug-event.ts b/libs/js-sdk/src/utils/add-debug-event.ts new file mode 100644 index 00000000..797bc44a --- /dev/null +++ b/libs/js-sdk/src/utils/add-debug-event.ts @@ -0,0 +1,12 @@ +import type { Attributes } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' + +export function addDebugEvent(event: string, attributes: Attributes = {}) { + if ( + typeof window !== 'undefined' + ? (window as any)['FEATUREBOARD_SDK_DEBUG'] + : process.env.FEATUREBOARD_SDK_DEBUG + ) { + trace.getActiveSpan()?.addEvent(event, attributes) + } +} diff --git a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts index 506319e5..e24f4807 100644 --- a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -1,8 +1,9 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' -import { SpanStatusCode, trace } from '@opentelemetry/api' +import { SpanStatusCode } from '@opentelemetry/api' import type { EffectiveFeatureStateStore } from '../effective-feature-state-store' import { getEffectiveEndpoint } from '../update-strategies/getEffectiveEndpoint' import { version } from '../version' +import { addDebugEvent } from './add-debug-event' import { compareArrays } from './compare-arrays' import { getTracer } from './get-tracer' import { resolveError } from './resolve-error' @@ -54,15 +55,13 @@ export async function fetchFeaturesConfigurationViaHttp( const newAudiences = getCurrentAudiences() if (!compareArrays(newAudiences, audiences)) { - trace - .getActiveSpan() - ?.addEvent( - 'Audiences changed while fetching, ignoring response', - { - audiences, - newAudiences, - }, - ) + addDebugEvent( + 'Audiences changed while fetching, ignoring response', + { + audiences, + newAudiences, + }, + ) return etag } const existing = { ...stateStore.all() } @@ -75,7 +74,7 @@ export async function fetchFeaturesConfigurationViaHttp( unavailableFeatures.forEach((unavailableFeature) => { stateStore.set(unavailableFeature, undefined) }) - span.addEvent('Feature updates received', { + addDebugEvent('Feature updates received', { audiences, unavailableFeatures, }) diff --git a/libs/js-sdk/src/utils/retry.ts b/libs/js-sdk/src/utils/retry.ts index 51816d99..cd3449f5 100644 --- a/libs/js-sdk/src/utils/retry.ts +++ b/libs/js-sdk/src/utils/retry.ts @@ -16,46 +16,47 @@ export async function retry( return tracer.startActiveSpan('retry', async (span) => { let retryAttempt = 0 - // eslint-disable-next-line no-constant-condition - while (true) { - try { - return await retryAttemptFn( - tracer, - retryAttempt, - fn, - ).finally(() => span.end()) - } catch (error) { - const err = resolveError(error) - if (cancellationToken?.cancel) { - span.end() - return Promise.resolve() - } + try { + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await retryAttemptFn(tracer, retryAttempt, fn) + } catch (error) { + const err = resolveError(error) + if (cancellationToken?.cancel) { + span.end() + 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', - }) - span.end() - // Max retries - throw error - } + 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', + }) + span.end() + // Max retries + throw error + } - const delayMs = - initialDelayMs * Math.pow(backoffFactor, retryAttempt) + const delayMs = + initialDelayMs * Math.pow(backoffFactor, retryAttempt) - await tracer.startActiveSpan('delay', (delaySpan) => - delay(delayMs).finally(() => delaySpan.end()), - ) + await tracer.startActiveSpan('delay', (delaySpan) => + delay(delayMs).finally(() => delaySpan.end()), + ) - retryAttempt++ + retryAttempt++ + } } + } finally { + span.end() } }) } 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/project.json b/libs/node-sdk/project.json index b0ab606f..82ed4394 100644 --- a/libs/node-sdk/project.json +++ b/libs/node-sdk/project.json @@ -21,6 +21,7 @@ "executor": "nx:run-commands", "options": { "commands": [ + "echo \"export const version = '$(jq -r '.version' package.json)';\" > src/version.ts", "tsup src/index.ts -d dist --sourcemap --format esm --legacy-output --external @featureboard/js-sdk", "tsup src/index.ts -d dist/legacycjs --sourcemap --format cjs --legacy-output --external @featureboard/js-sdk", "tsup src/index.ts -d dist --sourcemap --format esm,cjs --external @featureboard/js-sdk", diff --git a/libs/node-sdk/src/feature-state-store.ts b/libs/node-sdk/src/feature-state-store.ts index 5716fd76..5466dc5d 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,50 @@ 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( + '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: 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 - } + 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, + }) + + console.error( + 'Failed to initialise from external state store', + error, + ) + throw error + } + }, + ) 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..82b95171 100644 --- a/libs/node-sdk/src/server-client.ts +++ b/libs/node-sdk/src/server-client.ts @@ -4,15 +4,21 @@ import type { 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 { addDebugEvent } from './utils/add-debug-event' +import { DebugFeatureStateStore } from './utils/debug-store' +import { getTracer } from './utils/get-tracer' export interface CreateServerClientOptions { /** Connect to a self hosted instance of FeatureBoard */ @@ -44,10 +50,18 @@ export function createServerClient({ updateStrategy, environmentApiKey, }: CreateServerClientOptions): ServerClient { + 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 +69,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( + '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() { @@ -102,15 +126,36 @@ export function createServerClient({ request: (audienceKeys: string[]) => { const request = updateStrategyImplementation.onRequest() - serverConnectionDebug( - 'Creating request client for audiences: %o', - audienceKeys, - ) - return request - ? addUserWarnings( - request.then(() => syncRequest(stateStore, audienceKeys)), - ) - : makeRequestClient(syncRequest(stateStore, audienceKeys)) + return tracer.startActiveSpan( + 'get-request-client', + { attributes: { audiences: audienceKeys } }, + (span) => { + if (request) { + return addUserWarnings( + request.then(() => + syncRequest(stateStore, audienceKeys), + ), + ).then( + (client) => { + span.end() + return client + }, + (reason) => { + span.end() + throw reason + }, + ) + } + + try { + return makeRequestClient( + syncRequest(stateStore, audienceKeys), + ) + } finally { + span.end() + } + }, + ) as FeatureBoardClient & PromiseLike }, updateFeatures() { return updateStrategyImplementation.updateFeatures() @@ -147,7 +192,7 @@ export function makeRequestClient( } function syncRequest( - stateStore: AllFeatureStateStore, + stateStore: IFeatureStateStore, audienceKeys: string[], ): FeatureBoardClient { // Shallow copy the feature state so requests are stable @@ -185,20 +230,21 @@ function syncRequest( ) { const featureValues = featuresState[featureKey as string] if (!featureValues) { - serverConnectionDebug( + addDebugEvent( 'getFeatureValue - no value, returning user fallback: %o', - audienceKeys, + { audienceKeys }, ) + return defaultValue } const audienceException = featureValues.audienceExceptions.find((a) => audienceKeys.includes(a.audienceKey), ) const value = audienceException?.value ?? featureValues.defaultValue - serverConnectionDebug('getFeatureValue: %o', { - audienceExceptionValue: audienceException?.value, - defaultValue: featureValues.defaultValue, + addDebugEvent('getFeatureValue', { + featureKey, value, + defaultValue, }) return value } diff --git a/libs/node-sdk/src/tests/http-client.spec.ts b/libs/node-sdk/src/tests/http-client.spec.ts index 63f4a02e..3aad39c5 100644 --- a/libs/node-sdk/src/tests/http-client.spec.ts +++ b/libs/node-sdk/src/tests/http-client.spec.ts @@ -1,30 +1,30 @@ import type { FeatureConfiguration } 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 { 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( +it( + 'calls featureboard /all endpoint on creation', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -44,30 +44,29 @@ describe('http client', () => { .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( + }, + ), +) + +it( + 'can wait for initialisation', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -80,35 +79,34 @@ describe('http client', () => { .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( + }, + ), +) + +it( + 'Gets value using audience exceptions', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [ + { + audienceKey: 'my-audience', + value: 'audience-exception-value', + }, + ], + defaultValue: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -121,52 +119,47 @@ describe('http client', () => { .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', - }, - ] - - let count = 0 - const server = setupServer( + }, + ), +) + +it( + 'can manually fetch updates', + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) + 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', + }, + ]) } - count++ - return HttpResponse.json(values) + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + { + featureKey: 'my-feature-2', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -191,39 +184,41 @@ describe('http client', () => { // This was removed from the server expect(value2).toBe('default-value') expect(value3).toBe('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: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const lastModified = new Date().toISOString() - - const server = setupServer( + }, + ), +) + +// 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') === lastModified) { + if ( + request.headers.get('if-none-match') === + testContext.lastModified + ) { return new Response(null, { status: 304 }) } - return HttpResponse.json(values, { - headers: { - etag: lastModified, + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], + { + headers: { + etag: testContext.lastModified, + }, }, - }) + ) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const httpClient = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -231,21 +226,15 @@ describe('http client', () => { }) 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', - }, - ] - const server = setupServer( + }, + ), +) + +it( + 'Initialisation fails, reties and succeeds, no external state store', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', () => @@ -257,13 +246,18 @@ describe('http client', () => { ), http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const client = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -276,60 +270,53 @@ describe('http 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( - http.get('https://client.featureboard.app/all', () => { - count++ - 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' }, - }) - - await expect(async () => { - await client.waitForInitialised() - }).rejects.toThrowError('500') - expect(count).toEqual(2 + 1) // initial request and 5 retry - } finally { - server.resetHandlers() - server.close() - } }, - { timeout: 60000 }, - ) + ), +) + +it( + 'Initialisation retries 5 time then throws an error, no external state store', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + testContext.count++ + return HttpResponse.json( + { + message: 'Test FeatureBoard API Error', + }, + { status: 500 }, + ) + }), + ], + async ({ testContext }) => { + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - it('Use external state store when API request fails', async () => { - const server = setupServer( + await expect(async () => { + await client.waitForInitialised() + }).rejects.toThrowError('500') + expect(testContext.count).toEqual(2 + 1) // initial request and 5 retry + }, + ), +) + +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 }, ) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const client = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -355,80 +342,72 @@ describe('http client', () => { .request([]) .getFeatureValue('my-feature', 'default-value') expect(value).toEqual('external-state-store-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it( - 'Initialisation retries 5 time then throws an error with external state store', - async () => { - let countAPIRequest = 0 - let countExternalStateStoreRequest = 0 - const server = setupServer( - http.get('https://client.featureboard.app/all', () => { - countAPIRequest++ - 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( - () => { - countExternalStateStoreRequest++ - return Promise.reject({ - message: 'Test External State Store Error', - }) - }, - () => { - return Promise.resolve() - }, - ), - }) - - await expect(async () => { - await client.waitForInitialised() - }).rejects.toThrowError('Test External State Store Error') - expect(countAPIRequest).toEqual(2 + 1) // initial request and 5 retry - expect(countExternalStateStoreRequest).toEqual(2 + 1) // initial request and 5 retry - } finally { - server.resetHandlers() - server.close() - } }, - { timeout: 60000 }, - ) - - it('Update external state store when internal store updates', async () => { - expect.assertions(1) - - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value', - }, - ] + ), +) + +it( + 'Initialisation retries 5 time then throws an error with external state store', + featureBoardFixture( + { countAPIRequest: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + testContext.countAPIRequest++ + return HttpResponse.json( + { message: 'Test FeatureBoard API Error' }, + { status: 500 }, + ) + }), + ], + async ({ testContext }) => { + let countExternalStateStoreRequest = 0 + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + externalStateStore: new MockExternalStateStore( + () => { + countExternalStateStoreRequest++ + return Promise.reject({ + message: 'Test External State Store Error', + }) + }, + () => { + return Promise.resolve() + }, + ), + }) - const server = setupServer( + await expect(async () => { + await client.waitForInitialised() + }).rejects.toThrowError('Test External State Store Error') + 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', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + expect.assertions(1) - try { const client = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -451,33 +430,31 @@ describe('http client', () => { ), }) 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', - }, - ] - - const server = setupServer( + }, + ), +) + +it( + 'Catch error when update external state store throws error', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + expect.assertions(1) - try { const client = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -500,44 +477,41 @@ describe('http client', () => { ), }) 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', - }, - ] - - const values2ndRequest: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value2', - }, - ] - const server = setupServer( + }, + ), +) + +it( + 'Subscription to feature value immediately return current value but will not be called again', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value', + }, + ]), { once: true }, ), http.get('https://client.featureboard.app/all', () => - HttpResponse.json(values2ndRequest), + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-value2', + }, + ]), ), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + let count = 0 + expect.assertions(2) - try { const client = createServerClient({ environmentApiKey: 'env-api-key', api: featureBoardHostedService, @@ -560,9 +534,6 @@ describe('http client', () => { await client.updateFeatures() expect(count).toEqual(1) - } finally { - server.resetHandlers() - server.close() - } - }) -}) + }, + ), +) diff --git a/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts index 5f8396aa..1f9f3f84 100644 --- a/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createManualUpdateStrategy.ts @@ -20,7 +20,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 875bb72e..4c5e37ba 100644 --- a/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts @@ -1,8 +1,8 @@ import { createEnsureSingle } from '@featureboard/js-sdk' +import { addDebugEvent } from '../utils/add-debug-event' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' import { getAllEndpoint } from './getAllEndpoint' import type { AllConfigUpdateStrategy } from './update-strategies' -import { updatesLog } from './updates-log' export function createOnRequestUpdateStrategy( environmentApiKey: string, @@ -23,7 +23,6 @@ export function createOnRequestUpdateStrategy( environmentApiKey, stateStore, etag, - 'on-request', ) }) @@ -48,14 +47,14 @@ export function createOnRequestUpdateStrategy( const now = Date.now() if (!responseExpires || now >= responseExpires) { responseExpires = now + maxAgeMs - updatesLog('Response expired, fetching updates: %o', { + addDebugEvent('Response expired, fetching updates', { maxAgeMs, newExpiry: responseExpires, }) return fetchUpdatesSingle() } - updatesLog('Response not expired: %o', { + addDebugEvent('Response not expired', { responseExpires, now, }) diff --git a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts index 6a5244c4..115f8c85 100644 --- a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -23,7 +23,6 @@ export function createPollingUpdateStrategy( environmentApiKey, stateStore, etag, - 'polling', ) }) 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..19b60220 --- /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('all', { allValues: JSON.stringify(allValues) }) + return { ...allValues } + } + + get(featureKey: string): FeatureConfiguration | undefined { + const value = this.store.get(featureKey) + addDebugEvent('get', { featureKey, value: JSON.stringify(value) }) + + return value + } + + set(featureKey: string, value: FeatureConfiguration | undefined) { + addDebugEvent('set', { 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..60aa92ee --- /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.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 9bda4164..7ae2088a 100644 --- a/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -1,53 +1,69 @@ import 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 { addDebugEvent } from './add-debug-event' +import { getTracer } from './get-tracer' export async function fetchFeaturesConfigurationViaHttp( allEndpoint: string, environmentApiKey: string, - stateStore: AllFeatureStateStore, + stateStore: IFeatureStateStore, etag: string | undefined, - updateTrigger: string, ) { - httpClientDebug( - 'Fetching updates: trigger=%s, lastModified=%s', - updateTrigger, - etag, - ) - const response = await fetch(allEndpoint, { - method: 'GET', - headers: { - 'x-environment-key': environmentApiKey, - ...(etag ? { 'if-none-match': etag } : {}), - }, - }) + return getTracer().startActiveSpan( + 'fetchEffectiveFeatures(http)', + { attributes: { etag } }, + async (span) => { + try { + const response = await fetch(allEndpoint, { + method: 'GET', + headers: { + 'x-environment-key': environmentApiKey, + ...(etag ? { 'if-none-match': etag } : {}), + }, + }) - 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) { - httpClientDebug('No changes') - return etag - } + // Expect most times will just get a response from the HEAD request saying no updates + if (response.status === 304) { + addDebugEvent('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), + ) + 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 + const newEtag = response.headers.get('etag') || undefined + addDebugEvent('fetching updates done', { 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() + } + }, + ) } 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/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..dedab36a --- /dev/null +++ b/libs/node-sdk/test-setup.ts @@ -0,0 +1,32 @@ +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 +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 = new NodeSDK({ + serviceName: 'featureboard-node-sdk-test', + spanProcessor: spanProcessor, + instrumentations: [], + }) + + 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'], }, }) From e47eda070ae423f7c2187ae575ef8b2a2f0aa91c Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:21:13 +0800 Subject: [PATCH 09/28] Fixed tests --- libs/node-sdk/src/server-client.ts | 20 +- .../src/tests/mode-on-request.spec.ts | 222 +++++++++--------- libs/node-sdk/src/tests/mode-polling.spec.ts | 171 +++++++------- 3 files changed, 199 insertions(+), 214 deletions(-) diff --git a/libs/node-sdk/src/server-client.ts b/libs/node-sdk/src/server-client.ts index 82b95171..4d89ab1b 100644 --- a/libs/node-sdk/src/server-client.ts +++ b/libs/node-sdk/src/server-client.ts @@ -124,26 +124,18 @@ export function createServerClient({ return updateStrategyImplementation.close() }, request: (audienceKeys: string[]) => { - const request = updateStrategyImplementation.onRequest() - return tracer.startActiveSpan( 'get-request-client', { attributes: { audiences: audienceKeys } }, (span) => { + const request = updateStrategyImplementation.onRequest() + if (request) { return addUserWarnings( - request.then(() => - syncRequest(stateStore, audienceKeys), - ), - ).then( - (client) => { + request.then(() => { span.end() - return client - }, - (reason) => { - span.end() - throw reason - }, + return syncRequest(stateStore, audienceKeys) + }), ) } @@ -155,7 +147,7 @@ export function createServerClient({ span.end() } }, - ) as FeatureBoardClient & PromiseLike + ) }, updateFeatures() { return updateStrategyImplementation.updateFeatures() 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 a3b11e5c..47e95b92 100644 --- a/libs/node-sdk/src/tests/mode-on-request.spec.ts +++ b/libs/node-sdk/src/tests/mode-on-request.spec.ts @@ -1,28 +1,28 @@ import type { FeatureConfiguration } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' -import { describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' import { createServerClient } from '../server-client' +import { featureBoardFixture } from '../utils/featureboard-fixture' -describe('On request update mode', () => { - it('fetches initial values', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( +it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const client = createServerClient({ environmentApiKey: 'fake-key', updateStrategy: 'on-request', @@ -35,30 +35,29 @@ describe('On request update mode', () => { 'default-value', ) expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('throws if request() is not awaited in request mode', async () => { - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( + }, + ), +) + +it( + 'throws if request() is not awaited in request mode', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const client = createServerClient({ environmentApiKey: 'fake-key', updateStrategy: 'on-request', @@ -73,43 +72,39 @@ describe('On request update mode', () => { ).toThrow( 'request() must be awaited when using on-request update strategy', ) - } finally { - server.resetHandlers() - server.close() - } - }) - - // 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( + }, + ), +) + +// 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', + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) } - count++ - return HttpResponse.json(values) + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { const connection = createServerClient({ environmentApiKey: 'fake-key', updateStrategy: 'on-request', @@ -120,55 +115,56 @@ describe('On request update mode', () => { expect( client.getFeatureValue('my-feature', 'default-value'), ).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('fetches update when response is 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( + }, + ), +) + +it( + 'fetches update when response is expired', + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/all', () => { - if (count > 0) { - return HttpResponse.json(newValues) + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) } - count++ - return HttpResponse.json(values) + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) }), - ) - server.listen({ onUnhandledRequest: 'error' }) - - const connection = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: { kind: 'on-request', options: { maxAgeMs: 1 } }, - }) - await connection.waitForInitialised() + ], + async () => { + 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)) + // Ensure response has expired + await new Promise((resolve) => setTimeout(resolve, 10)) - const client = await connection.request([]) - expect(client.getFeatureValue('my-feature', 'default-value')).toEqual( - 'new-service-default-value', - ) - }) -}) + const client = await connection.request([]) + expect( + client.getFeatureValue('my-feature', 'default-value'), + ).toEqual('new-service-default-value') + }, + ), +) declare module '@featureboard/js-sdk' { interface Features extends Record {} diff --git a/libs/node-sdk/src/tests/mode-polling.spec.ts b/libs/node-sdk/src/tests/mode-polling.spec.ts index 36b193f2..b1d1fd1b 100644 --- a/libs/node-sdk/src/tests/mode-polling.spec.ts +++ b/libs/node-sdk/src/tests/mode-polling.spec.ts @@ -1,35 +1,35 @@ 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 { beforeEach, 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( +it( + 'fetches initial values', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) - - try { + ], + async () => { + interval.set = vi.fn(() => {}) as any const client = createServerClient({ environmentApiKey: 'fake-key', updateStrategy: 'polling', @@ -40,36 +40,35 @@ describe('Polling update mode', () => { .request([]) .getFeatureValue('my-feature', 'default-value') expect(value).toEqual('service-default-value') - } finally { - server.resetHandlers() - server.close() - } - }) - - it('sets up interval correctly', async () => { - const handle = {} - interval.set = vi.fn(() => { - return handle - }) as any - interval.clear = vi.fn(() => {}) + }, + ), +) - const values: FeatureConfiguration[] = [ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ] - const server = setupServer( +it( + 'sets up interval correctly', + featureBoardFixture( + {}, + () => [ http.get( 'https://client.featureboard.app/all', - () => HttpResponse.json(values), + () => + HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]), { once: true }, ), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + const handle = {} + interval.set = vi.fn(() => { + return handle + }) as any + interval.clear = vi.fn(() => {}) - try { const client = createServerClient({ environmentApiKey: 'fake-key', updateStrategy: 'polling', @@ -78,61 +77,59 @@ describe('Polling update mode', () => { 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 + }, + ), +) - 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( +it( + 'fetches updates when interval fires', + featureBoardFixture( + { count: 0 }, + (testContext) => [ http.get('https://client.featureboard.app/all', () => { - if (count > 1) { + if (testContext.count > 1) { throw new Error('Too many requests') } - if (count > 0) { - return HttpResponse.json(newValues) + if (testContext.count > 0) { + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'new-service-default-value', + }, + ]) } - count++ - return HttpResponse.json(values) + testContext.count++ + return HttpResponse.json([ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ]) }), - ) - server.listen({ onUnhandledRequest: 'error' }) + ], + async () => { + const setMock = vi.fn(() => {}) + interval.set = setMock as any - const client = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'polling', - }) - await client.waitForInitialised() + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'polling', + }) + await client.waitForInitialised() - const pollCallback = (setMock.mock.calls[0] as any)[0] - await pollCallback() + const pollCallback = (setMock.mock.calls[0] as any)[0] + await pollCallback() - const value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('new-service-default-value') - }) -}) + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('new-service-default-value') + }, + ), +) declare module '@featureboard/js-sdk' { interface Features extends Record {} From bb3a2aa79aed11f87194c92b65c22f4aee688a3b Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:24:28 +0800 Subject: [PATCH 10/28] Update lockfile --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ddceca2..22d5ed4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,13 +219,13 @@ importers: '@featureboard/contracts': specifier: workspace:* version: link:../contracts - '@opentelemetry/api': - specifier: ^1.0.0 - version: 1.7.0 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) From fdaff8d29ab7849f6dd0c86251c05c176bfb0c83 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:24:49 +0800 Subject: [PATCH 11/28] Add changeset --- .changeset/cuddly-rivers-bathe.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/cuddly-rivers-bathe.md 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 From b450823bc3e71728667bfa37969380aa011a44eb Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:27:12 +0800 Subject: [PATCH 12/28] Initialzed -> initialised --- libs/js-sdk/src/create-browser-client.ts | 48 ++++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/libs/js-sdk/src/create-browser-client.ts b/libs/js-sdk/src/create-browser-client.ts index e460047f..4e206fce 100644 --- a/libs/js-sdk/src/create-browser-client.ts +++ b/libs/js-sdk/src/create-browser-client.ts @@ -47,15 +47,15 @@ export function createBrowserClient({ }): BrowserClient { const tracer = getTracer() - const waitingForInitialization: Array> = [] - const initializedCallbacks: Array<(initialised: boolean) => void> = [] + const waitingForInitialisation: Array> = [] + const initialisedCallbacks: Array<(initialised: boolean) => void> = [] const initialisedState: { initialisedPromise: PromiseCompletionSource - initializedCancellationToken: { cancel: boolean } + initialisedCancellationToken: { cancel: boolean } } = { initialisedPromise: new PromiseCompletionSource(), - initializedCancellationToken: { cancel: false }, + initialisedCancellationToken: { cancel: false }, } const stateStore = new EffectiveFeatureStateStore(audiences, initialValues) @@ -74,7 +74,7 @@ export function createBrowserClient({ const cancellationToken = { cancel: false } initialPromise.promise.catch(() => {}) initialisedState.initialisedPromise = initialPromise - initialisedState.initializedCancellationToken = cancellationToken + initialisedState.initialisedCancellationToken = cancellationToken try { await retry(async () => { @@ -99,7 +99,7 @@ export function createBrowserClient({ } catch (error) { if (initialPromise !== initialisedState.initialisedPromise) { addDebugEvent( - "Ignoring initialization error as it's out of date", + "Ignoring initialisation error as it's out of date", ) initializeSpan.end() return @@ -115,24 +115,24 @@ export function createBrowserClient({ ) initialisedState.initialisedPromise.reject(err) - waitingForInitialization.forEach((w) => w.reject(err)) - waitingForInitialization.length = 0 + waitingForInitialisation.forEach((w) => w.reject(err)) + waitingForInitialisation.length = 0 initializeSpan.end() return } // Successfully completed if (initialPromise !== initialisedState.initialisedPromise) { - addDebugEvent("Ignoring initialization event as it's out of date") + addDebugEvent("Ignoring initialisation event as it's out of date") initializeSpan.end() return } initialisedState.initialisedPromise.resolve(true) - notifyWaitingForInitialization(initializedCallbacks, initializeSpan) - waitingForInitialization.forEach((w) => w.resolve(true)) - waitingForInitialization.length = 0 + notifyWaitingForInitialisation(initialisedCallbacks, initializeSpan) + waitingForInitialisation.forEach((w) => w.resolve(true)) + waitingForInitialisation.length = 0 initializeSpan.end() } @@ -155,15 +155,15 @@ export function createBrowserClient({ return initialisedState.initialisedPromise.promise } - const initialized = new PromiseCompletionSource() - waitingForInitialization.push(initialized) - return initialized.promise + const initialised = new PromiseCompletionSource() + waitingForInitialisation.push(initialised) + return initialised.promise }, subscribeToInitialisedChanged(callback) { - initializedCallbacks.push(callback) + initialisedCallbacks.push(callback) return () => { - initializedCallbacks.splice( - initializedCallbacks.indexOf(callback), + initialisedCallbacks.splice( + initialisedCallbacks.indexOf(callback), 1, ) } @@ -181,7 +181,7 @@ export function createBrowserClient({ // Close connection and cancel retry updateStrategyImplementation.close() - initialisedState.initializedCancellationToken.cancel = true + initialisedState.initialisedCancellationToken.cancel = true await tracer.startActiveSpan( 'update-audiences', @@ -208,17 +208,17 @@ export function createBrowserClient({ ) }, close() { - initialisedState.initializedCancellationToken.cancel = true + initialisedState.initialisedCancellationToken.cancel = true return updateStrategyImplementation.close() }, } } -function notifyWaitingForInitialization( - initializedCallbacks: ((initialised: boolean) => void)[], +function notifyWaitingForInitialisation( + initialisedCallbacks: ((initialised: boolean) => void)[], initializeSpan: Span, ) { const errors: Error[] = [] - initializedCallbacks.forEach((c) => { + initialisedCallbacks.forEach((c) => { try { c(true) } catch (error) { @@ -234,5 +234,5 @@ function notifyWaitingForInitialization( if (errors.length > 0) { throw new AggregateError(errors, 'Multiple callback errors occurred') } - initializedCallbacks.length = 0 + initialisedCallbacks.length = 0 } From 78751a20b7b1ac8f7df2aa72aedc55d92920ec87 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:30:32 +0800 Subject: [PATCH 13/28] Dependency cleanup --- libs/js-sdk/package.json | 3 +-- libs/node-sdk/package.json | 3 +-- pnpm-lock.yaml | 6 ------ 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/libs/js-sdk/package.json b/libs/js-sdk/package.json index 5ad6cbea..4a7224af 100644 --- a/libs/js-sdk/package.json +++ b/libs/js-sdk/package.json @@ -42,7 +42,6 @@ "@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" + "@opentelemetry/sdk-trace-base": "^1.18.1" } } diff --git a/libs/node-sdk/package.json b/libs/node-sdk/package.json index ced6e15d..4485596c 100644 --- a/libs/node-sdk/package.json +++ b/libs/node-sdk/package.json @@ -39,7 +39,6 @@ "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" + "@opentelemetry/sdk-trace-base": "^1.18.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22d5ed4d..21b52f70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,9 +235,6 @@ importers: '@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: @@ -275,9 +272,6 @@ importers: '@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: From 69f014252f4a68930ae65b596b549c1539d232f7 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:35:30 +0800 Subject: [PATCH 14/28] Update version in code when versioning --- .github/workflows/main.yml | 4 ++++ libs/js-sdk/project.json | 11 +++++++++-- libs/node-sdk/project.json | 13 +++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) 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/libs/js-sdk/project.json b/libs/js-sdk/project.json index fbe71797..c897d4b9 100644 --- a/libs/js-sdk/project.json +++ b/libs/js-sdk/project.json @@ -17,11 +17,17 @@ "cwd": "libs/js-sdk" } }, + "version": { + "executor": "@nrwl/workspace: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": { "commands": [ - "echo \"export const version = '$(jq -r '.version' package.json)';\" > src/version.ts", "tsup src/index.ts -d dist --sourcemap --format esm --legacy-output --external @featureboard/contracts --env.TEST false", "tsup src/index.ts -d dist/legacycjs --sourcemap --format cjs --legacy-output --external @featureboard/contracts --env.TEST false", "tsup src/index.ts -d dist --sourcemap --format esm,cjs --external @featureboard/contracts --env.TEST false", @@ -29,7 +35,8 @@ ], "cwd": "libs/js-sdk", "parallel": false - } + }, + "dependsOn": ["version"] } } } diff --git a/libs/node-sdk/project.json b/libs/node-sdk/project.json index 82ed4394..d6d49d60 100644 --- a/libs/node-sdk/project.json +++ b/libs/node-sdk/project.json @@ -17,11 +17,19 @@ "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": { "commands": [ - "echo \"export const version = '$(jq -r '.version' package.json)';\" > src/version.ts", "tsup src/index.ts -d dist --sourcemap --format esm --legacy-output --external @featureboard/js-sdk", "tsup src/index.ts -d dist/legacycjs --sourcemap --format cjs --legacy-output --external @featureboard/js-sdk", "tsup src/index.ts -d dist --sourcemap --format esm,cjs --external @featureboard/js-sdk", @@ -29,7 +37,8 @@ ], "cwd": "libs/node-sdk", "parallel": false - } + }, + "dependsOn": ["version"] } } } From 48d8824f3b705c089eb9319beec19a68444893df Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:35:41 +0800 Subject: [PATCH 15/28] Remove versioning in PR build --- .github/workflows/pr.yml | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) 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 From e73e26daf52c154eaff73c2e255d0418c7a1ab87 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:48:30 +0800 Subject: [PATCH 16/28] Removed additional end spans --- libs/js-sdk/src/utils/retry.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/js-sdk/src/utils/retry.ts b/libs/js-sdk/src/utils/retry.ts index cd3449f5..fc0b28e5 100644 --- a/libs/js-sdk/src/utils/retry.ts +++ b/libs/js-sdk/src/utils/retry.ts @@ -24,7 +24,6 @@ export async function retry( } catch (error) { const err = resolveError(error) if (cancellationToken?.cancel) { - span.end() return Promise.resolve() } @@ -40,7 +39,6 @@ export async function retry( message: 'Operation failed after max retries exceeded', }) - span.end() // Max retries throw error } From e5983cc71ecd956dde9f286b5cad873031563496 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:49:18 +0800 Subject: [PATCH 17/28] Remove x-sdk-version header --- libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts index e24f4807..25fedbc5 100644 --- a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -2,7 +2,6 @@ import 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 { version } from '../version' import { addDebugEvent } from './add-debug-event' import { compareArrays } from './compare-arrays' import { getTracer } from './get-tracer' @@ -29,7 +28,6 @@ export async function fetchFeaturesConfigurationViaHttp( method: 'GET', headers: { 'x-environment-key': environmentApiKey, - 'x-sdk-version': version, ...(etag ? { 'if-none-match': etag } : {}), }, }) From 4cb0964eecfa17d6004360e6cd4ae3c6b2430982 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:53:02 +0800 Subject: [PATCH 18/28] Fixed incorrect executor --- libs/js-sdk/project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/js-sdk/project.json b/libs/js-sdk/project.json index c897d4b9..52c0134a 100644 --- a/libs/js-sdk/project.json +++ b/libs/js-sdk/project.json @@ -18,7 +18,7 @@ } }, "version": { - "executor": "@nrwl/workspace:run-commands", + "executor": "nx:run-commands", "options": { "command": "echo \"export const version = '$(jq -r '.version' package.json)';\" > src/version.ts", "cwd": "libs/js-sdk" From f2c9bb68af0bba2d7f275b02fb15113eb0fc3d7b Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 15:58:30 +0800 Subject: [PATCH 19/28] Add missing dependencies back in --- libs/js-sdk/package.json | 3 ++- libs/node-sdk/package.json | 3 ++- pnpm-lock.yaml | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/libs/js-sdk/package.json b/libs/js-sdk/package.json index 4a7224af..5ad6cbea 100644 --- a/libs/js-sdk/package.json +++ b/libs/js-sdk/package.json @@ -42,6 +42,7 @@ "@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-base": "^1.18.1", + "@opentelemetry/sdk-trace-node": "^1.18.1" } } diff --git a/libs/node-sdk/package.json b/libs/node-sdk/package.json index 4485596c..ced6e15d 100644 --- a/libs/node-sdk/package.json +++ b/libs/node-sdk/package.json @@ -39,6 +39,7 @@ "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-base": "^1.18.1", + "@opentelemetry/sdk-trace-node": "^1.18.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21b52f70..22d5ed4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,9 @@ importers: '@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: @@ -272,6 +275,9 @@ importers: '@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: From c6e2db89926efa7e2499bdcc8804f1e94eb520e3 Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 16:08:51 +0800 Subject: [PATCH 20/28] Add using verdaccio instructions --- CONTRIBUTING.md | 8 ++++++++ docs/use-verdaccio.md | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/use-verdaccio.md 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/docs/use-verdaccio.md b/docs/use-verdaccio.md new file mode 100644 index 00000000..efaf00e9 --- /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 pacakge) +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/ +``` From c2e79948f23edb81f49e416984402554bde3cbaa Mon Sep 17 00:00:00 2001 From: Jake Ginnivan Date: Tue, 12 Dec 2023 16:09:03 +0800 Subject: [PATCH 21/28] Don't init otel sdk when env variables not configured --- libs/js-sdk/test-setup.ts | 18 ++++++++++-------- libs/node-sdk/test-setup.ts | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/libs/js-sdk/test-setup.ts b/libs/js-sdk/test-setup.ts index bfbec11b..d8b3a41c 100644 --- a/libs/js-sdk/test-setup.ts +++ b/libs/js-sdk/test-setup.ts @@ -6,7 +6,7 @@ import { } from '@opentelemetry/sdk-trace-node' import { afterAll, beforeAll } from 'vitest' -let sdk: NodeSDK +let sdk: NodeSDK | undefined let spanProcessor: SimpleSpanProcessor | undefined beforeAll(({ suite }) => { @@ -17,16 +17,18 @@ beforeAll(({ suite }) => { : undefined spanProcessor = exporter ? new SimpleSpanProcessor(exporter) : undefined - sdk = new NodeSDK({ - serviceName: 'featureboard-js-sdk-test', - spanProcessor: spanProcessor, - instrumentations: [], - }) + sdk = spanProcessor + ? new NodeSDK({ + serviceName: 'featureboard-js-sdk-test', + spanProcessor: spanProcessor, + instrumentations: [], + }) + : undefined - sdk.start() + sdk?.start() }) afterAll(async () => { await spanProcessor?.forceFlush() - await sdk.shutdown() + await sdk?.shutdown() }) diff --git a/libs/node-sdk/test-setup.ts b/libs/node-sdk/test-setup.ts index dedab36a..4b9bb06a 100644 --- a/libs/node-sdk/test-setup.ts +++ b/libs/node-sdk/test-setup.ts @@ -6,7 +6,7 @@ import { } from '@opentelemetry/sdk-trace-node' import { afterAll, beforeAll } from 'vitest' -let sdk: NodeSDK +let sdk: NodeSDK | undefined let spanProcessor: SimpleSpanProcessor | undefined beforeAll(({ suite }) => { @@ -17,16 +17,18 @@ beforeAll(({ suite }) => { : undefined spanProcessor = exporter ? new SimpleSpanProcessor(exporter) : undefined - sdk = new NodeSDK({ - serviceName: 'featureboard-node-sdk-test', - spanProcessor: spanProcessor, - instrumentations: [], - }) + sdk = spanProcessor + ? new NodeSDK({ + serviceName: 'featureboard-node-sdk-test', + spanProcessor: spanProcessor, + instrumentations: [], + }) + : undefined - sdk.start() + sdk?.start() }) afterAll(async () => { await spanProcessor?.forceFlush() - await sdk.shutdown() + await sdk?.shutdown() }) From 99cfb2d94bfb3eed6ce5581e764dac5ef92c19ad Mon Sep 17 00:00:00 2001 From: Ida Danielsson Date: Wed, 17 Jan 2024 13:14:23 +0800 Subject: [PATCH 22/28] Set parent span when starting polling updates span --- .../createPollingUpdateStrategy.ts | 15 +++++++----- libs/js-sdk/src/utils/start-active-span.ts | 23 +++++++++++++++++++ .../createPollingUpdateStrategy.ts | 16 ++++++++----- libs/node-sdk/src/utils/start-active-span.ts | 23 +++++++++++++++++++ 4 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 libs/js-sdk/src/utils/start-active-span.ts create mode 100644 libs/node-sdk/src/utils/start-active-span.ts diff --git a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts index b4f02025..92c8441d 100644 --- a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -1,8 +1,9 @@ +import { trace } from '@opentelemetry/api' import { createEnsureSingleWithBackoff } from '../ensure-single' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' -import { getTracer } from '../utils/get-tracer' import { pollingUpdates } from '../utils/pollingUpdates' import { resolveError } from '../utils/resolve-error' +import { startActiveSpan } from '../utils/start-active-span' import type { EffectiveConfigUpdateStrategy } from './update-strategies' export function createPollingUpdateStrategy( @@ -13,6 +14,7 @@ export function createPollingUpdateStrategy( let stopPolling: undefined | (() => void) let etag: undefined | string let fetchUpdatesSingle: undefined | (() => Promise) + const parentSpan = trace.getActiveSpan() return { name: 'polling', @@ -36,10 +38,11 @@ export function createPollingUpdateStrategy( stopPolling() } stopPolling = pollingUpdates(() => { - return getTracer().startActiveSpan( - 'polling-updates', - { attributes: { etag }, root: true }, - async (span) => { + return startActiveSpan({ + name: 'polling-updates', + options: { attributes: { etag } }, + parentSpan, + fn: async (span) => { // Catch errors here to ensure no unhandled promise rejections after a poll if (fetchUpdatesSingle) { return await fetchUpdatesSingle() @@ -49,7 +52,7 @@ export function createPollingUpdateStrategy( .finally(() => span.end()) } }, - ) + }) }, intervalMs) return await fetchUpdatesSingle() 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/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts index 8789a86e..c103ba06 100644 --- a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -2,9 +2,10 @@ import { createEnsureSingleWithBackoff, resolveError, } from '@featureboard/js-sdk' +import { trace } from '@opentelemetry/api' import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfiguration' -import { getTracer } from '../utils/get-tracer' import { pollingUpdates } from '../utils/pollingUpdates' +import { startActiveSpan } from '../utils/start-active-span' import { getAllEndpoint } from './getAllEndpoint' import type { AllConfigUpdateStrategy } from './update-strategies' @@ -17,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 @@ -34,10 +37,11 @@ export function createPollingUpdateStrategy( stopPolling() } stopPolling = pollingUpdates(() => { - return getTracer().startActiveSpan( - 'polling-updates', - { attributes: { etag }, root: true }, - async (span) => { + return startActiveSpan({ + name: 'polling-updates', + options: { attributes: { etag } }, + parentSpan, + fn: async (span) => { if (fetchUpdatesSingle) { // Catch errors here to ensure no unhandled promise rejections after a poll return await fetchUpdatesSingle() @@ -47,7 +51,7 @@ export function createPollingUpdateStrategy( .finally(() => span.end()) } }, - ) + }) }, intervalMs) return fetchUpdatesSingle() 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) +} From 24b9f01337a1c02f333c08e1c4fb9be52500a996 Mon Sep 17 00:00:00 2001 From: Ida Danielsson Date: Wed, 17 Jan 2024 13:15:11 +0800 Subject: [PATCH 23/28] reset error in ensure single --- libs/js-sdk/src/ensure-single.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/js-sdk/src/ensure-single.ts b/libs/js-sdk/src/ensure-single.ts index dc63fe84..21fb5c90 100644 --- a/libs/js-sdk/src/ensure-single.ts +++ b/libs/js-sdk/src/ensure-single.ts @@ -14,6 +14,7 @@ export function createEnsureSingleWithBackoff( ) { return Promise.reject(tooManyRequestsError) } + tooManyRequestsError = undefined if (!current) { current = cb() .catch((error: Error) => { From 5a59ee93b1129edeadb6de57f2322f0d42fe6ccd Mon Sep 17 00:00:00 2001 From: Ida Danielsson Date: Wed, 17 Jan 2024 20:00:29 +0800 Subject: [PATCH 24/28] No parent span use root for polling updates span --- .../js-sdk/src/update-strategies/createPollingUpdateStrategy.ts | 2 +- .../src/update-strategies/createPollingUpdateStrategy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts index 92c8441d..976486ce 100644 --- a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -40,7 +40,7 @@ export function createPollingUpdateStrategy( stopPolling = pollingUpdates(() => { return startActiveSpan({ name: 'polling-updates', - options: { attributes: { etag } }, + options: { attributes: { etag }, root: !parentSpan }, parentSpan, fn: async (span) => { // Catch errors here to ensure no unhandled promise rejections after a poll diff --git a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts index c103ba06..93ac377e 100644 --- a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -39,7 +39,7 @@ export function createPollingUpdateStrategy( stopPolling = pollingUpdates(() => { return startActiveSpan({ name: 'polling-updates', - options: { attributes: { etag } }, + options: { attributes: { etag }, root: !parentSpan }, parentSpan, fn: async (span) => { if (fetchUpdatesSingle) { From d31967cba6e11f8c603e4955cd712f98334abbd7 Mon Sep 17 00:00:00 2001 From: Ida Danielsson Date: Wed, 17 Jan 2024 20:38:06 +0800 Subject: [PATCH 25/28] Update log message for on request --- .../createOnRequestUpdateStrategy.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts index 9524a456..b3d8f0da 100644 --- a/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts @@ -52,28 +52,27 @@ export function createOnRequestUpdateStrategy( if (fetchUpdatesSingle) { const now = Date.now() if (!responseExpires || now >= responseExpires) { - span.addEvent('onRequestUpdating', { - message: 'Response expired, fetching updates', + span.addEvent('Response expired, fetching update', { maxAgeMs, expiry: responseExpires, }) return fetchUpdatesSingle() .then(() => { responseExpires = now + maxAgeMs - span.addEvent('onRequestUpdated', { - message: - 'Successfully updated features', - maxAgeMs, - newExpiry: responseExpires, - }) + span.addEvent( + 'Successfully updated features', + { + maxAgeMs, + newExpiry: responseExpires, + }, + ) }) .catch((error) => { span.recordException(resolveError(error)) }) .finally(() => span.end()) } - span.addEvent('onRequestNotExpired', { - message: 'Response not expired', + span.addEvent('Response not expired', { maxAgeMs, expiry: responseExpires, now, From f8119439cfe942a8df7c9ad55f4739fabcb07c53 Mon Sep 17 00:00:00 2001 From: Ida Danielsson Date: Wed, 24 Jan 2024 17:42:24 +0800 Subject: [PATCH 26/28] Option to disable otel, updated span names, remove FEATUREBOARD_SDK_DEBUG env variable --- .vscode/settings.json | 15 +- README.md | 10 - libs/js-sdk/src/client-options.ts | 9 + libs/js-sdk/src/create-browser-client.ts | 103 +- libs/js-sdk/src/create-client.ts | 144 +- .../src/effective-feature-state-store.ts | 4 +- libs/js-sdk/src/ensure-single.ts | 2 + libs/js-sdk/src/featureboard-fixture.ts | 2 +- libs/js-sdk/src/tests/http-client.spec.ts | 1671 +++++++++-------- libs/js-sdk/src/tests/manual-client.spec.ts | 55 +- libs/js-sdk/src/tests/mode-manual.spec.ts | 193 +- libs/js-sdk/src/tests/mode-polling.spec.ts | 306 +-- .../createPollingUpdateStrategy.ts | 3 +- libs/js-sdk/src/utils/add-debug-event.ts | 8 +- .../src/utils/fetchFeaturesConfiguration.ts | 41 +- libs/js-sdk/src/utils/get-tracer.ts | 4 +- libs/js-sdk/src/utils/retry.ts | 11 +- libs/js-sdk/src/utils/trace-provider.ts | 10 + libs/node-sdk/src/feature-state-store.ts | 5 +- libs/node-sdk/src/server-client.ts | 108 +- libs/node-sdk/src/tests/http-client.spec.ts | 1441 +++++++------- libs/node-sdk/src/tests/mode-manual.spec.ts | 314 ++-- .../src/tests/mode-on-request.spec.ts | 376 ++-- libs/node-sdk/src/tests/mode-polling.spec.ts | 327 ++-- .../createOnRequestUpdateStrategy.ts | 5 +- .../createPollingUpdateStrategy.ts | 3 +- libs/node-sdk/src/utils/debug-store.ts | 6 +- .../src/utils/featureboard-fixture.ts | 2 +- .../src/utils/fetchFeaturesConfiguration.ts | 7 +- libs/node-sdk/src/utils/trace-provider.ts | 10 + 30 files changed, 2711 insertions(+), 2484 deletions(-) create mode 100644 libs/js-sdk/src/client-options.ts create mode 100644 libs/js-sdk/src/utils/trace-provider.ts create mode 100644 libs/node-sdk/src/utils/trace-provider.ts 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/README.md b/README.md index 58e0dde0..124dd23a 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,3 @@ To configure the SDK to use a local Open Telemetry Collector, set the following OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 You can put this in a `.env` file in the root of the project. - -## Debugging - -To enable debug logging, set the following environment variable: - -``` -FEATUREBOARD_SDK_DEBUG=true -``` - -In the browser set `window.FEATUREBOARD_SDK_DEBUG = true` 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 4e206fce..635950d9 100644 --- a/libs/js-sdk/src/create-browser-client.ts +++ b/libs/js-sdk/src/create-browser-client.ts @@ -3,17 +3,18 @@ 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 { resolveUpdateStrategy } from './update-strategies/resolveUpdateStrategy' import type { UpdateStrategies } from './update-strategies/update-strategies' -import { addDebugEvent } from './utils/add-debug-event' 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 @@ -26,6 +27,7 @@ export function createBrowserClient({ api, audiences, initialValues, + options, }: { /** Connect to a self hosted instance of FeatureBoard */ api?: FeatureBoardApiConfig @@ -44,7 +46,11 @@ export function createBrowserClient({ initialValues?: EffectiveFeatureValue[] environmentApiKey: string + + options?: ClientOptions }): BrowserClient { + // enable or disable OpenTelemetry, enabled by default + setTracingEnabled(!options || !options.disableOTel) const tracer = getTracer() const waitingForInitialisation: Array> = [] @@ -82,23 +88,11 @@ export function createBrowserClient({ return } - return await tracer.startActiveSpan( - 'connect', - { - attributes: { - audiences, - updateStrategy: updateStrategyImplementation.name, - }, - }, - (connectSpan) => - updateStrategyImplementation - .connect(stateStore) - .finally(() => connectSpan.end()), - ) + return await updateStrategyImplementation.connect(stateStore) }, cancellationToken) } catch (error) { if (initialPromise !== initialisedState.initialisedPromise) { - addDebugEvent( + initializeSpan.addEvent( "Ignoring initialisation error as it's out of date", ) initializeSpan.end() @@ -123,7 +117,9 @@ export function createBrowserClient({ // Successfully completed if (initialPromise !== initialisedState.initialisedPromise) { - addDebugEvent("Ignoring initialisation event as it's out of date") + initializeSpan.addEvent( + "Ignoring initialisation event as it's out of date", + ) initializeSpan.end() return } @@ -137,9 +133,12 @@ export function createBrowserClient({ } void tracer.startActiveSpan( - 'connect-with-retry', + 'fbsdk-connect-with-retry', { - attributes: { audiences }, + attributes: { + audiences, + updateStrategy: updateStrategyImplementation.name, + }, }, (connectWithRetrySpan) => initializeWithAudiences(connectWithRetrySpan, audiences), @@ -169,22 +168,8 @@ export function createBrowserClient({ } }, async updateAudiences(updatedAudiences: string[]) { - if (compareArrays(stateStore.audiences, updatedAudiences)) { - addDebugEvent('Skipped update audiences', { - updatedAudiences, - currentAudiences: stateStore.audiences, - }) - - // No need to update audiences - return Promise.resolve() - } - - // Close connection and cancel retry - updateStrategyImplementation.close() - initialisedState.initialisedCancellationToken.cancel = true - await tracer.startActiveSpan( - 'update-audiences', + 'fbsdk-update-audiences', { attributes: { audiences, @@ -192,19 +177,53 @@ export function createBrowserClient({ }, }, (updateAudiencesSpan) => { - stateStore.audiences = updatedAudiences - return initializeWithAudiences( - updateAudiencesSpan, - updatedAudiences, - ) + try { + if ( + compareArrays( + stateStore.audiences, + updatedAudiences, + ) + ) { + updateAudiencesSpan.addEvent( + 'Skipped update audiences', + { + updatedAudiences, + currentAudiences: stateStore.audiences, + }, + ) + + // No need to update audiences + return Promise.resolve() + } + + // Close connection and cancel retry + updateStrategyImplementation.close() + initialisedState.initialisedCancellationToken.cancel = + true + + stateStore.audiences = updatedAudiences + return initializeWithAudiences( + updateAudiencesSpan, + updatedAudiences, + ) + } finally { + updateAudiencesSpan.end() + } }, ) }, updateFeatures() { - return tracer.startActiveSpan('manual-update', (span) => - updateStrategyImplementation - .updateFeatures() - .then(() => span.end()), + return tracer.startActiveSpan( + 'fbsdk-update-features-manually', + (span) => { + try { + return updateStrategyImplementation + .updateFeatures() + .then(() => span.end()) + } finally { + span.end() + } + }, ) }, close() { diff --git a/libs/js-sdk/src/create-client.ts b/libs/js-sdk/src/create-client.ts index c90c1d0e..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 { addDebugEvent } from './utils/add-debug-event' +import { getTracer } from './utils/get-tracer' /** Designed for internal SDK use */ export function createClientInternal( @@ -9,59 +9,119 @@ export function createClientInternal( ): FeatureBoardClient { return { getEffectiveValues() { - const all = stateStore.all() - addDebugEvent('getEffectiveValues', { store: JSON.stringify(all) }) + 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]!, - })), - } + 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) - addDebugEvent('getFeatureValue', { - 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, ) { - addDebugEvent('subscribeToFeatureValue', { - featureKey, - 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() + } + }, + ) + } + } - const callback = (updatedFeatureKey: string, value: any): void => { - if (featureKey === updatedFeatureKey) { - addDebugEvent('subscribeToFeatureValue:update', { - featureKey, - value, - defaultValue, - }) - onValue(value ?? defaultValue) - } - } + stateStore.on('feature-updated', callback) + onValue(stateStore.get(featureKey) ?? defaultValue) - stateStore.on('feature-updated', callback) - onValue(stateStore.get(featureKey) ?? defaultValue) - - return () => { - addDebugEvent('unsubscribeToFeatureValue', { - 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 5b4a65fb..dcc00376 100644 --- a/libs/js-sdk/src/effective-feature-state-store.ts +++ b/libs/js-sdk/src/effective-feature-state-store.ts @@ -55,7 +55,7 @@ export class EffectiveFeatureStateStore { } set(featureKey: string, value: FeatureValue) { - addDebugEvent('Set', { featureKey, value }) + addDebugEvent('Feature store: set feature', { featureKey, value }) this._store[featureKey] = value @@ -66,7 +66,7 @@ export class EffectiveFeatureStateStore { get(featureKey: string): FeatureValue { const value = this._store[featureKey] - addDebugEvent('Get', { 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 21fb5c90..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,6 +13,7 @@ export function createEnsureSingleWithBackoff( tooManyRequestsError && tooManyRequestsError.retryAfter > new Date() ) { + trace.getActiveSpan()?.recordException(tooManyRequestsError) return Promise.reject(tooManyRequestsError) } tooManyRequestsError = undefined diff --git a/libs/js-sdk/src/featureboard-fixture.ts b/libs/js-sdk/src/featureboard-fixture.ts index 6921113c..7ba297f0 100644 --- a/libs/js-sdk/src/featureboard-fixture.ts +++ b/libs/js-sdk/src/featureboard-fixture.ts @@ -19,7 +19,7 @@ export function featureBoardFixture( server.listen({ onUnhandledRequest: 'error' }) await tracer.startActiveSpan( - task.name, + `${task.suite.name ? task.suite.name + ': ' : ''}${task.name}`, { root: true, attributes: {}, diff --git a/libs/js-sdk/src/tests/http-client.spec.ts b/libs/js-sdk/src/tests/http-client.spec.ts index 76b00856..80b4a55a 100644 --- a/libs/js-sdk/src/tests/http-client.spec.ts +++ b/libs/js-sdk/src/tests/http-client.spec.ts @@ -3,745 +3,716 @@ import { type EffectiveFeatureValue, } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { createBrowserClient } from '../create-browser-client' import { featureBoardFixture } from '../featureboard-fixture' import { featureBoardHostedService } from '../featureboard-service-urls' -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) - }, - ), -) - -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([ +describe('http client', () => { + 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', }, - ]), - { 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' }, - }) - - await httpClient.waitForInitialised() - await httpClient.updateFeatures() - - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - }, - ), -) - -// 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( - { matched: false, lastModified: new Date().toISOString() }, - (context) => [ - http.get( - 'https://client.featureboard.app/effective', - () => { + ] + + 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) + }, + ), + ) + + 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' }, + }) - 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 }) - } + await httpClient.waitForInitialised() - 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() - - expect(testContext.matched).toEqual(true) - }, - ), -) - -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', - }, - ], + 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([ { - headers: { etag: newLastModified }, + featureKey: 'my-feature', + value: 'service-default-value', }, - ) - } - - return HttpResponse.json( - [ + ]), + { once: true }, + ), + http.get( + 'https://client.featureboard.app/effective', + () => + HttpResponse.json([ { featureKey: 'my-feature', - value: 'service-default-value', + value: 'new-service-default-value', }, + ]), + { once: true }, + ), + ], + 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') + }, + ), + ) + + // 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( + { matched: false, lastModified: new Date().toISOString() }, + (context) => [ + http.get( + 'https://client.featureboard.app/effective', + () => { + const values: EffectiveFeatureValue[] = [ { - featureKey: 'my-feature-2', + featureKey: 'my-feature', 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', - featureBoardFixture( - {}, - - () => [ - http.get('https://client.featureboard.app/effective', () => - HttpResponse.json({}, { status: 500 }), - ), - ], - async ({}) => { - const client = createBrowserClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - audiences: ['audience1'], - initialValues: [ - { - featureKey: 'my-feature', - value: 'service-default-value', + ] + + return HttpResponse.json(values, { + headers: { etag: context.lastModified }, + }) }, - ], - updateStrategy: { kind: 'manual' }, - }) - - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(client.initialised).toEqual(false) - expect(value).toEqual('service-default-value') - await expect(async () => { - await client.waitForInitialised() - }).rejects.toThrowError('500') - expect(client.initialised).toEqual(true) - }, - ), -) - -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() + { 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() + + expect(testContext.matched).toEqual(true) + }, + ), + ) + + 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: 'new-service-default-value', + value: 'service-default-value', + }, + { + featureKey: 'my-feature-2', + value: 'service-default-value', }, ], { - headers: { etag: newLastModified }, + headers: { + etag: testContext.lastModified, + }, }, ) - } + }, + ), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - return HttpResponse.json( - [ - { - featureKey: 'my-feature', - value: '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 value2 = httpClient.client.getFeatureValue( + 'my-feature-2', + 'default-value', + ) + expect(value2).toEqual('default-value') + }, + ), + ) + + it( + 'can start with last known good config', + featureBoardFixture( + {}, + + () => [ + http.get('https://client.featureboard.app/effective', () => + HttpResponse.json({}, { status: 500 }), + ), + ], + async ({}) => { + const client = createBrowserClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + audiences: ['audience1'], + initialValues: [ { - headers: { etag: lastModified }, + featureKey: 'my-feature', + value: 'service-default-value', }, - ) - }, - ), - ], - async () => { - expect.assertions(4) + ], + updateStrategy: { kind: 'manual' }, + }) + + const value = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(client.initialised).toEqual(false) + expect(value).toEqual('service-default-value') + await expect(async () => { + await client.waitForInitialised() + }).rejects.toThrowError('500') + expect(client.initialised).toEqual(true) + }, + ), + ) + + 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 }, + }, + ) + } - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-default-value', + }, + ], + { + headers: { etag: lastModified }, + }, + ) + }, + ), + ], + async () => { + expect.assertions(4) - await httpClient.waitForInitialised() + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - expect(httpClient.initialised).toEqual(true) + await httpClient.waitForInitialised() - httpClient.subscribeToInitialisedChanged((init) => { expect(httpClient.initialised).toEqual(true) + + 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('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() - }, - ), -) - -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() + expect(value).toEqual('service-default-value') + + await httpClient.updateAudiences(['test-audience']) + await httpClient.waitForInitialised() + }, + ), + ) + + 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: 'new-service-default-value', + value: 'service-default-value', }, ], { - headers: { etag: newLastModified }, + headers: { etag: testContext.lastModified }, }, ) - } - 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) - - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) + }, + ), + ], + async ({ testContext }) => { + expect.assertions(3) - await httpClient.waitForInitialised() + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) - expect(httpClient.initialised).toEqual(true) + await httpClient.waitForInitialised() - const unsubscribe = httpClient.subscribeToInitialisedChanged( - (init: boolean) => { - if (!init) { - expect(httpClient.initialised).toEqual(false) - } else { - expect(httpClient.initialised).toEqual(true) - } - }, - ) + expect(httpClient.initialised).toEqual(true) - await httpClient.updateAudiences(['test-audience']) + const unsubscribe = httpClient.subscribeToInitialisedChanged( + (init: boolean) => { + if (!init) { + expect(httpClient.initialised).toEqual(false) + } else { + expect(httpClient.initialised).toEqual(true) + } + }, + ) - await httpClient.waitForInitialised() + await httpClient.updateAudiences(['test-audience']) - unsubscribe() + await httpClient.waitForInitialised() - await httpClient.updateAudiences(['test-audience-unsubscribe']) + unsubscribe() - await httpClient.waitForInitialised() + await httpClient.updateAudiences(['test-audience-unsubscribe']) - expect(testContext.count).equal(2) - }, - ), -) + await httpClient.waitForInitialised() -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() + 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: 'new-service-default-value', + value: 'service-default-value', }, ], { - headers: { etag: newLastModified }, + 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') } - 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 { + }) + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('default-value') + + httpClient.updateAudiences(['test-audience']) + await httpClient.waitForInitialised() + }, + ), + ) + + 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) + + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + expect(httpClient.initialised).toEqual(false) + + 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('default-value') - - httpClient.updateAudiences(['test-audience']) - await httpClient.waitForInitialised() - }, - ), -) - -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) + expect(value).toEqual('default-value') + }) - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - expect(httpClient.initialised).toEqual(false) - - 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') + 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') - }, - ), -) - -it( - 'Initialisation fails and retries, no external state store', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/effective', - () => - HttpResponse.json( + expect(httpClient.initialised).toEqual(true) + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-value') + }, + ), + ) + + it( + 'initialisation retries 5 times then throws an error', + featureBoardFixture( + { count: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/effective', () => { + testContext.count++ + return 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' }, - }) - - await httpClient.waitForInitialised() - expect(httpClient.initialised).toEqual(true) - const value = httpClient.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-value') - }, - ), -) - -it( - 'Initialisation retries 5 times then throws an error', - featureBoardFixture( - { count: 0 }, - (testContext) => [ - http.get('https://client.featureboard.app/effective', () => { - testContext.count++ - return HttpResponse.json( - { message: 'Test Server Request Error' }, - { status: 500 }, + ) + }), + ], + async ({ testContext }) => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await expect(async () => { + await httpClient.waitForInitialised() + }).rejects.toThrowError('500') + expect(testContext.count).toEqual(2 + 1) // initial request and 2 retry + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', ) - }), - ], - async ({ testContext }) => { - const httpClient = createBrowserClient({ - environmentApiKey: 'env-api-key', - audiences: [], - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await expect(async () => { - await httpClient.waitForInitialised() - }).rejects.toThrowError('500') - 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') - }, - ), -) - -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() - }, - ), -) - -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' }, - }) - } - return HttpResponse.json( - [ + expect(value).toEqual('default-value') + }, + ), + ) + + it( + 'feature value subscription called during initialisation', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => + HttpResponse.json([ { featureKey: 'my-feature', value: 'service-value', }, - ], - { - headers: { - etag: new Date().toISOString(), - }, + ]), + ), + ], + 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') + } }, ) - }), - ], - 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) - }, - ), -) - -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', - () => { + + await httpClient.waitForInitialised() + }, + ), + ) + + 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' }, + }) + } return HttpResponse.json( [ { @@ -755,70 +726,60 @@ it( }, }, ) - }, - { 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' }, + }) + 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/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' }, - }) - - 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 retry-after header using UTC date time', - featureBoardFixture( - { count: 0 }, - (testContext) => [ - http.get( - 'https://client.featureboard.app/effective', - () => { + { 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( [ @@ -833,73 +794,150 @@ it( }, }, ) - }, - { once: true }, - ), - http.get( - 'https://client.featureboard.app/effective', - () => { + }), + ], + 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 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++ - 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( - [ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + value: 'service-value', + }, + ], { - featureKey: 'my-feature', - value: 'service-value', - }, - ], - { - headers: { - etag: new Date().toISOString(), + 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(), + }, + }, + ) }, - ) - }), - ], - 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', - () => { + { 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( [ @@ -914,53 +952,62 @@ it( }, }, ) - }, - { 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( - [ + }), + ], + 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) + httpClient.close() + expect(testContext.count).toBe(2) + }, + ), + ) + + it( + 'otel disabled should not impact functionality', + featureBoardFixture( + {}, + () => [ + http.get('https://client.featureboard.app/effective', () => { + const values: EffectiveFeatureValue[] = [ { featureKey: 'my-feature', - value: 'service-value', - }, - ], - { - headers: { - etag: new Date().toISOString(), + value: 'service-default-value', }, - }, + ] + return HttpResponse.json(values) + }), + ], + async () => { + const httpClient = createBrowserClient({ + environmentApiKey: 'env-api-key', + audiences: [], + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + options: { disableOTel: true }, + }) + + await httpClient.waitForInitialised() + + const value = httpClient.client.getFeatureValue( + 'my-feature', + 'default-value', ) - }), - ], - 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) - httpClient.close() - expect(testContext.count).toBe(2) - }, - ), -) + 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 9df4f5c5..c8ad1675 100644 --- a/libs/js-sdk/src/tests/mode-manual.spec.ts +++ b/libs/js-sdk/src/tests/mode-manual.spec.ts @@ -1,114 +1,115 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { createBrowserClient } from '../create-browser-client' import { featureBoardFixture } from '../featureboard-fixture' -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() +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') + }, + ), + ) - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-default-value') - }, - ), -) + 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', + }, + ]) + } -it( - 'can manually update values', - featureBoardFixture( - { count: 0 }, - (testContext) => [ - http.get('https://client.featureboard.app/effective', () => { - if (testContext.count > 0) { + testContext.count++ return HttpResponse.json([ { featureKey: 'my-feature', - value: 'new-service-default-value', + value: 'service-default-value', }, ]) - } + }), + ], + async () => { + const client = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'manual', + }) + await client.waitForInitialised() + await client.updateFeatures() - 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() - - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - }, - ), -) - -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', - }) + const value = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('new-service-default-value') + }, + ), + ) - client.close() - }, - ), -) + 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() + }, + ), + ) +}) 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 69bd8b60..76567b39 100644 --- a/libs/js-sdk/src/tests/mode-polling.spec.ts +++ b/libs/js-sdk/src/tests/mode-polling.spec.ts @@ -1,6 +1,6 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { beforeEach, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createBrowserClient } from '../create-browser-client' import { featureBoardFixture } from '../featureboard-fixture' import { interval } from '../interval' @@ -10,178 +10,180 @@ beforeEach(() => { interval.clear = clearInterval }) -it( - 'fetches initial values', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/effective', - () => +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', + }) + + await connection.waitForInitialised() + + const value = connection.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-default-value') + }, + ), + ) + + it( + 'sets up interval correctly', + 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', - }) + ), + ], + async () => { + const handle = {} + interval.set = vi.fn(() => { + return handle + }) as any + interval.clear = vi.fn(() => {}) - await connection.waitForInitialised() - - const value = connection.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('service-default-value') - }, - ), -) - -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 connection = createBrowserClient({ - environmentApiKey: 'fake-key', - audiences: [], - updateStrategy: 'polling', - }) - connection.close() + expect(interval.set).toBeCalled() + expect(interval.clear).toBeCalledWith(handle) + }, + ), + ) - expect(interval.set).toBeCalled() - expect(interval.clear).toBeCalledWith(handle) - }, - ), -) + 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', + }, + ]) + } -it( - 'fetches updates when interval fires', - featureBoardFixture( - { count: 0 }, - (testContext) => [ - http.get('https://client.featureboard.app/effective', () => { - if (testContext.count > 0) { + testContext.count++ return HttpResponse.json([ { featureKey: 'my-feature', - value: 'new-service-default-value', + value: 'service-default-value', }, ]) - } + }), + ], + async () => { + const setMock = vi.fn(() => {}) + interval.set = setMock as any - 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 client = createBrowserClient({ + environmentApiKey: 'fake-key', + audiences: [], + updateStrategy: 'polling', + }) + await client.waitForInitialised() - const pollCallback = (setMock.mock.calls[0] as any)[0] - await pollCallback() + const pollCallback = (setMock.mock.calls[0] as any)[0] + await pollCallback() - const value = client.client.getFeatureValue( - 'my-feature', - 'default-value', - ) - expect(value).toEqual('new-service-default-value') - }, - ), -) -it( - 'Polling swallows errors received when updating features', - featureBoardFixture( - { countAPICalls: 0 }, - (testContext) => [ - http.get( - 'https://client.featureboard.app/effective', - () => { + const value = client.client.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('new-service-default-value') + }, + ), + ) + 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 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' }, + 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 }, + }, }) - }), - ], - async ({ testContext }) => { - 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') - expect(testContext.countAPICalls).toBe(2) - }, - ), -) + 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' { interface Features extends Record {} diff --git a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts index 976486ce..c093ee66 100644 --- a/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/js-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -39,7 +39,7 @@ export function createPollingUpdateStrategy( } stopPolling = pollingUpdates(() => { return startActiveSpan({ - name: 'polling-updates', + name: 'fbsdk-polling-updates', options: { attributes: { etag }, root: !parentSpan }, parentSpan, fn: async (span) => { @@ -51,6 +51,7 @@ export function createPollingUpdateStrategy( }) .finally(() => span.end()) } + span.end() }, }) }, intervalMs) diff --git a/libs/js-sdk/src/utils/add-debug-event.ts b/libs/js-sdk/src/utils/add-debug-event.ts index 797bc44a..88fbdacd 100644 --- a/libs/js-sdk/src/utils/add-debug-event.ts +++ b/libs/js-sdk/src/utils/add-debug-event.ts @@ -2,11 +2,5 @@ import type { Attributes } from '@opentelemetry/api' import { trace } from '@opentelemetry/api' export function addDebugEvent(event: string, attributes: Attributes = {}) { - if ( - typeof window !== 'undefined' - ? (window as any)['FEATUREBOARD_SDK_DEBUG'] - : process.env.FEATUREBOARD_SDK_DEBUG - ) { - trace.getActiveSpan()?.addEvent(event, 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 ea66d5f7..2d035494 100644 --- a/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/js-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -1,8 +1,10 @@ -import { TooManyRequestsError, type EffectiveFeatureValue } from '@featureboard/contracts' +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 { addDebugEvent } from './add-debug-event' import { compareArrays } from './compare-arrays' import { getTracer } from './get-tracer' import { resolveError } from './resolve-error' @@ -20,7 +22,7 @@ export async function fetchFeaturesConfigurationViaHttp( audiences, ) return getTracer().startActiveSpan( - 'fetch-effective-features-http', + 'fbsdk-fetch-effective-features-http', { attributes: { audiences, etag } }, async (span) => { try { @@ -42,19 +44,25 @@ export async function fetchFeaturesConfigurationViaHttp( retryAfterHeader && !retryAfterInt ? new Date(retryAfterHeader) : new Date() - + if (retryAfterInt) { - const retryAfterTime = retryAfter.getTime() + retryAfterInt * 1000 + 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) { @@ -65,6 +73,7 @@ export async function fetchFeaturesConfigurationViaHttp( // 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 } @@ -73,7 +82,7 @@ export async function fetchFeaturesConfigurationViaHttp( const newAudiences = getCurrentAudiences() if (!compareArrays(newAudiences, audiences)) { - addDebugEvent( + span.addEvent( 'Audiences changed while fetching, ignoring response', { audiences, @@ -92,12 +101,14 @@ export async function fetchFeaturesConfigurationViaHttp( unavailableFeatures.forEach((unavailableFeature) => { stateStore.set(unavailableFeature, undefined) }) - addDebugEvent('Feature updates received', { + 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) diff --git a/libs/js-sdk/src/utils/get-tracer.ts b/libs/js-sdk/src/utils/get-tracer.ts index 16c3d12b..2edbbae4 100644 --- a/libs/js-sdk/src/utils/get-tracer.ts +++ b/libs/js-sdk/src/utils/get-tracer.ts @@ -1,6 +1,6 @@ -import { trace } from '@opentelemetry/api' import { version } from '../version' +import { traceProvider } from './trace-provider' export function getTracer() { - return trace.getTracer('featureboard-js-sdk', version) + return traceProvider.getTracer('featureboard-js-sdk', version) } diff --git a/libs/js-sdk/src/utils/retry.ts b/libs/js-sdk/src/utils/retry.ts index 37745dab..5617d6cd 100644 --- a/libs/js-sdk/src/utils/retry.ts +++ b/libs/js-sdk/src/utils/retry.ts @@ -14,7 +14,7 @@ export async function retry( cancellationToken = { cancel: false }, ): Promise { const tracer = getTracer() - return tracer.startActiveSpan('retry', async (span) => { + return tracer.startActiveSpan(`fbsdk-retry`, async (span) => { let retryAttempt = 0 try { @@ -74,8 +74,11 @@ export async function retry( throw error } - await tracer.startActiveSpan('delay', (delaySpan) => - delay(delayMs).finally(() => delaySpan.end()), + await tracer.startActiveSpan( + 'fbsdk-trigger-retry-delay', + { attributes: { delayMs } }, + (delaySpan) => + delay(delayMs).finally(() => delaySpan.end()), ) retryAttempt++ @@ -93,7 +96,7 @@ async function retryAttemptFn( fn: () => Promise, ) { return await tracer.startActiveSpan( - 'retry-attempt', + `fbsdk-retry-attempt-${retryAttempt}`, { attributes: { retryAttempt } }, async (attemptSpan) => { try { 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/node-sdk/src/feature-state-store.ts b/libs/node-sdk/src/feature-state-store.ts index 5466dc5d..552171b3 100644 --- a/libs/node-sdk/src/feature-state-store.ts +++ b/libs/node-sdk/src/feature-state-store.ts @@ -29,11 +29,10 @@ export class AllFeatureStateStore implements IFeatureStateStore { const tracer = getTracer() await tracer.startActiveSpan( - 'initialise-from-external-store', + '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) => @@ -53,6 +52,8 @@ export class AllFeatureStateStore implements IFeatureStateStore { error, ) throw error + } finally { + externalStoreSpan.end() } }, ) diff --git a/libs/node-sdk/src/server-client.ts b/libs/node-sdk/src/server-client.ts index 4d89ab1b..2db06e3c 100644 --- a/libs/node-sdk/src/server-client.ts +++ b/libs/node-sdk/src/server-client.ts @@ -1,5 +1,6 @@ import type { EffectiveFeatureValue } from '@featureboard/contracts' import type { + ClientOptions, FeatureBoardApiConfig, FeatureBoardClient, Features, @@ -16,9 +17,9 @@ import type { IFeatureStateStore } from './feature-state-store' import { AllFeatureStateStore } from './feature-state-store' import { resolveUpdateStrategy } from './update-strategies/resolveUpdateStrategy' import type { UpdateStrategies } from './update-strategies/update-strategies' -import { addDebugEvent } from './utils/add-debug-event' 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 */ @@ -42,6 +43,8 @@ export interface CreateServerClientOptions { updateStrategy?: UpdateStrategies | UpdateStrategies['kind'] environmentApiKey: string + + options?: ClientOptions } export function createServerClient({ @@ -49,7 +52,10 @@ 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() @@ -71,7 +77,7 @@ export function createServerClient({ const retryCancellationToken = { cancel: false } void tracer.startActiveSpan( - 'connect-with-retry', + 'fbsdk-connect-with-retry', { attributes: {}, }, @@ -125,7 +131,7 @@ export function createServerClient({ }, request: (audienceKeys: string[]) => { return tracer.startActiveSpan( - 'get-request-client', + 'fbsdk-get-request-client', { attributes: { audiences: audienceKeys } }, (span) => { const request = updateStrategyImplementation.onRequest() @@ -150,7 +156,18 @@ export function createServerClient({ ) }, 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 @@ -192,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: ( @@ -220,25 +247,40 @@ function syncRequest( featureKey: T, defaultValue: Features[T], ) { - const featureValues = featuresState[featureKey as string] - if (!featureValues) { - addDebugEvent( - 'getFeatureValue - no value, returning user fallback: %o', - { audienceKeys }, - ) + 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), + 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 - addDebugEvent('getFeatureValue', { - featureKey, - value, - defaultValue, - }) - 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 5bab2fb7..77934982 100644 --- a/libs/node-sdk/src/tests/http-client.spec.ts +++ b/libs/node-sdk/src/tests/http-client.spec.ts @@ -4,595 +4,562 @@ import { } from '@featureboard/contracts' import { featureBoardHostedService } from '@featureboard/js-sdk' import { HttpResponse, http } from 'msw' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { createServerClient } from '../server-client' import { featureBoardFixture } from '../utils/featureboard-fixture' import { MockExternalStateStore } from './mock-external-state-store' -it( - 'calls featureboard /all endpoint on creation', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json([ +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') + }, + ), + ) + + 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') + }, + ), + ) + + 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' }, + }) + 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') + }, + ), + ) + + 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 }, - ), - ], - 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') - }, - ), -) - -it( - 'can wait for initialisation', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json([ { - featureKey: 'my-feature', + featureKey: 'my-feature-2', 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') - }, - ), -) - -it( - 'Gets value using audience exceptions', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json([ - { - featureKey: 'my-feature', - audienceExceptions: [ + ]) + }), + ], + async () => { + 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') + }, + ), + ) + + // 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( + [ { - audienceKey: 'my-audience', - value: 'audience-exception-value', + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', }, ], - 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(['my-audience']) - .getFeatureValue('my-feature', 'default-value') - expect(httpClient.initialised).toBe(true) - expect(value).toBe('audience-exception-value') - }, - ), -) - -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', - }, - { - featureKey: 'my-feature-2', - audienceExceptions: [], - defaultValue: 'service-default-value', + { + headers: { + etag: testContext.lastModified, + }, + }, + ) }, - ]) - }), - ], - async () => { - 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') - }, - ), -) - -// 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( - [ + ), + ], + async () => { + const httpClient = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await httpClient.updateFeatures() + }, + ), + ) + + 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' }, + }) + + await client.waitForInitialised() + expect(client.initialised).toEqual(true) + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('service-value') + }, + ), + ) + + it( + 'Initialisation retries 5 time then throws an error, no external state store', + 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: testContext.lastModified, + message: 'Test FeatureBoard API Error', }, - }, - ) - }), - ], - async () => { - const httpClient = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await httpClient.updateFeatures() - }, - ), -) - -it( - 'Initialisation fails, reties and succeeds, no external state store', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json( + { status: 500 }, + ) + }), + ], + async ({ testContext }) => { + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + }) + + await expect(async () => { + await client.waitForInitialised() + }).rejects.toThrowError('500') + expect(testContext.count).toEqual(2 + 1) // initial request and 5 retry + }, + ), + ) + + 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 }, - ), - { once: true }, - ), - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json([ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-value', + ) + }), + ], + 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() }, - ]), - { once: true }, - ), - ], - async () => { - 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') - }, - ), -) - -it( - 'Initialisation retries 5 time then throws an error, no external state store', - featureBoardFixture( - { count: 0 }, - (testContext) => [ - http.get('https://client.featureboard.app/all', () => { - testContext.count++ - return HttpResponse.json( - { - message: 'Test FeatureBoard API Error', - }, - { status: 500 }, - ) - }), - ], - async ({ testContext }) => { - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - }) - - await expect(async () => { + ), + }) + await client.waitForInitialised() - }).rejects.toThrowError('500') - expect(testContext.count).toEqual(2 + 1) // initial request and 5 retry - }, - ), -) - -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( + 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', + featureBoardFixture( + { countAPIRequest: 0 }, + (testContext) => [ + http.get('https://client.featureboard.app/all', () => { + testContext.countAPIRequest++ + return HttpResponse.json( + { message: 'Test FeatureBoard API Error' }, + { status: 500 }, + ) + }), + ], + async ({ testContext }) => { + let countExternalStateStoreRequest = 0 + const client = createServerClient({ + environmentApiKey: 'env-api-key', + api: featureBoardHostedService, + updateStrategy: { kind: 'manual' }, + externalStateStore: new MockExternalStateStore( + () => { + countExternalStateStoreRequest++ + return Promise.reject({ + message: 'Test External State Store Error', + }) + }, + () => { + return Promise.resolve() + }, + ), + }) + + await expect(async () => { + await client.waitForInitialised() + }).rejects.toThrowError('Test External State Store Error') + 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', + 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', }, - }), - () => { - 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', - featureBoardFixture( - { countAPIRequest: 0 }, - (testContext) => [ - http.get('https://client.featureboard.app/all', () => { - testContext.countAPIRequest++ - return HttpResponse.json( - { message: 'Test FeatureBoard API Error' }, - { status: 500 }, - ) - }), - ], - async ({ testContext }) => { - let countExternalStateStoreRequest = 0 - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - externalStateStore: new MockExternalStateStore( - () => { - countExternalStateStoreRequest++ - return Promise.reject({ - message: 'Test External State Store Error', - }) - }, - () => { - return Promise.resolve() - }, + ]), + { once: true }, ), - }) - - await expect(async () => { - await client.waitForInitialised() - }).rejects.toThrowError('Test External State Store Error') - 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', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json([ - { - 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.resolve() }, - ]), - { once: true }, - ), - ], - async () => { - expect.assertions(1) - - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - externalStateStore: new MockExternalStateStore( + ), + }) + await client.waitForInitialised() + }, + ), + ) + + 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() - }, - ), -) - -it( - 'Catch error when update external state store throws error', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/all', - () => - HttpResponse.json([ - { - 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', + ) }, - ]), - { once: true }, - ), - ], - async () => { - expect.assertions(1) - - const client = createServerClient({ - environmentApiKey: 'env-api-key', - api: featureBoardHostedService, - updateStrategy: { kind: 'manual' }, - externalStateStore: new MockExternalStateStore( + ), + }) + await client.waitForInitialised() + }, + ), + ) + + 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', }, - }), - (store) => { - expect(store['my-feature']?.defaultValue).toEqual( - 'service-value', - ) - return Promise.reject() - }, + ]), + { once: true }, ), - }) - await client.waitForInitialised() - }, - ), -) - -it( - 'Subscription to feature value immediately return current value but will not be called again', - featureBoardFixture( - {}, - () => [ - http.get( - 'https://client.featureboard.app/all', - () => + http.get('https://client.featureboard.app/all', () => HttpResponse.json([ { featureKey: 'my-feature', audienceExceptions: [], - defaultValue: 'service-value', + defaultValue: 'service-value2', }, ]), - { 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 () => { + 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') }, - }, - ) - }), - ], - 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', - () => { + ) + + 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( [ { @@ -607,70 +574,60 @@ it( }, }, ) - }, - { once: true }, - ), - 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' }, + }) + 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(), + }, + }, + ) }, - ) - }), - ], - 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() - - httpClient.close() - - expect(testContext.count).toBe(3) - }, - ), -) - -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', - () => { + { once: true }, + ), + 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( [ @@ -686,73 +643,73 @@ it( }, }, ) - }, - { 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() + + httpClient.close() + + expect(testContext.count).toBe(3) + }, + ), + ) + + 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 }, + }) }, - ) - }), - ], - 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() - - 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/all', - () => { + { once: true }, + ), + http.get('https://client.featureboard.app/all', () => { testContext.count++ return HttpResponse.json( [ @@ -768,53 +725,103 @@ it( }, }, ) - }, - { once: true }, - ), - http.get( - 'https://client.featureboard.app/all', - () => { + }), + ], + 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() + + 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/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 new Response(null, { - status: 429, - }) - }, - { once: true }, - ), - http.get('https://client.featureboard.app/all', () => { - testContext.count++ - return HttpResponse.json( - [ + return HttpResponse.json( + [ + { + featureKey: 'my-feature', + audienceExceptions: [], + defaultValue: 'service-default-value', + }, + ], { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ], - { - headers: { - etag: new Date().toISOString(), + 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) - }, - ), -) + ) + }), + ], + 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 81ea0dac..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,222 +1,224 @@ import type { FeatureConfiguration } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { createServerClient } from '../server-client' import { featureBoardFixture } from '../utils/featureboard-fixture' -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() +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 requestClient = await client.request([]) + const value = requestClient.getFeatureValue( + 'my-feature', + 'default-value', + ) + expect(value).toEqual('service-default-value') + }, + ), + ) -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', - }) + 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', + }) + + await client.waitForInitialised() - await client.waitForInitialised() + expect(() => + client + .request([]) + .getFeatureValue('my-feature', 'default-value'), + ).toThrow( + 'request() must be awaited when using on-request update strategy', + ) + }, + ), + ) - expect(() => - client - .request([]) - .getFeatureValue('my-feature', 'default-value'), - ).toThrow( - 'request() must be awaited when using on-request update strategy', - ) - }, - ), -) + // 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', + 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', + }, + ]) + } -// 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', - featureBoardFixture( - { count: 0 }, - (testContext) => [ - http.get('https://client.featureboard.app/all', () => { - if (testContext.count > 0) { + testContext.count++ return HttpResponse.json([ { featureKey: 'my-feature', audienceExceptions: [], - defaultValue: 'new-service-default-value', + defaultValue: 'service-default-value', }, ]) - } + }), + ], + async () => { + const connection = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'on-request', + }) - testContext.count++ - return HttpResponse.json([ - { - featureKey: 'my-feature', - audienceExceptions: [], - defaultValue: 'service-default-value', - }, - ]) - }), - ], - async () => { - const connection = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: 'on-request', - }) + const client = await connection.request([]) - const client = await connection.request([]) + expect( + client.getFeatureValue('my-feature', 'default-value'), + ).toEqual('service-default-value') + }, + ), + ) - expect( - client.getFeatureValue('my-feature', 'default-value'), - ).toEqual('service-default-value') - }, - ), -) + 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', + }, + ]) + } -it( - 'fetches update when response is expired', - featureBoardFixture( - { count: 0 }, - (testContext) => [ - http.get('https://client.featureboard.app/all', () => { - if (testContext.count > 0) { + testContext.count++ return HttpResponse.json([ { featureKey: 'my-feature', audienceExceptions: [], - defaultValue: 'new-service-default-value', + defaultValue: '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 }, }, - ]) - }), - ], - async () => { - const connection = createServerClient({ - environmentApiKey: 'fake-key', - updateStrategy: { - kind: 'on-request', - options: { maxAgeMs: 1 }, - }, - }) - await connection.waitForInitialised() + }) + await connection.waitForInitialised() - // Ensure response has expired - await new Promise((resolve) => setTimeout(resolve, 10)) + // Ensure response has expired + await new Promise((resolve) => setTimeout(resolve, 10)) - const client = await connection.request([]) - expect( - client.getFeatureValue('my-feature', 'default-value'), - ).toEqual('new-service-default-value') - }, - ), -) + const client = await connection.request([]) + expect( + client.getFeatureValue('my-feature', 'default-value'), + ).toEqual('new-service-default-value') + }, + ), + ) -it( - 'On Request swallows errors received when updating features', - featureBoardFixture( - { countAPICalls: 0 }, - (testContext) => [ - http.get( - 'https://client.featureboard.app/all', - () => { + 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 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' }, + 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 }, + }, }) - }), - ], - async ({ testContext }) => { - 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') - expect(testContext.countAPICalls).toBe(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' { interface Features extends Record {} diff --git a/libs/node-sdk/src/tests/mode-polling.spec.ts b/libs/node-sdk/src/tests/mode-polling.spec.ts index a3359de9..f383d374 100644 --- a/libs/node-sdk/src/tests/mode-polling.spec.ts +++ b/libs/node-sdk/src/tests/mode-polling.spec.ts @@ -1,6 +1,6 @@ import type { FeatureConfiguration } from '@featureboard/contracts' import { HttpResponse, http } from 'msw' -import { beforeEach, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { interval } from '../interval' import { createServerClient } from '../server-client' import { featureBoardFixture } from '../utils/featureboard-fixture' @@ -10,185 +10,186 @@ beforeEach(() => { interval.clear = clearInterval }) -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() +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 value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('service-default-value') - }, - ), -) + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('service-default-value') + }, + ), + ) -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(() => {}) + 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 client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'polling', + }) + client.close() + + expect(interval.set).toBeCalled() + expect(interval.clear).toBeCalledWith(handle) + }, + ), + ) - expect(interval.set).toBeCalled() - expect(interval.clear).toBeCalledWith(handle) - }, - ), -) + 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', + }, + ]) + } -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) { + testContext.count++ return HttpResponse.json([ { featureKey: 'my-feature', audienceExceptions: [], - defaultValue: 'new-service-default-value', + defaultValue: 'service-default-value', }, ]) - } + }), + ], + async () => { + const setMock = vi.fn(() => {}) + interval.set = setMock as any - 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 client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: 'polling', + }) + await client.waitForInitialised() - const pollCallback = (setMock.mock.calls[0] as any)[0] - await pollCallback() + const pollCallback = (setMock.mock.calls[0] as any)[0] + await pollCallback() - const value = client - .request([]) - .getFeatureValue('my-feature', 'default-value') - expect(value).toEqual('new-service-default-value') - }, - ), -) + const value = client + .request([]) + .getFeatureValue('my-feature', 'default-value') + expect(value).toEqual('new-service-default-value') + }, + ), + ) -it( - 'Polling swallows errors received when updating features', - featureBoardFixture( - { countAPICalls: 0 }, - (testContext) => [ - http.get( - 'https://client.featureboard.app/all', - () => { + 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 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' }, + return new Response(null, { + status: 429, + headers: { 'Retry-After': '2' }, + }) + }), + ], + async ({ testContext }) => { + const client = createServerClient({ + environmentApiKey: 'fake-key', + updateStrategy: { + kind: 'polling', + options: { intervalMs: 100 }, + }, }) - }), - ], - async ({ testContext }) => { - 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') - expect(testContext.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/createOnRequestUpdateStrategy.ts b/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts index b3d8f0da..01feaf59 100644 --- a/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createOnRequestUpdateStrategy.ts @@ -6,6 +6,7 @@ import { fetchFeaturesConfigurationViaHttp } from '../utils/fetchFeaturesConfigu import { getTracer } from '../utils/get-tracer' import { getAllEndpoint } from './getAllEndpoint' import { type AllConfigUpdateStrategy } from './update-strategies' +import { addDebugEvent } from '../utils/add-debug-event' export function createOnRequestUpdateStrategy( environmentApiKey: string, @@ -46,7 +47,7 @@ export function createOnRequestUpdateStrategy( }, async onRequest() { return getTracer().startActiveSpan( - 'on-request', + 'fbsdk-on-request', { attributes: { etag } }, async (span) => { if (fetchUpdatesSingle) { @@ -77,7 +78,7 @@ export function createOnRequestUpdateStrategy( expiry: 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 93ac377e..e682f0a5 100644 --- a/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts +++ b/libs/node-sdk/src/update-strategies/createPollingUpdateStrategy.ts @@ -38,7 +38,7 @@ export function createPollingUpdateStrategy( } stopPolling = pollingUpdates(() => { return startActiveSpan({ - name: 'polling-updates', + name: 'fbsdk-polling-updates', options: { attributes: { etag }, root: !parentSpan }, parentSpan, fn: async (span) => { @@ -50,6 +50,7 @@ export function createPollingUpdateStrategy( }) .finally(() => span.end()) } + span.end() }, }) }, intervalMs) diff --git a/libs/node-sdk/src/utils/debug-store.ts b/libs/node-sdk/src/utils/debug-store.ts index 19b60220..6d7c6381 100644 --- a/libs/node-sdk/src/utils/debug-store.ts +++ b/libs/node-sdk/src/utils/debug-store.ts @@ -11,19 +11,19 @@ export class DebugFeatureStateStore implements IFeatureStateStore { all(): Record { const allValues = this.store.all() - addDebugEvent('all', { allValues: JSON.stringify(allValues) }) + addDebugEvent('Feature store: all features', { allValues: JSON.stringify(allValues) }) return { ...allValues } } get(featureKey: string): FeatureConfiguration | undefined { const value = this.store.get(featureKey) - addDebugEvent('get', { featureKey, value: JSON.stringify(value) }) + addDebugEvent('Feature store: get feature', { featureKey, value: JSON.stringify(value) }) return value } set(featureKey: string, value: FeatureConfiguration | undefined) { - addDebugEvent('set', { featureKey, value: JSON.stringify(value) }) + 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 index 60aa92ee..37c028e7 100644 --- a/libs/node-sdk/src/utils/featureboard-fixture.ts +++ b/libs/node-sdk/src/utils/featureboard-fixture.ts @@ -19,7 +19,7 @@ export function featureBoardFixture( server.listen({ onUnhandledRequest: 'error' }) await tracer.startActiveSpan( - task.name, + `${task.suite.name ? task.suite.name + ': ' : ''}${task.name}`, { root: true, attributes: {}, diff --git a/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts b/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts index 3eb82ec3..608711e2 100644 --- a/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts +++ b/libs/node-sdk/src/utils/fetchFeaturesConfiguration.ts @@ -5,7 +5,6 @@ import { import { resolveError } from '@featureboard/js-sdk' import { SpanStatusCode } from '@opentelemetry/api' import type { IFeatureStateStore } from '../feature-state-store' -import { addDebugEvent } from './add-debug-event' import { getTracer } from './get-tracer' export async function fetchFeaturesConfigurationViaHttp( @@ -15,7 +14,7 @@ export async function fetchFeaturesConfigurationViaHttp( etag: string | undefined, ): Promise { return getTracer().startActiveSpan( - 'fetch-features-http', + 'fbsdk-fetch-features-http', { attributes: { etag } }, async (span) => { try { @@ -66,7 +65,7 @@ export async function fetchFeaturesConfigurationViaHttp( // Expect most times will just get a response from the HEAD request saying no updates if (response.status === 304) { - addDebugEvent('No changes') + span.addEvent('Fetch succeeded without changes') return etag } @@ -85,7 +84,7 @@ export async function fetchFeaturesConfigurationViaHttp( } const newEtag = response.headers.get('etag') || undefined - addDebugEvent('fetching updates done', { newEtag }) + span.addEvent('Fetch succeeded with updates', { newEtag }) return newEtag } catch (error) { const err = resolveError(error) 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, + ) +} From bd7ddc740bde4af2ec0837c7273379065b99b71c Mon Sep 17 00:00:00 2001 From: Ida Danielsson Date: Wed, 24 Jan 2024 18:03:25 +0800 Subject: [PATCH 27/28] export client options --- libs/js-sdk/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/js-sdk/src/index.ts b/libs/js-sdk/src/index.ts index 771d1008..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' From 1bfceee041ddd842ac7c3ba6a7b9ff2bd49e9568 Mon Sep 17 00:00:00 2001 From: Ida Danielsson Date: Wed, 24 Jan 2024 18:53:07 +0800 Subject: [PATCH 28/28] Fix spelling --- docs/use-verdaccio.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/use-verdaccio.md b/docs/use-verdaccio.md index efaf00e9..8ef8aef2 100644 --- a/docs/use-verdaccio.md +++ b/docs/use-verdaccio.md @@ -9,7 +9,7 @@ nx local-registry ## Publish your package ```bash -cd libs/js-sdk (or whatever pacakge) +cd libs/js-sdk (or whatever package) pnpm publish --registry http://localhost:4873/ --no-git-checks ```