diff --git a/AGENTS.md b/AGENTS.md index 0802b5eb..2a6a1943 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ The client manages a singleton connection to LaunchDarkly and the associated tel | Export | Description | |---|---| | `initClient(options?)` | Auto-discovers and initializes `@launchdarkly/node-server-sdk` (optional peer dep, loaded via dynamic import). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Promise`. On every successful path, including the already-initialized path, flushes `$ld:ai:sdk:info` for any LaunchDarkly AI packages that have not yet reported. | -| `initClient(client)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface` (e.g. from `@launchdarkly/vercel-server-sdk`). Stores it directly without calling the node SDK. Flushes pending `$ld:ai:sdk:info` events. | +| `initClient(client, options?)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface` (e.g. from `@launchdarkly/vercel-server-sdk`). Stores it directly without calling the node SDK. Flushes pending `$ld:ai:sdk:info` events. The optional second argument is the same options bag as the other overload. Idempotent like that overload: once a client is set, a second call returns it and ignores both a different client and the new telemetry options — `shutdown()` first to swap clients. | | `getClient()` | Returns the initialized `LDClientInterface`. Throws if initialization has not completed. | | `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be called before the process exits. Clears sdk-info reporting so a later client reports again. | | `waitForTelemetry()` | Waits for the OTel provider to be ready. Useful when spans must not be dropped at startup. | diff --git a/packages/client/agents.md b/packages/client/agents.md index 2d561e57..002b98ed 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -473,7 +473,7 @@ When `enabled` is `false`, `config` is always `null`. When `enabled` is `true` b - **Lazy initialization.** Importing the package does not initialize the LD client. The first API call that needs LaunchDarkly (`extractVariation`, graph resolution, etc.) calls `initClient()` internally, provided `LD_SDK_KEY` is set. - **Explicit initialization — Node SDK path.** `initClient(options?)` dynamically imports `@launchdarkly/node-server-sdk` at runtime (optional peer dep). If the package is not installed it throws with a clear message. -- **Explicit initialization — BYOC path.** `initClient(client, options?)` accepts any pre-initialized object that satisfies `LDClientInterface` — this is the path for Vercel, Cloudflare, or other edge runtimes whose SDK has different init semantics. No `@launchdarkly/node-server-sdk` is required. The optional second argument is the same options bag as the other overload, passed whole to telemetry setup (`otlpEndpoint`, `serviceName` and `environment` mean the same thing). +- **Explicit initialization — BYOC path.** `initClient(client, options?)` accepts any pre-initialized object that satisfies `LDClientInterface` — this is the path for Vercel, Cloudflare, or other edge runtimes whose SDK has different init semantics. No `@launchdarkly/node-server-sdk` is required. The optional second argument is the same options bag as the other overload, passed whole to telemetry setup (`otlpEndpoint`, `serviceName` and `environment` mean the same thing). Once a client is set, a repeat call — with the same client or a different one — returns the existing client and ignores the new telemetry options; call `shutdown()` first to swap clients. - **Options are ignored once the client singleton exists.** The skill store is not an option: `setSkillStore(store)` (experimental entry point) sets it independently of `initClient`, on every call, so a client that initialized lazily can be given one later. A nullish argument never clears a configured store; only `shutdown()` does, and it clears skills state unconditionally, because that state can exist without a client. A throw from that clearing is logged and does not fail `shutdown()`. Any options key `initClient` does not read, a `skillStore` key included, is ignored with a generic warning that names the key but not the feature or its setter. - **Return value.** `initClient()` returns `Promise`. Callers that don't need the instance may discard the return value — this is a non-breaking change from the previous `Promise` signature. - `getClient()` throws if `initClient()` has not resolved — any code that calls `getClient()` directly must ensure initialization has occurred. diff --git a/packages/client/src/__tests__/lifecycle.test.ts b/packages/client/src/__tests__/lifecycle.test.ts index 0ce59834..888977a5 100644 --- a/packages/client/src/__tests__/lifecycle.test.ts +++ b/packages/client/src/__tests__/lifecycle.test.ts @@ -419,6 +419,55 @@ describe('lifecycle', () => { await expect(initClient()).rejects.toThrow('timeout'); }); + it('does not re-run telemetry setup on a repeat BYOC call', async () => { + // The repeat call's options cannot take effect: OTel's globals are + // already held. Both the early return and setupTelemetry's one-provider + // guard keep the first call's configuration. + const { initClient } = await import('../lifecycle.js'); + const byocClient = makeMockClient(); + await initClient(byocClient, { serviceName: 'first', otlpEndpoint: 'https://first.test' }); + await initClient(byocClient, { serviceName: 'second', otlpEndpoint: 'https://second.test' }); + await initClient(byocClient); + + expect(mockOTLPTraceExporter).toHaveBeenCalledOnce(); + expect(mockOTLPTraceExporter).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://first.test/v1/traces' }), + ); + expect(mockResourceFromAttributes).toHaveBeenCalledOnce(); + expect(mockResourceFromAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'service.name': 'first' })); + }); + + it('returns the first client on a repeat BYOC call rather than swapping it', async () => { + const { initClient, getClient } = await import('../lifecycle.js'); + const first = makeMockClient(); + const second = makeMockClient(); + expect(await initClient(first)).toBe(first); + expect(await initClient(second)).toBe(first); + expect(getClient()).toBe(first); + }); + + it('swaps in a new BYOC client after shutdown', async () => { + const { initClient, getClient, shutdown } = await import('../lifecycle.js'); + const first = makeMockClient(); + const second = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + await initClient(first); + await shutdown(); + expect(await initClient(second)).toBe(second); + expect(getClient()).toBe(second); + }); + + it('flushes registered AI package information on a repeat BYOC call', async () => { + const client = makeMockClient(); + const { initClient } = await import('../lifecycle.js'); + await initClient(client); + client.track.mockClear(); + registerAiSdkPackage('@launchdarkly/ai-openai-agents', '0.1.1'); + await initClient(client); + + expect(client.track).toHaveBeenCalledOnce(); + expect(client.track.mock.calls[0][2].aiSdkName).toBe('@launchdarkly/ai-openai-agents'); + }); + it('flushes registered AI package information on the BYOC path', async () => { const client = makeMockClient(); registerAiSdkPackage('@launchdarkly/ai-server', '0.1.1'); @@ -777,7 +826,7 @@ describe('lifecycle', () => { it('builds one provider across repeat BYOC calls', async () => { // A second provider would be refused registration yet replace the handle // shutdown flushes, so the live provider would leak and never flush. - const byocClient = { ...makeMockClient(), variation: vi.fn() }; + const byocClient = makeMockClient(); const { initClient, shutdown } = await import('../lifecycle.js'); await initClient(byocClient); await initClient(byocClient); diff --git a/packages/client/src/lifecycle.ts b/packages/client/src/lifecycle.ts index 61b99637..eec0a7ae 100644 --- a/packages/client/src/lifecycle.ts +++ b/packages/client/src/lifecycle.ts @@ -111,8 +111,9 @@ async function setupTelemetry(options: InitBaseClientOptions, sdkKey: string): P // One provider at a time. A second would be refused the global registration, // so it would receive no spans while replacing the handle `shutdownTelemetry` - // flushes, leaking the live one. A repeat BYOC call reuses the existing - // provider, as OTel itself would; a failed init has already torn its own down. + // flushes, leaking the live one. A BYOC call made while an options-path init + // is still in flight reuses that attempt's provider, as OTel itself would; a + // failed init has already torn its own down. // Checked after the imports, since nothing between here and the assignment // below awaits. if (tracerProvider) return null; @@ -366,6 +367,11 @@ function warnOnUnknownOptions(options: unknown): void { * * Idempotent: later calls return the existing client and ignore their options. * + * That idempotency covers overload 2 too: once a client is set, passing a + * *different* pre-initialized client does not swap it, and the second call's + * telemetry options are ignored rather than re-running telemetry setup. Call + * `shutdown()` first to hand the SDK a new client. + * * A call that rejects caches neither a client nor the failure: a later call * retries initialization (once `LD_SDK_KEY` is available, say) rather than * replaying the same rejection. @@ -389,6 +395,16 @@ export async function initClient( warnOnUnknownOptions(isLDClient(optionsOrClient) ? clientOptions : optionsOrClient); + // Ahead of *both* init paths, so "a second call returns the existing client + // and every other option is ignored" holds for BYOC as well, as it does in + // the Python SDK's `_resolve_client`. Below the BYOC branch, a repeat + // `initClient(client)` re-ran telemetry setup with the new options, which + // could not take effect, and swapped the stored client. + if (singleton.client) { + flushAiSdkInfo(singleton.client); + return singleton.client; + } + if (isLDClient(optionsOrClient)) { // Pre-initialized client path (edge / custom runtimes). // Still run telemetry setup (with the caller's options) so OTel traces work @@ -401,10 +417,6 @@ export async function initClient( return optionsOrClient; } - if (singleton.client) { - flushAiSdkInfo(singleton.client); - return singleton.client; - } const pending = singleton.initPromise ?? startInit(singleton, optionsOrClient); let client: LDClientInterface; try {