Skip to content

Commit

Permalink
feat: edge sdks should send events to bulk/environment endpoint (#256)
Browse files Browse the repository at this point in the history
Edge sdks use clientSideID not sdk-key. We should be able to post events
to the eventsUri/bulk/clientSideID endpoint for edge sdks without
needing to specify the sdk key in the authorization header.

This is an alternate solution to #217. Both prs are attempting to enable
events for edge sdks.

Don't be alarmed with the filecount, it's mostly shell file fixes
unrelated to this to include a directive which seems to be needed for
local build.

---------

Co-authored-by: LaunchDarklyReleaseBot <[email protected]>
  • Loading branch information
yusinto and LaunchDarklyReleaseBot authored Nov 12, 2023
1 parent 388c287 commit f45910f
Show file tree
Hide file tree
Showing 20 changed files with 202 additions and 44 deletions.
9 changes: 5 additions & 4 deletions packages/sdk/cloudflare/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
"module": "./dist/index.mjs",
"packageManager": "[email protected]",
"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",
Expand All @@ -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"
}
}
25 changes: 23 additions & 2 deletions packages/sdk/cloudflare/example/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
19 changes: 15 additions & 4 deletions packages/sdk/cloudflare/example/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
const sdkKey = 'test-sdk-key';
async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {
const clientSideID = 'test-client-side-id';
const flagKey = 'testFlag1';
const { searchParams } = new URL(request.url);

Expand All @@ -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);
Expand All @@ -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}`);
},
};
10 changes: 5 additions & 5 deletions packages/sdk/cloudflare/example/src/testData.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"negate": false
}
],
"trackEvents": false,
"trackEvents": true,
"rollout": {
"bucketBy": "bucket",
"variations": [{ "variation": 1, "weight": 100 }]
Expand All @@ -36,7 +36,7 @@
},
"clientSide": true,
"salt": "aef830243d6640d0a973be89988e008d",
"trackEvents": false,
"trackEvents": true,
"trackEventsFallthrough": false,
"debugEventsUntilDate": null,
"version": 2,
Expand Down Expand Up @@ -65,7 +65,7 @@
},
"clientSide": true,
"salt": "aef830243d6640d0a973be89988e008d",
"trackEvents": false,
"trackEvents": true,
"trackEventsFallthrough": false,
"debugEventsUntilDate": null,
"version": 2,
Expand All @@ -87,7 +87,7 @@
"negate": false
}
],
"trackEvents": false
"trackEvents": true
}
],
"fallthrough": {
Expand All @@ -101,7 +101,7 @@
},
"clientSide": true,
"salt": "aef830243d6640d0a973be89988e008d",
"trackEvents": false,
"trackEvents": true,
"trackEventsFallthrough": false,
"debugEventsUntilDate": null,
"version": 2,
Expand Down
12 changes: 6 additions & 6 deletions packages/sdk/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
17 changes: 13 additions & 4 deletions packages/shared/common/src/internal/events/EventSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/common/src/internal/events/LDInternalOptions.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 2 additions & 0 deletions packages/shared/common/src/internal/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,4 +19,5 @@ export {
EventProcessor,
shouldSample,
NullEventProcessor,
LDInternalOptions,
};
24 changes: 24 additions & 0 deletions packages/shared/common/src/options/ServiceEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
16 changes: 13 additions & 3 deletions packages/shared/common/src/utils/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
9 changes: 8 additions & 1 deletion packages/shared/mocks/src/clientContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions packages/shared/sdk-server-edge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions packages/shared/sdk-server-edge/src/api/LDClient.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
});
});
Loading

0 comments on commit f45910f

Please sign in to comment.