diff --git a/packages/sdk/fastly/README.md b/packages/sdk/fastly/README.md index dbf0eb9d87..6d0c24db92 100644 --- a/packages/sdk/fastly/README.md +++ b/packages/sdk/fastly/README.md @@ -22,7 +22,18 @@ yarn add @launchdarkly/fastly-server-sdk - The SDK must be initialized and used when processing requests, not during build-time initialization. - The SDK caches all KV data during initialization to reduce the number of backend requests needed to fetch KV data. This means changes to feature flags or segments will not be picked up during the lifecycle of a single request instance. -- Events should flushed using the [`waitUntil()` method](https://js-compute-reference-docs.edgecompute.app/docs/globals/FetchEvent/prototype/waitUntil). + +### Events and the client lifecycle + +The SDK is designed to be created per request: call `init()` inside your request handler, and flush events at the end of each request using the [`waitUntil()` method](https://js-compute-reference-docs.edgecompute.app/docs/globals/FetchEvent/prototype/waitUntil): + +```js +event.waitUntil(ldClient.flush()); +``` + +The SDK does not run a periodic background flush. On [Fastly Compute](https://www.fastly.com/documentation/guides/compute/developer-guides/sandbox-lifecycle/), outbound requests must be made while a request is being handled, and by default each request runs in a fresh sandbox that is torn down afterward, so a background timer cannot reliably deliver events. Flushing explicitly with `waitUntil()` on every request that evaluates flags is the supported pattern -- it keeps the sandbox alive just long enough to send the events after the response is returned. + +If you enable Fastly's opt-in [reusable sandboxes](https://www.fastly.com/documentation/guides/compute/developer-guides/sandbox-lifecycle/), a single sandbox may serve multiple requests, so avoid holding a single client at module scope and relying on it to flush on its own: buffered events would accumulate and, once the event queue capacity is reached, be dropped. Creating a client per request and flushing via `waitUntil()` avoids this regardless of sandbox mode. ## Quickstart diff --git a/packages/sdk/fastly/__tests__/api/LDClient.test.ts b/packages/sdk/fastly/__tests__/api/LDClient.test.ts index b4d2e71fe1..176f117d7a 100644 --- a/packages/sdk/fastly/__tests__/api/LDClient.test.ts +++ b/packages/sdk/fastly/__tests__/api/LDClient.test.ts @@ -44,6 +44,19 @@ describe('Edge LDClient', () => { }, }); }); + it('constructs the event processor without background flush timers', async () => { + const client = new LDClient('client-side-id', createBasicPlatform().info, { + sendEvents: true, + eventsBackendName: 'launchdarkly', + }); + await client.waitForInitialization({ timeout: 10 }); + + // The 6th positional argument to EventProcessor is `start`. Edge clients run per + // request and flush explicitly, so they must not start the background flush timers. + expect(mockEventProcessor).toHaveBeenCalled(); + expect(mockEventProcessor.mock.calls[0][5]).toBe(false); + }); + it('uses custom eventsUri when specified', async () => { const client = new LDClient('client-side-id', createBasicPlatform().info, { sendEvents: true, diff --git a/packages/sdk/fastly/src/api/LDClient.ts b/packages/sdk/fastly/src/api/LDClient.ts index 443becd83c..84e9822ed1 100644 --- a/packages/sdk/fastly/src/api/LDClient.ts +++ b/packages/sdk/fastly/src/api/LDClient.ts @@ -1,4 +1,4 @@ -import { Info, internal, LDClientImpl } from '@launchdarkly/js-server-sdk-common'; +import { Info, LDClientImpl, ServerInternalOptions } from '@launchdarkly/js-server-sdk-common'; import EdgePlatform from '../platform'; import { FastlySDKOptions } from '../utils/validateOptions'; @@ -18,10 +18,16 @@ export default class LDClient extends LDClientImpl { platformInfo, eventsBackendName || DEFAULT_EVENTS_BACKEND_NAME, ); - const internalOptions: internal.LDInternalOptions = { + const internalOptions: ServerInternalOptions = { analyticsEventPath: `/events/bulk/${clientSideID}`, diagnosticEventPath: `/events/diagnostic/${clientSideID}`, includeAuthorizationHeader: false, + // Fastly Compute runs a fresh instance per request and flushes events explicitly + // via the request handler (event.waitUntil(client.flush())). Disable the event + // processor's background flush timers so a per-request client does not leave + // interval timers running that would keep the runtime alive and pin the client + // graph in memory. + disableBackgroundEventFlush: true, }; const finalOptions = createOptions(ldOptions); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 591ea99ad1..6af44c9137 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -116,6 +116,7 @@ function constructFDv1( dataSourceErrorHandler: (e: any) => void, hooks: Hook[], instanceId: string | undefined, + startEventProcessor: boolean, ): { config: Configuration; logger: LDLogger | undefined; @@ -170,6 +171,7 @@ function constructFDv1( baseHeaders, new ContextDeduplicator(config), diagnosticsManager, + startEventProcessor, ); } @@ -253,6 +255,7 @@ function constructFDv2( initSuccess: () => void, hooks: Hook[], instanceId: string | undefined, + startEventProcessor: boolean, ): { config: Configuration; logger: LDLogger | undefined; @@ -314,6 +317,7 @@ function constructFDv2( baseHeaders, new ContextDeduplicator(config), diagnosticsManager, + startEventProcessor, ); } @@ -587,6 +591,10 @@ export default class LDClientImpl implements LDClient { ) { const config = new Configuration(options, internalOptions); + // Edge SDKs run per request and flush events explicitly, so they opt out of the + // event processor's background flush timers to avoid leaving interval timers running. + const startEventProcessor = !internalOptions?.disableBackgroundEventFlush; + this.environmentMetadata = createPluginEnvironmentMetadata(_platform, _sdkKey, config); const hooks: Hook[] = []; @@ -620,6 +628,7 @@ export default class LDClientImpl implements LDClient { (e) => this._dataSourceErrorHandler(e), hooks, internalOptions?.instanceId, + startEventProcessor, )); this.bigSegmentStatusProviderInternal = this._bigSegmentsManager @@ -657,6 +666,7 @@ export default class LDClientImpl implements LDClient { () => this._initSuccess(), hooks, internalOptions?.instanceId, + startEventProcessor, )); this._featureStore = transactionalStore; this.bigSegmentStatusProviderInternal = this._bigSegmentsManager diff --git a/packages/shared/sdk-server/src/index.ts b/packages/shared/sdk-server/src/index.ts index 63e0a22323..efa57ae57f 100644 --- a/packages/shared/sdk-server/src/index.ts +++ b/packages/shared/sdk-server/src/index.ts @@ -11,6 +11,8 @@ export * from './events'; export * from '@launchdarkly/js-sdk-common'; export * as internalServer from './internal'; +export type { ServerInternalOptions } from './options/ServerInternalOptions'; + export { LDClientImpl, BigSegmentStoreStatusProviderImpl, diff --git a/packages/shared/sdk-server/src/options/ServerInternalOptions.ts b/packages/shared/sdk-server/src/options/ServerInternalOptions.ts index 9c571f8672..1583fe9191 100644 --- a/packages/shared/sdk-server/src/options/ServerInternalOptions.ts +++ b/packages/shared/sdk-server/src/options/ServerInternalOptions.ts @@ -12,4 +12,17 @@ export interface ServerInternalOptions extends internal.LDInternalOptions { * undefined and the header is omitted. */ instanceId?: string; + + /** + * When true, the event processor is constructed without starting its periodic + * background work (the flush and context-deduplication interval timers, and the + * diagnostic timer when diagnostics are enabled). Events are still recorded and + * are delivered only via explicit flush() calls. + * + * This is intended for per-request edge SDKs that flush explicitly (for example + * via a runtime's waitUntil) and must not leave interval timers running. A live + * interval timer keeps the runtime event loop alive and roots the whole client + * graph in memory, which for a per-request client is a leak. + */ + disableBackgroundEventFlush?: boolean; }