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
18 changes: 17 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,23 @@ Span events are not a content carrier. OTEP 4430 deprecated the span-event
recording API, and the `gen_ai.content.prompt` / `gen_ai.content.completion`
events these handlers used to emit were read by nothing on the LaunchDarkly
side. The only event a handler emits is `feature_flag`, on the root, for
trace correlation. When `variables.ldContext` has a usable identity, that
trace correlation. It carries `feature_flag.result.variant` (the served
variation key) whenever that key is non-empty; graph spans can have an empty
one, and then it is left off. It also carries `feature_flag.result.value`.
When `variables.__ldValue` is present, it is `JSON.stringify` of that raw
variation (messages or instructions, model, provider, `_ldMeta`, judge
configuration), the same string the LaunchDarkly OTel `TracingHook` writes
with `includeValue: true`. Otherwise it is `JSON.stringify` of the variation
key (for example `"\"opus\""`), and it is left off when there is neither.
The core client sets `__ldValue`, beside `__ld`, only when the handler that
runs has `captureContent` on (`ProviderHandler.captureContent`, the same gate
judge reasoning uses), so the raw config never reaches a capture-off handler.
A deferred judge carries its variation on `JudgeTask.judgeValue`, and
`runJudge` applies the same gate with the judge's handler. The
`launchdarkly.graph` root span has no single handler, so it gets the graph
flag's raw variation only when the process-wide default captures content.
It is not in `TrackData`, because `TrackData` is the `$ld:ai:*` event payload.
When `variables.ldContext` has a usable identity, that
event also carries `feature_flag.context.id` and `feature_flag.contextKeys`,
and the root span gets `context.contextKeys.<kind>`. Child spans must not.

Expand Down
2 changes: 2 additions & 0 deletions packages/claude-agents/src/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,8 @@ describe('createClaudeAgentsHandler', () => {
'feature_flag.key': 'test-config',
'feature_flag.provider.name': 'LaunchDarkly',
'feature_flag.set.id': 'env-abc',
'feature_flag.result.variant': 'variation-a',
'feature_flag.result.value': JSON.stringify('variation-a'),
});
expect(mockSpan.setAttribute).toHaveBeenCalledWith('launchdarkly.operation.type', 'gen_ai');
expect(mockSpan.setAttribute).toHaveBeenCalledWith('launchdarkly.config.key', 'test-config');
Expand Down
1 change: 1 addition & 0 deletions packages/client/etc/ai-server.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ export type JudgeTask = {
configKey: string;
judgeConfig: AiConfigRep;
judgeMeta: LDVariationMeta;
judgeValue?: unknown;
actualOutput: string;
userInput?: string;
trajectory?: string;
Expand Down
107 changes: 106 additions & 1 deletion packages/client/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ vi.mock('../judges.js', async (importOriginal) => {
});

import { config } from '../client.js';
import { runJudges } from '../judges.js';
import { buildJudgeTasks, runJudge, runJudges } from '../judges.js';
import { extractVariation, getClient } from '../lifecycle.js';
import type { ProviderHandler } from '../types.js';
import { createHandler, setLdSpanAttributes } from '../utils.js';

// ─── Fixtures ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -921,3 +922,107 @@ describe('config().stream() — multi-handler routing', () => {
await expect(collectStream(gen)).rejects.toThrow(/OpenAI/i);
});
});

// ─── config().invoke — feature_flag.result.value with content capture on ─────

