diff --git a/packages/sdk/cloudflare/example/package.json b/packages/sdk/cloudflare/example/package.json index dd328d55a2..7434c52e13 100644 --- a/packages/sdk/cloudflare/example/package.json +++ b/packages/sdk/cloudflare/example/package.json @@ -5,13 +5,13 @@ "module": "./dist/index.mjs", "packageManager": "yarn@3.4.1", "dependencies": { - "@launchdarkly/cloudflare-server-sdk": "^2.1.4" + "@launchdarkly/cloudflare-server-sdk": "2.2.3" }, "devDependencies": { "@cloudflare/workers-types": "^4.20230321.0", - "@types/jest": "^27.5.1", + "@types/jest": "^29.5.5", "esbuild": "^0.14.41", - "jest": "^28.1.0", + "jest": "^29.7.0", "jest-environment-miniflare": "^2.5.0", "miniflare": "^2.5.0", "prettier": "^2.6.2", @@ -23,6 +23,7 @@ "build": "node build.js", "start": "wrangler dev", "deploy": "wrangler publish", - "test": "yarn build && jest" + "test": "yarn build && jest", + "clean": "rm -rf dist && rm -rf node_modules && rm -rf .yarn/cache && yarn build" } } diff --git a/packages/sdk/cloudflare/example/src/index.test.ts b/packages/sdk/cloudflare/example/src/index.test.ts index 0ada182361..4ae6b27c8d 100644 --- a/packages/sdk/cloudflare/example/src/index.test.ts +++ b/packages/sdk/cloudflare/example/src/index.test.ts @@ -3,20 +3,41 @@ import testData from './testData.json'; describe('test', () => { let env: Bindings; + let mockExecutionContext: ExecutionContext; beforeEach(async () => { + // solves jest complaining about console.log in flush after exiting + // eslint-disable-next-line no-console + console.log = jest.fn(); + + mockExecutionContext = { + waitUntil: jest.fn(), + passThroughOnException: jest.fn(), + }; env = getMiniflareBindings(); const { LD_KV } = env; await LD_KV.put('LD-Env-test-sdk-key', JSON.stringify(testData)); }); + afterEach(() => { + jest.resetAllMocks(); + }); + test('variation true', async () => { - const res = await app.fetch(new Request('http://localhost/?email=truemail'), env); + const res = await app.fetch( + new Request('http://localhost/?email=truemail'), + env, + mockExecutionContext, + ); expect(await res.text()).toContain('testFlag1: true'); }); test('variation false', async () => { - const res = await app.fetch(new Request('http://localhost/?email=falsemail'), env); + const res = await app.fetch( + new Request('http://localhost/?email=falsemail'), + env, + mockExecutionContext, + ); expect(await res.text()).toContain('testFlag1: false'); }); }); diff --git a/packages/sdk/cloudflare/example/src/index.ts b/packages/sdk/cloudflare/example/src/index.ts index 11d785ed3b..8c44228cbb 100644 --- a/packages/sdk/cloudflare/example/src/index.ts +++ b/packages/sdk/cloudflare/example/src/index.ts @@ -1,8 +1,9 @@ +/* eslint-disable no-console */ import { init as initLD } from '@launchdarkly/cloudflare-server-sdk'; export default { - async fetch(request: Request, env: Bindings): Promise { - const sdkKey = 'test-sdk-key'; + async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise { + const clientSideID = 'test-client-side-id'; const flagKey = 'testFlag1'; const { searchParams } = new URL(request.url); @@ -11,7 +12,7 @@ export default { const context = { kind: 'user', key: 'test-user-key-1', email }; // start using ld - const client = initLD(sdkKey, env.LD_KV); + const client = initLD(clientSideID, env.LD_KV, { sendEvents: true }); await client.waitForInitialization(); const flagValue = await client.variation(flagKey, context, false); const flagDetail = await client.variationDetail(flagKey, context, false); @@ -22,8 +23,18 @@ export default { detail: ${JSON.stringify(flagDetail)} allFlags: ${JSON.stringify(allFlags)}`; - // eslint-disable-next-line console.log(`------------- ${resp}`); + + // Gotcha: you must call flush otherwise events will not be sent to LD servers + // due to the ephemeral nature of edge workers. + // https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#waituntil + ctx.waitUntil( + client.flush((err: Error | null, res: boolean) => { + console.log(`flushed events result: ${res}, error: ${err}`); + client.close(); + }), + ); + return new Response(`${resp}`); }, }; diff --git a/packages/sdk/cloudflare/example/src/testData.json b/packages/sdk/cloudflare/example/src/testData.json index d4fbacd7bb..2c02011ec4 100644 --- a/packages/sdk/cloudflare/example/src/testData.json +++ b/packages/sdk/cloudflare/example/src/testData.json @@ -18,7 +18,7 @@ "negate": false } ], - "trackEvents": false, + "trackEvents": true, "rollout": { "bucketBy": "bucket", "variations": [{ "variation": 1, "weight": 100 }] @@ -36,7 +36,7 @@ }, "clientSide": true, "salt": "aef830243d6640d0a973be89988e008d", - "trackEvents": false, + "trackEvents": true, "trackEventsFallthrough": false, "debugEventsUntilDate": null, "version": 2, @@ -65,7 +65,7 @@ }, "clientSide": true, "salt": "aef830243d6640d0a973be89988e008d", - "trackEvents": false, + "trackEvents": true, "trackEventsFallthrough": false, "debugEventsUntilDate": null, "version": 2, @@ -87,7 +87,7 @@ "negate": false } ], - "trackEvents": false + "trackEvents": true } ], "fallthrough": { @@ -101,7 +101,7 @@ }, "clientSide": true, "salt": "aef830243d6640d0a973be89988e008d", - "trackEvents": false, + "trackEvents": true, "trackEventsFallthrough": false, "debugEventsUntilDate": null, "version": 2, diff --git a/packages/sdk/cloudflare/src/index.ts b/packages/sdk/cloudflare/src/index.ts index 7aa051b975..692a0c6fc4 100644 --- a/packages/sdk/cloudflare/src/index.ts +++ b/packages/sdk/cloudflare/src/index.ts @@ -35,20 +35,20 @@ export type { LDClient }; * (`new LDClient()/new LDClientImpl()/new LDClient()`); the SDK does not currently support * this. * + * @param clientSideID + * The client side ID. This is only used to query the kvNamespace above, + * not to connect with LaunchDarkly servers. * @param kvNamespace * The Cloudflare KV configured for LaunchDarkly. - * @param sdkKey - * The client side SDK key. This is only used to query the kvNamespace above, - * not to connect with LaunchDarkly servers. * @param options * Optional configuration settings. The only supported option is logger. * @return * The new {@link LDClient} instance. */ -export const init = (sdkKey: string, kvNamespace: KVNamespace, options: LDOptions = {}) => { +export const init = (clientSideID: string, kvNamespace: KVNamespace, options: LDOptions = {}) => { const logger = options.logger ?? BasicLogger.get(); - return initEdge(sdkKey, createPlatformInfo(), { - featureStore: new EdgeFeatureStore(kvNamespace, sdkKey, 'Cloudflare', logger), + return initEdge(clientSideID, createPlatformInfo(), { + featureStore: new EdgeFeatureStore(kvNamespace, clientSideID, 'Cloudflare', logger), logger, ...options, }); diff --git a/packages/shared/common/src/internal/events/EventSender.test.ts b/packages/shared/common/src/internal/events/EventSender.test.ts index d90d4dabae..8d06db2774 100644 --- a/packages/shared/common/src/internal/events/EventSender.test.ts +++ b/packages/shared/common/src/internal/events/EventSender.test.ts @@ -12,7 +12,14 @@ jest.mock('../../utils', () => { const basicConfig = { tags: new ApplicationTags({ application: { id: 'testApplication1', version: '1.0.0' } }), - serviceEndpoints: { events: 'https://events.fake.com', streaming: '', polling: '' }, + serviceEndpoints: { + events: 'https://events.fake.com', + streaming: '', + polling: '', + analyticsEventPath: '/bulk', + diagnosticEventPath: '/diagnostic', + includeAuthorizationHeader: true, + }, }; const testEventData1 = { eventId: 'test-event-data-1' }; const testEventData2 = { eventId: 'test-event-data-2' }; diff --git a/packages/shared/common/src/internal/events/EventSender.ts b/packages/shared/common/src/internal/events/EventSender.ts index 5a49347d0e..c61fe46110 100644 --- a/packages/shared/common/src/internal/events/EventSender.ts +++ b/packages/shared/common/src/internal/events/EventSender.ts @@ -20,12 +20,21 @@ export default class EventSender implements LDEventSender { constructor(clientContext: ClientContext) { const { basicConfiguration, platform } = clientContext; - const { sdkKey, serviceEndpoints, tags } = basicConfiguration; + const { + sdkKey, + serviceEndpoints: { + events, + analyticsEventPath, + diagnosticEventPath, + includeAuthorizationHeader, + }, + tags, + } = basicConfiguration; const { crypto, info, requests } = platform; - this.defaultHeaders = defaultHeaders(sdkKey, info, tags); - this.eventsUri = `${serviceEndpoints.events}/bulk`; - this.diagnosticEventsUri = `${serviceEndpoints.events}/diagnostic`; + this.defaultHeaders = defaultHeaders(sdkKey, info, tags, includeAuthorizationHeader); + this.eventsUri = `${events}${analyticsEventPath}`; + this.diagnosticEventsUri = `${events}${diagnosticEventPath}`; this.requests = requests; this.crypto = crypto; } diff --git a/packages/shared/common/src/internal/events/LDInternalOptions.ts b/packages/shared/common/src/internal/events/LDInternalOptions.ts new file mode 100644 index 0000000000..898197051a --- /dev/null +++ b/packages/shared/common/src/internal/events/LDInternalOptions.ts @@ -0,0 +1,15 @@ +/** + * This is for internal use only. + * + * Edge sdks use clientSideID to query feature stores. They also send analytics + * using this clientSideID. This is a hybrid behavior because they are based + * on js-server-common, but uses the clientSideID instead of the sdkKey for the + * above reasons. These internal options allow the edge sdks to use the + * EventSender to send analytics to the correct LD endpoints using + * the clientSideId. + */ +export type LDInternalOptions = { + analyticsEventPath?: string; + diagnosticEventPath?: string; + includeAuthorizationHeader?: boolean; +}; diff --git a/packages/shared/common/src/internal/events/index.ts b/packages/shared/common/src/internal/events/index.ts index 547a87ceb6..27035ef5ee 100644 --- a/packages/shared/common/src/internal/events/index.ts +++ b/packages/shared/common/src/internal/events/index.ts @@ -5,6 +5,7 @@ import InputEvalEvent from './InputEvalEvent'; import InputEvent from './InputEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; +import type { LDInternalOptions } from './LDInternalOptions'; import NullEventProcessor from './NullEventProcessor'; import shouldSample from './sampling'; @@ -18,4 +19,5 @@ export { EventProcessor, shouldSample, NullEventProcessor, + LDInternalOptions, }; diff --git a/packages/shared/common/src/options/ServiceEndpoints.ts b/packages/shared/common/src/options/ServiceEndpoints.ts index 03531950ce..4debefa468 100644 --- a/packages/shared/common/src/options/ServiceEndpoints.ts +++ b/packages/shared/common/src/options/ServiceEndpoints.ts @@ -11,13 +11,37 @@ export default class ServiceEndpoints { public readonly streaming: string; public readonly polling: string; public readonly events: string; + + /** Valid paths are: + * /bulk + * /events/bulk/envId + * /mobile + */ + public readonly analyticsEventPath: string; + + /** Valid paths are: + * /diagnostic + * /events/diagnostic/envId + * /mobile/events/diagnostic + */ + public readonly diagnosticEventPath: string; + + // if true the sdk key will be included as authorization header + public readonly includeAuthorizationHeader: boolean; + public constructor( streaming: string, polling: string, events: string = ServiceEndpoints.DEFAULT_EVENTS, + analyticsEventPath: string = '/bulk', + diagnosticEventPath: string = '/diagnostic', + includeAuthorizationHeader: boolean = true, ) { this.streaming = canonicalizeUri(streaming); this.polling = canonicalizeUri(polling); this.events = canonicalizeUri(events); + this.analyticsEventPath = analyticsEventPath; + this.diagnosticEventPath = diagnosticEventPath; + this.includeAuthorizationHeader = includeAuthorizationHeader; } } diff --git a/packages/shared/common/src/utils/http.ts b/packages/shared/common/src/utils/http.ts index c29ea4759c..a6f8ad2dc5 100644 --- a/packages/shared/common/src/utils/http.ts +++ b/packages/shared/common/src/utils/http.ts @@ -2,20 +2,30 @@ import { Info } from '../api'; import { ApplicationTags } from '../options'; export type LDHeaders = { - authorization: string; + authorization?: string; 'user-agent': string; 'x-launchdarkly-wrapper'?: string; 'x-launchdarkly-tags'?: string; }; -export function defaultHeaders(sdkKey: string, info: Info, tags?: ApplicationTags): LDHeaders { +export function defaultHeaders( + sdkKey: string, + info: Info, + tags?: ApplicationTags, + includeAuthorizationHeader: boolean = true, +): LDHeaders { const { userAgentBase, version, wrapperName, wrapperVersion } = info.sdkData(); const headers: LDHeaders = { - authorization: sdkKey, 'user-agent': `${userAgentBase ?? 'NodeJSClient'}/${version}`, }; + // edge sdks sets this to false because they use the clientSideID + // and they don't need the authorization header + if (includeAuthorizationHeader) { + headers.authorization = sdkKey; + } + if (wrapperName) { headers['x-launchdarkly-wrapper'] = wrapperVersion ? `${wrapperName}/${wrapperVersion}` diff --git a/packages/shared/mocks/src/clientContext.ts b/packages/shared/mocks/src/clientContext.ts index aa14729f55..90baaefe71 100644 --- a/packages/shared/mocks/src/clientContext.ts +++ b/packages/shared/mocks/src/clientContext.ts @@ -5,7 +5,14 @@ import basicPlatform from './platform'; const clientContext: ClientContext = { basicConfiguration: { sdkKey: 'testSdkKey', - serviceEndpoints: { events: '', polling: '', streaming: 'https://mockstream.ld.com' }, + serviceEndpoints: { + events: '', + polling: '', + streaming: 'https://mockstream.ld.com', + diagnosticEventPath: '/diagnostic', + analyticsEventPath: '/bulk', + includeAuthorizationHeader: true, + }, }, platform: basicPlatform, }; diff --git a/packages/shared/sdk-server-edge/package.json b/packages/shared/sdk-server-edge/package.json index 578d201b7c..cc8cfae80e 100644 --- a/packages/shared/sdk-server-edge/package.json +++ b/packages/shared/sdk-server-edge/package.json @@ -40,6 +40,7 @@ "crypto-js": "^4.1.1" }, "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^29.5.0", diff --git a/packages/shared/sdk-server-edge/src/api/LDClient.test.ts b/packages/shared/sdk-server-edge/src/api/LDClient.test.ts new file mode 100644 index 0000000000..fe7949811d --- /dev/null +++ b/packages/shared/sdk-server-edge/src/api/LDClient.test.ts @@ -0,0 +1,41 @@ +import { internal } from '@launchdarkly/js-server-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + +import LDClient from './LDClient'; + +jest.mock('@launchdarkly/js-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-sdk-common'); + return { + ...actual, + ...{ + internal: { + ...actual.internal, + DiagnosticsManager: jest.fn(), + EventProcessor: jest.fn(), + }, + }, + }; +}); + +const mockEventProcessor = internal.EventProcessor as jest.Mock; +describe('Edge LDClient', () => { + it('uses clientSideID endpoints', async () => { + const client = new LDClient('client-side-id', basicPlatform.info, { + sendEvents: true, + }); + await client.waitForInitialization(); + const passedConfig = mockEventProcessor.mock.calls[0][0]; + + expect(passedConfig).toMatchObject({ + sendEvents: true, + serviceEndpoints: { + includeAuthorizationHeader: false, + analyticsEventPath: '/events/bulk/client-side-id', + diagnosticEventPath: '/events/diagnostic/client-side-id', + events: 'https://events.launchdarkly.com', + polling: 'https://sdk.launchdarkly.com', + streaming: 'https://stream.launchdarkly.com', + }, + }); + }); +}); diff --git a/packages/shared/sdk-server-edge/src/api/LDClient.ts b/packages/shared/sdk-server-edge/src/api/LDClient.ts index d2b4e821ba..60a978d4b9 100644 --- a/packages/shared/sdk-server-edge/src/api/LDClient.ts +++ b/packages/shared/sdk-server-edge/src/api/LDClient.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; -import { Info, LDClientImpl, LDOptions } from '@launchdarkly/js-server-sdk-common'; +import { Info, internal, LDClientImpl, LDOptions } from '@launchdarkly/js-server-sdk-common'; import EdgePlatform from '../platform'; import createCallbacks from './createCallbacks'; @@ -9,16 +9,19 @@ import createOptions from './createOptions'; /** * The LaunchDarkly SDK edge client object. */ -export class LDClient extends LDClientImpl { +export default class LDClient extends LDClientImpl { emitter: EventEmitter; - // sdkKey is only used to query featureStore, not to initialize with LD servers - constructor(sdkKey: string, platformInfo: Info, options: LDOptions) { + // clientSideID is only used to query the edge key-value store and send analytics, not to initialize with LD servers + constructor(clientSideID: string, platformInfo: Info, options: LDOptions) { const em = new EventEmitter(); const platform = new EdgePlatform(platformInfo); - super('n/a', platform, createOptions(options), createCallbacks(em)); + const internalOptions: internal.LDInternalOptions = { + analyticsEventPath: `/events/bulk/${clientSideID}`, + diagnosticEventPath: `/events/diagnostic/${clientSideID}`, + includeAuthorizationHeader: false, + }; + super(clientSideID, platform, createOptions(options), createCallbacks(em), internalOptions); this.emitter = em; } } - -export default LDClient; diff --git a/packages/shared/sdk-server-edge/src/api/index.ts b/packages/shared/sdk-server-edge/src/api/index.ts index b72690fe3b..c4ae612f9d 100644 --- a/packages/shared/sdk-server-edge/src/api/index.ts +++ b/packages/shared/sdk-server-edge/src/api/index.ts @@ -1,2 +1,4 @@ +import LDClient from './LDClient'; + export * from './EdgeFeatureStore'; -export * from './LDClient'; +export { LDClient }; diff --git a/packages/shared/sdk-server-edge/src/index.ts b/packages/shared/sdk-server-edge/src/index.ts index 60d9785e80..24a6c49b77 100644 --- a/packages/shared/sdk-server-edge/src/index.ts +++ b/packages/shared/sdk-server-edge/src/index.ts @@ -19,9 +19,9 @@ export type { LDClient, LDOptions, EdgeProvider }; * * This is an internal API to be used directly only by LaunchDarkly Edge SDKs. */ -export const init = (sdkKey: string, platformInfo: Info, options: LDOptionsInternal) => { +export const init = (clientSideID: string, platformInfo: Info, options: LDOptionsInternal) => { // this throws if options are invalid - validateOptions(sdkKey, options); + validateOptions(clientSideID, options); - return new LDClient(sdkKey, platformInfo, options); + return new LDClient(clientSideID, platformInfo, options); }; diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 13ec18627f..ef0818bc08 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -120,13 +120,14 @@ export default class LDClientImpl implements LDClient { private platform: Platform, options: LDOptions, callbacks: LDClientCallbacks, + internalOptions?: internal.LDInternalOptions, ) { this.onError = callbacks.onError; this.onFailed = callbacks.onFailed; this.onReady = callbacks.onReady; const { onUpdate, hasEventListeners } = callbacks; - const config = new Configuration(options); + const config = new Configuration(options, internalOptions); if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index 494bb83c5d..709216d189 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -1,5 +1,6 @@ import { ApplicationTags, + internal, LDClientContext, LDLogger, NumberWithMinimum, @@ -207,7 +208,7 @@ export default class Configuration { public readonly bigSegments?: LDBigSegmentsOptions; - constructor(options: LDOptions = {}) { + constructor(options: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) { // The default will handle undefined, but not null. // Because we can be called from JS we need to be extra defensive. // eslint-disable-next-line no-param-reassign @@ -226,6 +227,9 @@ export default class Configuration { validatedOptions.streamUri, validatedOptions.baseUri, validatedOptions.eventsUri, + internalOptions.analyticsEventPath, + internalOptions.diagnosticEventPath, + internalOptions.includeAuthorizationHeader, ); this.eventsCapacity = validatedOptions.capacity; this.timeout = validatedOptions.timeout; diff --git a/scripts/package-name.sh b/scripts/package-name.sh index 1827284c73..e443b2c7d4 100755 --- a/scripts/package-name.sh +++ b/scripts/package-name.sh @@ -1,4 +1,3 @@ -#!/usr/bin/env bash # Given a path get the name of the package. # ./scripts/package-name.sh packages/sdk/server-node # Produces something like: