Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LDClientInterface>`. 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. |
Expand Down
2 changes: 1 addition & 1 deletion packages/client/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LDClientInterface>`. Callers that don't need the instance may discard the return value — this is a non-breaking change from the previous `Promise<void>` signature.
- `getClient()` throws if `initClient()` has not resolved — any code that calls `getClient()` directly must ensure initialization has occurred.
Expand Down
51 changes: 50 additions & 1 deletion packages/client/src/__tests__/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 18 additions & 6 deletions packages/client/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Loading