describe('config().invoke — capture-on handler', () => {
beforeEach(() => {
vi.clearAllMocks();
(getClient as ReturnType<typeof vi.fn>).mockReturnValue({ track: mockTrack });
(runJudges as ReturnType<typeof vi.fn>).mockResolvedValue({});
});

it('writes the raw variation JSON as feature_flag.result.value on the root feature_flag event', async () => {
const rawVariation = {
...mockConfig,
_ldMeta: { enabled: true, variationKey: 'v1', version: 1, mode: 'messages' },
};
(extractVariation as ReturnType<typeof vi.fn>).mockResolvedValue({
config: mockConfig,
meta: mockMeta,
value: rawVariation,
});
const span = { setAttribute: vi.fn(), addEvent: vi.fn() };
const handler = createHandler(
['OpenAI', 'messages'],
async (_config, _input, _tools, variables) => {
setLdSpanAttributes(span as any, variables);
return { output: 'ok', usage: { input_tokens: 1, output_tokens: 1 } };
},
{ captureContent: true },
);

await config({ key: 'my-flag', handler }).invoke('hi', mockContext);

const event = span.addEvent.mock.calls.find((c: unknown[]) => c[0] === 'feature_flag');
expect((event?.[1] as Record<string, unknown>)['feature_flag.result.value']).toBe(JSON.stringify(rawVariation));
});
});

// ─── buildJudgeTasks / runJudge — feature_flag.result.value on the deferred judge span ─────

describe('deferred judge feature_flag.result.value', () => {
const judgeConfig = {
model: { name: 'gpt-4o' },
provider: { name: 'OpenAI' },
instructions: 'You are a judge.',
evaluationMetricKey: 'judge-metric',
};
const judgeMeta = { enabled: true, variationKey: 'judge-v1', version: 2, mode: 'messages' as const };
const rawJudgeVariation = { ...judgeConfig, _ldMeta: { ...judgeMeta } };
const parentConfig = {
...mockConfig,
judgeConfiguration: { judges: [{ key: 'judge-flag', samplingRate: 1 }] },
};

beforeEach(() => {
vi.clearAllMocks();
(getClient as ReturnType<typeof vi.fn>).mockReturnValue({ track: mockTrack });
(extractVariation as ReturnType<typeof vi.fn>).mockResolvedValue({
config: judgeConfig,
meta: judgeMeta,
value: rawJudgeVariation,
});
});

const judgeHandler = (span: { setAttribute: unknown; addEvent: unknown }, capture: boolean) =>
createHandler(
['OpenAI', 'messages'],
async (_config, _input, _tools, variables) => {
setLdSpanAttributes(span as any, variables);
return { output: '{"score":0.9,"reasoning":"ok"}', usage: { input_tokens: 1, output_tokens: 1 } };
},
{ captureContent: capture },
);

const featureFlagValue = (span: { addEvent: ReturnType<typeof vi.fn> }) => {
const event = span.addEvent.mock.calls.find((c: unknown[]) => c[0] === 'feature_flag');
return (event?.[1] as Record<string, unknown>)['feature_flag.result.value'];
};

it.each([
['on', true, JSON.stringify(rawJudgeVariation)],
['off', false, JSON.stringify('judge-v1')],
])('with capture %s, the task carries the raw judge variation and its run writes the expected value', async (_label, capture, expected) => {
const span = { setAttribute: vi.fn(), addEvent: vi.fn() };
const handler = judgeHandler(span, capture);
const tasks = await buildJudgeTasks({
config: parentConfig,
userContext: mockContext,
handler,
handlers: [handler],
llmResponse: 'hello',
baseTrackData: { runId: 'r', configKey: 'flag', variationKey: 'v1', version: 1, modelName: '', providerName: '' },
});

expect(tasks).toHaveLength(1);
expect(tasks[0].judgeValue).toEqual(rawJudgeVariation);
// A worker receives the task through structured clone.
const task = structuredClone(tasks[0]);

const result = await runJudge(task, [handler]);

expect(result?.score).toBe(0.9);
expect(featureFlagValue(span)).toBe(expected);
});
});
43 changes: 42 additions & 1 deletion packages/client/src/__tests__/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,23 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites

// ─── Hoisted mocks ────────────────────────────────────────────────────────────

const { mockTrack, mockVariation, mockExtractVariation } = vi.hoisted(() => ({
const { mockTrack, mockVariation, mockExtractVariation, captureDefault } = vi.hoisted(() => ({
mockTrack: vi.fn(),
mockVariation: vi.fn(),
mockExtractVariation: vi.fn(),
// Stands in for the process-wide content capture default, which is off on this branch.
captureDefault: { value: false },
}));

vi.mock('../content.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../content.js')>();
return {
...actual,
captureContentFor: (handler: { captureContent?: boolean } | undefined) =>
typeof handler?.captureContent === 'boolean' ? handler.captureContent : captureDefault.value,
};
});

vi.mock('../lifecycle.js', () => ({
getClient: vi.fn().mockReturnValue({ track: mockTrack, variation: mockVariation }),
initClient: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -688,6 +699,36 @@ describe('graph() conversation id', () => {
expect(flagEvents[0].attributes?.['feature_flag.key']).toBe('graph-flag');
});

it.each([
['invoke', false],
['stream', false],
['invoke', true],
['stream', true],
] as const)('writes feature_flag.result.value on the launchdarkly.graph span on %s with default capture %s', async (mode, capture) => {
setupTwoNodeGraph();
const rawGraphVariation = {
root: 'root-node',
edges: { 'root-node': [{ key: 'leaf-node' }] },
_ldMeta: { variationKey: 'graph-v1', version: 3 },
};
mockVariation.mockResolvedValue(rawGraphVariation);
captureDefault.value = capture;
try {
const g = graph('graph-flag', { handlers: [makeStreamingHandler(['ok'])] });
if (mode === 'invoke') await g.invoke('hi', mockContext);
else await collectStream(g.stream('hi', mockContext));
} finally {
captureDefault.value = false;
}

const graphSpan = exporter.getFinishedSpans().find((s) => s.name === 'launchdarkly.graph');
const flagEvent = graphSpan?.events.find((e) => e.name === 'feature_flag');
expect(flagEvent?.attributes?.['feature_flag.result.variant']).toBe('graph-v1');
expect(flagEvent?.attributes?.['feature_flag.result.value']).toBe(
capture ? JSON.stringify(rawGraphVariation) : JSON.stringify('graph-v1'),
);
});

it('gives each invocation with the same context its own run id', async () => {
setupTwoNodeGraph();
const g = graph('graph-flag', { handlers: [makeStreamingHandler(['ok'])] });
Expand Down
90 changes: 90 additions & 0 deletions packages/client/src/__tests__/tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,71 @@ describe('executeAndTrack', () => {
expect(result.response).toBe('Hello!');
});

it('passes evaluatedValue as __ldValue to a capture-on handler and keeps it out of trackData and events', async () => {
const handler = makeHandler('ok');
handler.captureContent = true;
const raw = { ...execConfig, _ldMeta: { enabled: true, variationKey: 'v1' } };
const result = await executeAndTrack({
configKey: 'my-flag',
config: execConfig as any,
meta: execMeta,
userContext: execContext,
handler,
evaluatedValue: raw,
});
expect(handler.mock.calls[0][3].__ldValue).toBe(raw);
expect(result.trackData).not.toHaveProperty('__ldValue');
for (const call of mockTrack.mock.calls) expect(JSON.stringify(call[2])).not.toContain('You are helpful.');
});

it.each([
['capture off', false],
['capture unset', undefined],
])('does not pass __ldValue to a handler with %s', async (_label, capture) => {
const handler = makeHandler('ok');
if (capture !== undefined) handler.captureContent = capture;
await executeAndTrack({
configKey: 'my-flag',
config: execConfig as any,
meta: execMeta,
userContext: execContext,
handler,
evaluatedValue: { ...execConfig },
});
expect(handler.mock.calls[0][3]).not.toHaveProperty('__ldValue');
});

it.each([
['capture off', false, undefined],
['capture on', true, 'evaluated'],
])('never passes a caller-supplied __ldValue to a handler with %s', async (_label, capture, expected) => {
const handler = makeHandler('ok');
handler.captureContent = capture;
await executeAndTrack({
configKey: 'my-flag',
config: execConfig as any,
meta: execMeta,
userContext: execContext,
handler,
variables: { __ldValue: 'from-caller' },
evaluatedValue: 'evaluated',
});
expect(handler.mock.calls[0][3].__ldValue).toBe(expected);
});

it('does not set __ldValue when evaluatedValue is absent', async () => {
const handler = makeHandler('ok');
handler.captureContent = true;
await executeAndTrack({
configKey: 'my-flag',
config: execConfig as any,
meta: execMeta,
userContext: execContext,
handler,
});
expect(handler.mock.calls[0][3]).not.toHaveProperty('__ldValue');
});

it('returns parsed usage with correct totals', async () => {
const handler = makeHandler('hi', { input_tokens: 5, output_tokens: 10 });
const result = await executeAndTrack({
Expand Down Expand Up @@ -376,6 +441,31 @@ describe('executeAndStream', () => {
return fn;
}

it.each([
[true, true],
[false, false],
])('passes __ldValue to the stream only when captureContent is %s', async (capture, expected) => {
const handler = makeStreamingHandler(['Hi']);
const seen: Record<string, unknown>[] = [];
const inner = handler.stream;
handler.stream = (c: unknown, u: unknown, t: unknown, v: Record<string, unknown>, h: unknown) => {
seen.push(v);
return inner(c, u, t, v, h);
};
handler.captureContent = capture;
await collectExecStream(
executeAndStream({
configKey: 'my-flag',
config: execConfig as any,
meta: execMeta,
userContext: execContext,
handler,
evaluatedValue: { ...execConfig },
}),
);
expect('__ldValue' in seen[0]).toBe(expected);
});

it('copies modelKey and modelVersion from meta onto every streamed event payload', async () => {
const handler = makeStreamingHandler(['Hi']);
await collectExecStream(
Expand Down
42 changes: 42 additions & 0 deletions packages/client/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,48 @@ describe('setLdSpanAttributes', () => {
const eventCall = span.addEvent.mock.calls.find((c: any[]) => c[0] === 'feature_flag');
expect(eventCall?.[1]).not.toHaveProperty('feature_flag.set.id');
});

it('includes feature_flag.result.variant in the event from variationKey', () => {
const span = makeMockSpan();
setLdSpanAttributes(span as any, { __ld: { ...ldFixture, variationKey: 'var-a' } });
expect(span.addEvent).toHaveBeenCalledWith(
'feature_flag',
expect.objectContaining({ 'feature_flag.result.variant': 'var-a' }),
);
});

it('omits feature_flag.result.variant when variationKey is empty', () => {
const span = makeMockSpan();
setLdSpanAttributes(span as any, { __ld: { ...ldFixture, variationKey: '', graphKey: 'my-graph' } });
const eventCall = span.addEvent.mock.calls.find((c: any[]) => c[0] === 'feature_flag');
expect(eventCall?.[1]).not.toHaveProperty('feature_flag.result.variant');
});

const rawVariation = {
model: { name: 'gpt-x', parameters: { temperature: 0.2 } },
provider: { name: 'openai' },
messages: [{ role: 'system', content: 'Be brief.' }],
_ldMeta: { enabled: true, variationKey: 'var-a', version: 3 },
};

it('writes the raw variation JSON as feature_flag.result.value when __ldValue is present', () => {
const span = makeMockSpan();
setLdSpanAttributes(span as any, { __ld: { ...ldFixture, variationKey: 'var-a' }, __ldValue: rawVariation });
// Same serialization as the LaunchDarkly OTel TracingHook with includeValue: JSON.stringify(detail.value).
expect(featureFlagAttrs(span)['feature_flag.result.value']).toBe(JSON.stringify(rawVariation));
});

it('writes the variation key JSON as feature_flag.result.value when there is no raw variation', () => {
const span = makeMockSpan();
setLdSpanAttributes(span as any, { __ld: { ...ldFixture, variationKey: 'opus' } });
expect(featureFlagAttrs(span)['feature_flag.result.value']).toBe('"opus"');
});

it('omits feature_flag.result.value when there is no variation key and no raw variation (graph spans)', () => {
const span = makeMockSpan();
setLdSpanAttributes(span as any, { __ld: { ...ldFixture, variationKey: '', graphKey: 'my-graph' } });
expect(featureFlagAttrs(span)).not.toHaveProperty('feature_flag.result.value');
});
});

// ─── setLdSpanAttributes — context identity ───────────────────────────────────
Expand Down
Loading
Loading