070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
2196 lines
70 KiB
TypeScript
2196 lines
70 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import {
|
||
buildFeedbackPayload,
|
||
buildTracePayload,
|
||
deriveLangfuseDeliveryState,
|
||
readLangfuseConfig,
|
||
readTelemetrySinkConfig,
|
||
reportRunCompleted,
|
||
reportRunFeedback,
|
||
type FeedbackReportContext,
|
||
type LangfuseConfig,
|
||
type ReportContext,
|
||
type TelemetrySinkConfig,
|
||
} from '../src/langfuse-trace.js';
|
||
import { buildPromptStackTelemetry } from '../src/prompt-telemetry.js';
|
||
|
||
function makeCtx(overrides: Partial<ReportContext> = {}): ReportContext {
|
||
const base: ReportContext = {
|
||
installationId: 'install-uuid-1',
|
||
projectId: 'proj-1',
|
||
conversationId: 'conv-uuid-aaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||
agentId: 'claude',
|
||
run: {
|
||
runId: 'run-1',
|
||
status: 'succeeded',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_004_500,
|
||
},
|
||
message: {
|
||
messageId: 'msg-1',
|
||
prompt: 'Make a landing page for a coffee shop.',
|
||
output: 'Here is a landing page draft …',
|
||
usage: {
|
||
inputTokens: 1234,
|
||
inputTokensProvider: 1234,
|
||
inputTokensEffective: 1484,
|
||
outputTokens: 567,
|
||
totalTokens: 2051,
|
||
cacheReadInputTokens: 200,
|
||
cacheCreationInputTokens: 50,
|
||
uncachedInputTokens: 1234,
|
||
estimatedContextTokens: 1350,
|
||
cacheHitRatio: 0.1347708894878706,
|
||
cacheTokenSource: 'anthropic',
|
||
},
|
||
},
|
||
artifacts: [],
|
||
tools: [
|
||
{
|
||
id: 'tool-1',
|
||
name: 'Bash',
|
||
startedAt: 1_700_000_001_000,
|
||
endedAt: 1_700_000_001_800,
|
||
input: '{"command":"ls -la"}',
|
||
output: 'total 0',
|
||
},
|
||
{
|
||
id: 'tool-2',
|
||
name: 'Write',
|
||
startedAt: 1_700_000_002_000,
|
||
endedAt: 1_700_000_002_900,
|
||
input: '{"path":"index.html"}',
|
||
output: 'wrote index.html',
|
||
},
|
||
],
|
||
eventsSummary: { toolCalls: 2, errors: 0, durationMs: 4500 },
|
||
prefs: { metrics: true, content: false, artifactManifest: false },
|
||
};
|
||
return { ...base, ...overrides };
|
||
}
|
||
|
||
const TEST_CONFIG: LangfuseConfig = {
|
||
authHeader: 'Basic dGVzdA==',
|
||
baseUrl: 'https://us.cloud.langfuse.com',
|
||
timeoutMs: 20_000,
|
||
retries: 0,
|
||
};
|
||
|
||
function bodyOf(
|
||
batch: unknown[],
|
||
type: string,
|
||
name?: string,
|
||
): Record<string, any> {
|
||
const event = (batch as Array<{ type: string; body: Record<string, any> }>).find(
|
||
(item) => item.type === type && (name === undefined || item.body.name === name),
|
||
);
|
||
expect(event).toBeTruthy();
|
||
return event!.body;
|
||
}
|
||
|
||
describe('readLangfuseConfig', () => {
|
||
it('returns null when keys are missing', () => {
|
||
expect(readLangfuseConfig({})).toBeNull();
|
||
expect(readLangfuseConfig({ LANGFUSE_PUBLIC_KEY: 'pk' })).toBeNull();
|
||
expect(readLangfuseConfig({ LANGFUSE_SECRET_KEY: 'sk' })).toBeNull();
|
||
});
|
||
|
||
it('returns null when keys are whitespace-only', () => {
|
||
expect(
|
||
readLangfuseConfig({
|
||
LANGFUSE_PUBLIC_KEY: ' ',
|
||
LANGFUSE_SECRET_KEY: 'sk',
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('builds Basic auth header from public:secret', () => {
|
||
const cfg = readLangfuseConfig({
|
||
LANGFUSE_PUBLIC_KEY: 'pk-lf-abc',
|
||
LANGFUSE_SECRET_KEY: 'sk-lf-xyz',
|
||
});
|
||
expect(cfg).not.toBeNull();
|
||
const expected =
|
||
'Basic ' + Buffer.from('pk-lf-abc:sk-lf-xyz').toString('base64');
|
||
expect(cfg!.authHeader).toBe(expected);
|
||
});
|
||
|
||
it('uses default US base URL when LANGFUSE_BASE_URL is absent', () => {
|
||
const cfg = readLangfuseConfig({
|
||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||
LANGFUSE_SECRET_KEY: 'sk',
|
||
});
|
||
expect(cfg!.baseUrl).toBe('https://us.cloud.langfuse.com');
|
||
});
|
||
|
||
it('honours LANGFUSE_BASE_URL and strips trailing slashes', () => {
|
||
const cfg = readLangfuseConfig({
|
||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||
LANGFUSE_SECRET_KEY: 'sk',
|
||
LANGFUSE_BASE_URL: 'https://cloud.langfuse.com//',
|
||
});
|
||
expect(cfg!.baseUrl).toBe('https://cloud.langfuse.com');
|
||
});
|
||
|
||
it('reads optional timeout and retry tuning from env', () => {
|
||
const cfg = readLangfuseConfig({
|
||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||
LANGFUSE_SECRET_KEY: 'sk',
|
||
LANGFUSE_TIMEOUT_MS: '45000',
|
||
LANGFUSE_RETRIES: '2',
|
||
});
|
||
expect(cfg!.timeoutMs).toBe(45_000);
|
||
expect(cfg!.retries).toBe(2);
|
||
});
|
||
|
||
it('falls back when timeout and retry env values are invalid', () => {
|
||
const cfg = readLangfuseConfig({
|
||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||
LANGFUSE_SECRET_KEY: 'sk',
|
||
LANGFUSE_TIMEOUT_MS: '-1',
|
||
LANGFUSE_RETRIES: '-2',
|
||
});
|
||
expect(cfg!.timeoutMs).toBe(20_000);
|
||
expect(cfg!.retries).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('readTelemetrySinkConfig', () => {
|
||
it('prefers the Open Design telemetry relay when configured', () => {
|
||
const cfg = readTelemetrySinkConfig({
|
||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://telemetry.open-design.ai/api/langfuse//',
|
||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||
LANGFUSE_SECRET_KEY: 'sk',
|
||
});
|
||
expect(cfg).toEqual({
|
||
kind: 'relay',
|
||
relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
|
||
timeoutMs: 20_000,
|
||
retries: 1,
|
||
});
|
||
});
|
||
|
||
it('uses relay-specific timeout and retry tuning when present', () => {
|
||
const cfg = readTelemetrySinkConfig({
|
||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://telemetry.open-design.ai/api/langfuse',
|
||
OPEN_DESIGN_TELEMETRY_TIMEOUT_MS: '30000',
|
||
OPEN_DESIGN_TELEMETRY_RETRIES: '3',
|
||
LANGFUSE_TIMEOUT_MS: '1',
|
||
LANGFUSE_RETRIES: '0',
|
||
});
|
||
expect(cfg).toMatchObject({
|
||
kind: 'relay',
|
||
timeoutMs: 30_000,
|
||
retries: 3,
|
||
});
|
||
});
|
||
|
||
it('falls back to direct Langfuse config for local smoke tests', () => {
|
||
const cfg = readTelemetrySinkConfig({
|
||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||
LANGFUSE_SECRET_KEY: 'sk',
|
||
});
|
||
expect(cfg).toMatchObject({
|
||
kind: 'langfuse',
|
||
baseUrl: 'https://us.cloud.langfuse.com',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('deriveLangfuseDeliveryState', () => {
|
||
it('marks traces not expected when metrics consent is off', () => {
|
||
expect(
|
||
deriveLangfuseDeliveryState(
|
||
{ metrics: false, content: true, artifactManifest: true },
|
||
{ kind: 'langfuse', ...TEST_CONFIG },
|
||
),
|
||
).toEqual({
|
||
langfuse_expected: false,
|
||
langfuse_delivery_status: 'not_expected',
|
||
langfuse_drop_reason: 'metrics_consent_off',
|
||
});
|
||
});
|
||
|
||
it('marks traces not expected when content consent is off', () => {
|
||
expect(
|
||
deriveLangfuseDeliveryState(
|
||
{ metrics: true, content: false, artifactManifest: true },
|
||
{ kind: 'langfuse', ...TEST_CONFIG },
|
||
),
|
||
).toEqual({
|
||
langfuse_expected: false,
|
||
langfuse_delivery_status: 'not_expected',
|
||
langfuse_drop_reason: 'content_consent_off',
|
||
});
|
||
});
|
||
|
||
it('marks traces not expected when no sink is configured', () => {
|
||
expect(
|
||
deriveLangfuseDeliveryState(
|
||
{ metrics: true, content: true, artifactManifest: true },
|
||
null,
|
||
),
|
||
).toEqual({
|
||
langfuse_expected: false,
|
||
langfuse_delivery_status: 'not_expected',
|
||
langfuse_drop_reason: 'missing_sink_config',
|
||
});
|
||
});
|
||
|
||
it('marks eligible traces as queued at run-finished time', () => {
|
||
expect(
|
||
deriveLangfuseDeliveryState(
|
||
{ metrics: true, content: true, artifactManifest: true },
|
||
{ kind: 'langfuse', ...TEST_CONFIG },
|
||
),
|
||
).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'queued',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('buildTracePayload', () => {
|
||
it('emits a trace with nested agent + generation observations', () => {
|
||
const batch = buildTracePayload(makeCtx());
|
||
const types = (batch as Array<{ type: string }>).map((e) => e.type);
|
||
expect(types).toEqual([
|
||
'trace-create',
|
||
'span-create',
|
||
'generation-create',
|
||
'span-create',
|
||
'span-create',
|
||
]);
|
||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||
const bash = bodyOf(batch, 'span-create', 'tool:Bash');
|
||
const write = bodyOf(batch, 'span-create', 'tool:Write');
|
||
expect(span.id).toBe('run-1-agent');
|
||
expect(span.traceId).toBe('run-1');
|
||
expect(gen.traceId).toBe('run-1');
|
||
expect(gen.parentObservationId).toBe('run-1-agent');
|
||
expect(bash.parentObservationId).toBe('run-1-agent');
|
||
expect(bash.input).toBeUndefined();
|
||
expect(bash.output).toBeUndefined();
|
||
expect(bash.metadata.toolName).toBe('Bash');
|
||
expect(write.parentObservationId).toBe('run-1-agent');
|
||
});
|
||
|
||
it('omits prompt + output when content gate is off', () => {
|
||
const batch = buildTracePayload(makeCtx());
|
||
const trace = (batch[0] as any).body;
|
||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||
const tool = bodyOf(batch, 'span-create', 'tool:Bash');
|
||
expect(trace.input).toBeUndefined();
|
||
expect(trace.output).toBeUndefined();
|
||
expect(span.input).toBeUndefined();
|
||
expect(span.output).toBeUndefined();
|
||
expect(gen.input).toBeUndefined();
|
||
expect(gen.output).toBeUndefined();
|
||
expect(tool.input).toBeUndefined();
|
||
expect(tool.output).toBeUndefined();
|
||
});
|
||
|
||
it('includes prompt + output when content gate is on', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
message: {
|
||
messageId: 'msg-1',
|
||
prompt: 'Make a landing page for a coffee shop.',
|
||
output:
|
||
'Built it.\n<artifact identifier="demo" type="text/html"><!doctype html><html>heavy</html></artifact>',
|
||
},
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
const tool = bodyOf(batch, 'span-create', 'tool:Bash');
|
||
const write = bodyOf(batch, 'span-create', 'tool:Write');
|
||
expect(trace.input).toMatch(/coffee shop/);
|
||
expect(trace.output).toContain('[REDACTED:artifact_content]');
|
||
expect(trace.output).not.toContain('<!doctype html>');
|
||
expect(tool.input).toMatch(/ls -la/);
|
||
expect(tool.output).toBe('total 0');
|
||
expect(write.input).toBe('[REDACTED:tool_input:content_tool:Write]');
|
||
expect(write.output).toBe('[REDACTED:tool_output:content_tool:Write]');
|
||
});
|
||
|
||
it('adds full prompt-stack content once on generation input and flat metadata elsewhere', () => {
|
||
const promptTelemetry = buildPromptStackTelemetry({
|
||
composedPrompt:
|
||
'# Instructions\n\nWork in /Users/alice/project\n\n---\n# User request\n\nBuild a card',
|
||
sections: [
|
||
{ kind: 'daemonSystemPrompt', content: 'Work in /Users/alice/project' },
|
||
{ kind: 'userRequest', content: 'Build a card' },
|
||
{ kind: 'attachments', metadata: ['src/App.tsx'] },
|
||
],
|
||
});
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
promptTelemetry,
|
||
}),
|
||
);
|
||
|
||
const trace = bodyOf(batch, 'trace-create');
|
||
const generation = bodyOf(batch, 'generation-create', 'llm');
|
||
expect(trace.input).toBe('Make a landing page for a coffee shop.');
|
||
expect(generation.input).toMatchObject({
|
||
type: 'open-design.prompt-stack',
|
||
redactionVersion: 'prompt-stack-redaction-v1',
|
||
sectionCount: 3,
|
||
sections: [
|
||
expect.objectContaining({
|
||
kind: 'daemonSystemPrompt',
|
||
redactedContent: expect.stringContaining('[REDACTED:path]'),
|
||
}),
|
||
expect.objectContaining({
|
||
kind: 'userRequest',
|
||
redactedContent: 'Build a card',
|
||
}),
|
||
expect.objectContaining({
|
||
kind: 'attachments',
|
||
contentMode: 'metadata-only',
|
||
}),
|
||
],
|
||
});
|
||
expect(trace.metadata.promptStack).toBeUndefined();
|
||
expect(generation.metadata.promptStack).toBeUndefined();
|
||
expect(trace.metadata.promptStack_section_daemonSystemPrompt_present).toBeUndefined();
|
||
expect(trace.metadata.promptStack_section_attachments_present).toBeUndefined();
|
||
expect(trace.metadata.promptStack_section_daemonSystemPrompt_rawBytes).toBeUndefined();
|
||
expect(trace.metadata.promptStack_promptFingerprint).toMatch(/^sha256:/);
|
||
expect(generation.metadata.promptStack_promptFingerprint).toBe(
|
||
trace.metadata.promptStack_promptFingerprint,
|
||
);
|
||
});
|
||
|
||
it('adds prompt-stack byte and cache-token blame metadata for TTFT diagnosis', () => {
|
||
const promptTelemetry = buildPromptStackTelemetry({
|
||
composedPrompt: ['a'.repeat(1000), 'b'.repeat(500), 'c'.repeat(100)].join('\n'),
|
||
sections: [
|
||
{ kind: 'daemonSystemPrompt', content: 'a'.repeat(1000) },
|
||
{ kind: 'pluginStagePrompt', content: 'b'.repeat(500) },
|
||
{ kind: 'userRequest', content: 'c'.repeat(100) },
|
||
],
|
||
});
|
||
const ctx = makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
promptTelemetry,
|
||
});
|
||
ctx.run = {
|
||
...ctx.run,
|
||
timings: {
|
||
tool_call_count: 0,
|
||
total_duration_ms: 89_162,
|
||
time_to_first_token_ms: 26_613,
|
||
spawn_to_first_token_ms: 26_491,
|
||
},
|
||
};
|
||
|
||
const batch = buildTracePayload(ctx);
|
||
const trace = bodyOf(batch, 'trace-create');
|
||
const generation = bodyOf(batch, 'generation-create', 'llm');
|
||
|
||
expect(trace.metadata.promptStack_topSectionsByBytes).toEqual([
|
||
expect.objectContaining({
|
||
kind: 'daemonSystemPrompt',
|
||
rawBytes: 1000,
|
||
redactedBytes: 1000,
|
||
attributionBytes: 1000,
|
||
attributionShare: 0.625,
|
||
estimatedInputEffectiveTokens: 929,
|
||
estimatedCacheCreationInputTokens: 32,
|
||
estimatedCacheReadInputTokens: 126,
|
||
estimatedUncachedInputTokens: 772,
|
||
}),
|
||
expect.objectContaining({
|
||
kind: 'pluginStagePrompt',
|
||
rawBytes: 500,
|
||
attributionShare: 0.3125,
|
||
estimatedCacheCreationInputTokens: 15,
|
||
}),
|
||
expect.objectContaining({
|
||
kind: 'userRequest',
|
||
rawBytes: 100,
|
||
attributionShare: 0.0625,
|
||
estimatedCacheCreationInputTokens: 3,
|
||
}),
|
||
]);
|
||
expect(trace.metadata.cacheCreationTokensBySection).toEqual([
|
||
{
|
||
kind: 'daemonSystemPrompt',
|
||
ordinal: 0,
|
||
attributionBytes: 1000,
|
||
estimatedCacheCreationInputTokens: 32,
|
||
},
|
||
{
|
||
kind: 'pluginStagePrompt',
|
||
ordinal: 1,
|
||
attributionBytes: 500,
|
||
estimatedCacheCreationInputTokens: 15,
|
||
},
|
||
{
|
||
kind: 'userRequest',
|
||
ordinal: 2,
|
||
attributionBytes: 100,
|
||
estimatedCacheCreationInputTokens: 3,
|
||
},
|
||
]);
|
||
expect(trace.metadata.promptStack_ttftAttribution).toMatchObject({
|
||
method: 'proportional_by_prompt_section_redacted_bytes',
|
||
time_to_first_token_ms: 26_613,
|
||
spawn_to_first_token_ms: 26_491,
|
||
totalAttributionBytes: 1600,
|
||
sectionCount: 3,
|
||
primarySectionKind: 'daemonSystemPrompt',
|
||
primarySectionAttributionShare: 0.625,
|
||
primarySectionEstimatedCacheCreationInputTokens: 32,
|
||
cacheTokenSource: 'anthropic',
|
||
});
|
||
expect(generation.metadata.promptStack_topSectionsByBytes).toEqual(
|
||
trace.metadata.promptStack_topSectionsByBytes,
|
||
);
|
||
expect(generation.metadata.promptStack_ttftAttribution).toEqual(
|
||
trace.metadata.promptStack_ttftAttribution,
|
||
);
|
||
});
|
||
|
||
it('omits prompt-stack redactedContent when metrics or content consent is off', () => {
|
||
const promptTelemetry = buildPromptStackTelemetry({
|
||
composedPrompt: '# User request\n\nBuild a card',
|
||
sections: [{ kind: 'userRequest', content: 'Build a card' }],
|
||
});
|
||
|
||
for (const prefs of [
|
||
{ metrics: true, content: false, artifactManifest: false },
|
||
{ metrics: false, content: true, artifactManifest: false },
|
||
]) {
|
||
const batch = buildTracePayload(makeCtx({ prefs, promptTelemetry }));
|
||
const trace = bodyOf(batch, 'trace-create');
|
||
const generation = bodyOf(batch, 'generation-create', 'llm');
|
||
expect(trace.input).toBeUndefined();
|
||
expect(trace.metadata.promptStack).toBeUndefined();
|
||
expect(trace.metadata.promptStack_redactedContentBytes).toBe(0);
|
||
expect(generation.input).toMatchObject({
|
||
type: 'open-design.prompt-stack',
|
||
redactedContentBytes: 0,
|
||
sections: [expect.not.objectContaining({ redactedContent: expect.any(String) })],
|
||
});
|
||
}
|
||
});
|
||
|
||
it('truncates ASCII prompt and output at 64 KB (bytes == chars)', () => {
|
||
const longPrompt = 'a'.repeat(80_000);
|
||
const longOutput = 'b'.repeat(80_000);
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
message: {
|
||
messageId: 'msg-1',
|
||
prompt: longPrompt,
|
||
output: longOutput,
|
||
},
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
expect(Buffer.byteLength(trace.input, 'utf8')).toBe(64 * 1024);
|
||
expect(Buffer.byteLength(trace.output, 'utf8')).toBe(64 * 1024);
|
||
});
|
||
|
||
it('truncates by UTF-8 bytes, not by JS string length, for multi-byte text', () => {
|
||
// Each CJK character is 3 bytes in UTF-8 but 1 unit in String.length.
|
||
// 30_000 chars × 3 bytes = 90_000 bytes, well over the 64 KB input cap.
|
||
const longCJK = '设'.repeat(30_000);
|
||
expect(longCJK.length).toBe(30_000);
|
||
expect(Buffer.byteLength(longCJK, 'utf8')).toBe(90_000);
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
message: { messageId: 'msg-1', prompt: longCJK, output: '' },
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
expect(Buffer.byteLength(trace.input, 'utf8')).toBeLessThanOrEqual(64 * 1024);
|
||
// Boundary safety: the trimmed result must still be valid UTF-8 (no
|
||
// half-encoded characters). Round-tripping through Buffer should be
|
||
// lossless if the cut landed correctly.
|
||
expect(Buffer.from(trace.input as string, 'utf8').toString('utf8')).toBe(
|
||
trace.input,
|
||
);
|
||
// And every character is still '设', i.e. we didn't mangle the encoding.
|
||
expect(/^设+$/.test(trace.input as string)).toBe(true);
|
||
});
|
||
|
||
it('omits artifacts when manifest gate is off', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
artifacts: [
|
||
{ slug: 'a', type: 'html', sizeBytes: 100 },
|
||
{ slug: 'b', type: 'jsx', sizeBytes: 200 },
|
||
],
|
||
attachmentManifest: [
|
||
{
|
||
attachment_id: 'att-1',
|
||
object_class: 'attachment',
|
||
storage_ref: 'od://objects/workspaces/unknown/projects/proj-1/runs/run-1/attachment/att-1',
|
||
status: 'ok',
|
||
project_id: 'proj-1',
|
||
run_id: 'run-1',
|
||
workspace_id: null,
|
||
size_bytes: 100,
|
||
redacted: false,
|
||
truncated: false,
|
||
stored_in_open_design: true,
|
||
retention_policy: 'project_lifetime',
|
||
access_scope: 'project',
|
||
sensitivity: 'private',
|
||
source: 'user_upload',
|
||
expires_at: null,
|
||
approved_by: null,
|
||
},
|
||
],
|
||
artifactManifest: [
|
||
{
|
||
artifact_id: 'art-1',
|
||
object_class: 'artifact',
|
||
type: 'html',
|
||
storage_ref: 'od://objects/workspaces/unknown/projects/proj-1/runs/run-1/artifact/art-1',
|
||
status: 'ok',
|
||
project_id: 'proj-1',
|
||
run_id: 'run-1',
|
||
workspace_id: null,
|
||
size_bytes: 200,
|
||
redacted: false,
|
||
truncated: false,
|
||
stored_in_open_design: true,
|
||
retention_policy: 'project_lifetime',
|
||
access_scope: 'project',
|
||
sensitivity: 'private',
|
||
source: 'agent_generated',
|
||
expires_at: null,
|
||
approved_by: null,
|
||
},
|
||
],
|
||
manifestCompleteness: 'complete',
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
expect(trace.metadata.artifacts).toBeUndefined();
|
||
expect(trace.metadata.artifactsTruncated).toBeUndefined();
|
||
expect(trace.metadata.attachment_manifest).toBeUndefined();
|
||
expect(trace.metadata.artifact_manifest).toBeUndefined();
|
||
expect(trace.metadata.manifest_completeness).toBeUndefined();
|
||
});
|
||
|
||
it('includes trace-safe object manifests when content telemetry is on', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true },
|
||
attachmentManifest: [
|
||
{
|
||
attachment_id: 'att-1',
|
||
object_class: 'attachment',
|
||
storage_ref: 'od://objects/workspaces/unknown/projects/proj-1/runs/run-1/attachment/att-1',
|
||
status: 'ok',
|
||
project_id: 'proj-1',
|
||
run_id: 'run-1',
|
||
workspace_id: null,
|
||
size_bytes: 1024,
|
||
sha256: 'sha256:abc',
|
||
mime_type: 'application/pdf',
|
||
extension: 'pdf',
|
||
redacted: false,
|
||
truncated: false,
|
||
stored_in_open_design: true,
|
||
retention_policy: 'project_lifetime',
|
||
access_scope: 'project',
|
||
sensitivity: 'private',
|
||
source: 'user_upload',
|
||
expires_at: null,
|
||
approved_by: null,
|
||
},
|
||
],
|
||
artifactManifest: [
|
||
{
|
||
artifact_id: 'art-1',
|
||
object_class: 'artifact',
|
||
type: 'html',
|
||
storage_ref: 'od://objects/workspaces/unknown/projects/proj-1/runs/run-1/artifact/art-1',
|
||
status: 'partial',
|
||
reason: 'size_unavailable',
|
||
project_id: 'proj-1',
|
||
run_id: 'run-1',
|
||
workspace_id: null,
|
||
build_status: 'complete',
|
||
preview_status: 'unavailable',
|
||
export_status: 'available',
|
||
redacted: false,
|
||
truncated: false,
|
||
stored_in_open_design: true,
|
||
retention_policy: 'project_lifetime',
|
||
access_scope: 'project',
|
||
sensitivity: 'private',
|
||
source: 'agent_generated',
|
||
expires_at: null,
|
||
approved_by: null,
|
||
},
|
||
],
|
||
manifestCompleteness: 'partial',
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
expect(trace.metadata.attachment_manifest).toEqual([
|
||
expect.objectContaining({
|
||
attachment_id: 'att-1',
|
||
object_class: 'attachment',
|
||
storage_ref: expect.stringContaining('/attachment/att-1'),
|
||
size_bytes: 1024,
|
||
sha256: 'sha256:abc',
|
||
retention_policy: 'project_lifetime',
|
||
access_scope: 'project',
|
||
sensitivity: 'private',
|
||
source: 'user_upload',
|
||
}),
|
||
]);
|
||
expect(trace.metadata.artifact_manifest).toEqual([
|
||
expect.objectContaining({
|
||
artifact_id: 'art-1',
|
||
object_class: 'artifact',
|
||
type: 'html',
|
||
storage_ref: expect.stringContaining('/artifact/art-1'),
|
||
status: 'partial',
|
||
reason: 'size_unavailable',
|
||
build_status: 'complete',
|
||
export_status: 'available',
|
||
source: 'agent_generated',
|
||
}),
|
||
]);
|
||
expect(trace.metadata.manifest_completeness).toBe('partial');
|
||
});
|
||
|
||
it('caps artifacts at 50 entries with a truncation flag', () => {
|
||
const many = Array.from({ length: 75 }, (_, i) => ({
|
||
slug: `art-${i}`,
|
||
type: 'html',
|
||
sizeBytes: 1,
|
||
}));
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
artifacts: many,
|
||
prefs: { metrics: true, content: true },
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
expect(trace.metadata.artifacts).toHaveLength(50);
|
||
expect(trace.metadata.artifactsTruncated).toBe(true);
|
||
});
|
||
|
||
it('caps artifact manifests at 50 entries with a truncation flag', () => {
|
||
const many = Array.from({ length: 75 }, (_, i) => ({
|
||
artifact_id: `art-${i}`,
|
||
object_class: 'artifact' as const,
|
||
type: 'html',
|
||
storage_ref: `od://objects/workspaces/unknown/projects/proj-1/runs/run-1/artifact/art-${i}`,
|
||
status: 'ok' as const,
|
||
project_id: 'proj-1',
|
||
run_id: 'run-1',
|
||
workspace_id: null,
|
||
size_bytes: 1,
|
||
redacted: false,
|
||
truncated: false,
|
||
stored_in_open_design: true,
|
||
retention_policy: 'project_lifetime' as const,
|
||
access_scope: 'project' as const,
|
||
sensitivity: 'private' as const,
|
||
source: 'agent_generated' as const,
|
||
expires_at: null,
|
||
approved_by: null,
|
||
}));
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
artifactManifest: many,
|
||
manifestCompleteness: 'complete',
|
||
prefs: { metrics: true, content: true },
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
expect(trace.metadata.artifact_manifest).toHaveLength(50);
|
||
expect(trace.metadata.artifact_manifest_truncated).toBe(true);
|
||
});
|
||
|
||
it('caps attachment manifests and prompt-build refs at 50 entries', () => {
|
||
const many = Array.from({ length: 75 }, (_, i) => ({
|
||
attachment_id: `att-${i}`,
|
||
object_class: 'attachment' as const,
|
||
storage_ref: `od://objects/workspaces/unknown/projects/proj-1/runs/run-1/attachment/att-${i}`,
|
||
status: 'ok' as const,
|
||
project_id: 'proj-1',
|
||
run_id: 'run-1',
|
||
workspace_id: null,
|
||
size_bytes: i + 1,
|
||
sha256: `sha256:att-${i}`,
|
||
mime_type: 'application/pdf',
|
||
extension: 'pdf',
|
||
redacted: false,
|
||
truncated: false,
|
||
stored_in_open_design: true,
|
||
retention_policy: 'project_lifetime' as const,
|
||
access_scope: 'project' as const,
|
||
sensitivity: 'private' as const,
|
||
source: 'user_upload' as const,
|
||
expires_at: null,
|
||
approved_by: null,
|
||
}));
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
attachmentManifest: many,
|
||
manifestCompleteness: 'complete',
|
||
prefs: { metrics: true, content: true },
|
||
run: {
|
||
runId: 'run-1',
|
||
status: 'succeeded',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_004_500,
|
||
timingMarks: {
|
||
promptBuildStartAt: 1_700_000_000_100,
|
||
promptBuildEndAt: 1_700_000_000_200,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
const trace = (batch[0] as any).body;
|
||
const promptBuild = bodyOf(batch, 'span-create', 'prompt-build');
|
||
|
||
expect(trace.metadata.attachment_manifest).toHaveLength(50);
|
||
expect(trace.metadata.attachment_manifest_truncated).toBe(true);
|
||
expect(promptBuild.input.ingredients.attachment_refs).toHaveLength(50);
|
||
expect(promptBuild.input.ingredients.attachment_refs_truncated).toBe(true);
|
||
expect(promptBuild.input.ingredients.attachment_refs.at(-1)).toMatchObject({
|
||
attachment_id: 'att-49',
|
||
sha256: 'sha256:att-49',
|
||
});
|
||
});
|
||
|
||
it('keeps eventsSummary metadata regardless of content / artifact gates', () => {
|
||
const batch = buildTracePayload(makeCtx());
|
||
const trace = (batch[0] as any).body;
|
||
expect(trace.metadata.eventsSummary).toEqual({
|
||
toolCalls: 2,
|
||
errors: 0,
|
||
durationMs: 4500,
|
||
});
|
||
});
|
||
|
||
it('records token counts in metadata.tokens and generation.usage', () => {
|
||
const batch = buildTracePayload(makeCtx());
|
||
const trace = (batch[0] as any).body;
|
||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||
expect(trace.metadata.tokens).toEqual({
|
||
input: 1234,
|
||
inputProvider: 1234,
|
||
inputEffective: 1484,
|
||
output: 567,
|
||
total: 2051,
|
||
cacheReadInput: 200,
|
||
cacheCreationInput: 50,
|
||
uncachedInput: 1234,
|
||
estimatedContext: 1350,
|
||
cacheHitRatio: 0.1347708894878706,
|
||
cacheTokenSource: 'anthropic',
|
||
});
|
||
expect(gen.usage).toEqual({
|
||
input: 1484,
|
||
output: 567,
|
||
total: 2051,
|
||
unit: 'TOKENS',
|
||
});
|
||
});
|
||
|
||
it('uses conversationId as sessionId when within length limit', () => {
|
||
const batch = buildTracePayload(makeCtx());
|
||
expect((batch[0] as any).body.sessionId).toBe(
|
||
'conv-uuid-aaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||
);
|
||
});
|
||
|
||
it('drops sessionId when conversationId exceeds 200 chars', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({ conversationId: 'x'.repeat(201) }),
|
||
);
|
||
expect((batch[0] as any).body.sessionId).toBeUndefined();
|
||
});
|
||
|
||
it('builds tag list with project + agent + extras', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({ extraTags: ['legacy:tag'] }),
|
||
);
|
||
expect((batch[0] as any).body.tags).toEqual([
|
||
'open-design',
|
||
'project:proj-1',
|
||
'agent:claude',
|
||
'legacy:tag',
|
||
]);
|
||
});
|
||
|
||
it('adds turn-level tags (model / skill / DS) and runtime tags (os / client)', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
turn: {
|
||
model: 'gpt-4o',
|
||
reasoning: 'high',
|
||
skillId: 'landing-page',
|
||
designSystemId: 'mission-control',
|
||
},
|
||
runtime: {
|
||
os: 'darwin',
|
||
arch: 'arm64',
|
||
nodeVersion: 'v22.22.0',
|
||
appVersion: '0.5.0',
|
||
clientType: 'desktop',
|
||
},
|
||
}),
|
||
);
|
||
expect((batch[0] as any).body.tags).toEqual([
|
||
'open-design',
|
||
'project:proj-1',
|
||
'agent:claude',
|
||
'model:gpt-4o',
|
||
'skill:landing-page',
|
||
'ds:mission-control',
|
||
'os:darwin',
|
||
'client:desktop',
|
||
]);
|
||
});
|
||
|
||
it('promotes model + reasoning to first-class generation fields', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
turn: { model: 'claude-sonnet-4-5', reasoning: 'high' },
|
||
}),
|
||
);
|
||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||
expect(gen.model).toBe('claude-sonnet-4-5');
|
||
expect(gen.modelParameters).toEqual({ reasoning: 'high' });
|
||
});
|
||
|
||
it('omits modelParameters entirely when reasoning is unset', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({ turn: { model: 'gpt-4o' } }),
|
||
);
|
||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||
expect(gen.model).toBe('gpt-4o');
|
||
expect(gen.modelParameters).toBeUndefined();
|
||
});
|
||
|
||
it('mirrors runtime + turn fields into trace metadata for query / export', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
turn: { model: 'claude-sonnet-4-5', skillId: 'landing-page' },
|
||
runtime: {
|
||
os: 'linux',
|
||
arch: 'x64',
|
||
nodeVersion: 'v22.22.0',
|
||
appVersion: '0.5.0',
|
||
appChannel: 'beta',
|
||
packaged: true,
|
||
clientType: 'web',
|
||
},
|
||
}),
|
||
);
|
||
const m = (batch[0] as any).body.metadata;
|
||
expect(m.model).toBe('claude-sonnet-4-5');
|
||
expect(m.skillId).toBe('landing-page');
|
||
expect(m.os).toBe('linux');
|
||
expect(m.arch).toBe('x64');
|
||
expect(m.nodeVersion).toBe('v22.22.0');
|
||
expect(m.appVersion).toBe('0.5.0');
|
||
expect(m.appChannel).toBe('beta');
|
||
expect(m.packaged).toBe(true);
|
||
expect(m.clientType).toBe('web');
|
||
expect(m.projectId).toBe('proj-1');
|
||
expect(m.agent).toBe('claude');
|
||
});
|
||
|
||
it('marks generation.level=ERROR when run failed', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
run: {
|
||
runId: 'run-1',
|
||
status: 'failed',
|
||
startedAt: 1,
|
||
endedAt: 2,
|
||
error: 'boom',
|
||
},
|
||
}),
|
||
);
|
||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||
expect(gen.level).toBe('ERROR');
|
||
expect(gen.statusMessage).toBe('boom');
|
||
expect(span.level).toBe('ERROR');
|
||
expect(span.statusMessage).toBe('boom');
|
||
expect(bodyOf(batch, 'event-create', 'run-error').statusMessage).toBe('boom');
|
||
expect((batch[0] as any).body.metadata.error).toBe('boom');
|
||
expect((batch[0] as any).body.metadata.success).toBe(false);
|
||
});
|
||
|
||
it('uses an agent-runtime span instead of an llm generation for session-init failures with no model usage', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
run: {
|
||
runId: 'run-auth',
|
||
status: 'failed',
|
||
startedAt: 1,
|
||
endedAt: 2,
|
||
error: 'Not logged in · Please run /login',
|
||
errorCode: 'AGENT_AUTH_REQUIRED',
|
||
failure: {
|
||
failure_category: 'auth',
|
||
failure_detail: 'auth_required',
|
||
failure_stage: 'session_init',
|
||
retryable: false,
|
||
user_action: 'login',
|
||
},
|
||
timingMarks: {
|
||
modelCallStartAt: 1,
|
||
},
|
||
},
|
||
message: {
|
||
messageId: 'msg-auth',
|
||
prompt: 'make an artifact',
|
||
output: 'Not logged in · Please run /login',
|
||
usage: {
|
||
inputTokens: 0,
|
||
inputTokensProvider: 0,
|
||
inputTokensEffective: 0,
|
||
outputTokens: 0,
|
||
totalTokens: 0,
|
||
cacheReadInputTokens: 0,
|
||
cacheCreationInputTokens: 0,
|
||
uncachedInputTokens: 0,
|
||
estimatedContextTokens: 0,
|
||
cacheTokenSource: 'anthropic',
|
||
},
|
||
},
|
||
tools: [],
|
||
eventsSummary: { toolCalls: 0, errors: 1, durationMs: 2 },
|
||
}),
|
||
);
|
||
expect(
|
||
(batch as Array<{ type: string; body: Record<string, any> }>).find(
|
||
(item) => item.type === 'generation-create' && item.body.name === 'llm',
|
||
),
|
||
).toBeUndefined();
|
||
const runtime = bodyOf(batch, 'span-create', 'agent-runtime');
|
||
expect(runtime.level).toBe('ERROR');
|
||
expect(runtime.statusMessage).toBe('Not logged in · Please run /login');
|
||
expect(runtime.metadata.reason).toBe('no_model_generation');
|
||
expect(bodyOf(batch, 'span-create', 'runtime-call').parentObservationId).toBe(
|
||
'run-auth-runtime',
|
||
);
|
||
const metadata = (batch[0] as any).body.metadata;
|
||
expect(metadata.status).toBe('failed');
|
||
expect(metadata.success).toBe(false);
|
||
expect(metadata.error_code).toBe('AGENT_AUTH_REQUIRED');
|
||
expect(metadata.failure_category).toBe('auth');
|
||
});
|
||
|
||
it('mirrors structured failure fields into trace metadata', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
run: {
|
||
runId: 'run-rate-limit',
|
||
status: 'failed',
|
||
startedAt: 1,
|
||
endedAt: 2,
|
||
error: 'session limit reached',
|
||
errorCode: 'RATE_LIMITED',
|
||
failure: {
|
||
failure_category: 'rate_limit',
|
||
failure_detail: 'hard_quota',
|
||
failure_stage: 'session_init',
|
||
retryable: false,
|
||
user_action: 'none',
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
const metadata = (batch[0] as any).body.metadata;
|
||
expect(metadata.error_code).toBe('RATE_LIMITED');
|
||
expect(metadata.langfuse_trace_id).toBe('run-rate-limit');
|
||
expect(metadata.langfuse_expected).toBe(false);
|
||
expect(metadata.langfuse_delivery_status).toBe('not_expected');
|
||
expect(metadata.langfuse_drop_reason).toBe('content_consent_off');
|
||
expect(metadata.failure_category).toBe('rate_limit');
|
||
expect(metadata.failure_detail).toBe('hard_quota');
|
||
expect(metadata.failure_stage).toBe('session_init');
|
||
expect(metadata.retryable).toBe(false);
|
||
expect(metadata.user_action).toBe('none');
|
||
});
|
||
|
||
it('mirrors run timing fields into trace metadata', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
run: {
|
||
runId: 'run-timing',
|
||
status: 'succeeded',
|
||
startedAt: 1,
|
||
endedAt: 2,
|
||
timings: {
|
||
queue_duration_ms: 10,
|
||
pre_spawn_duration_ms: 20,
|
||
process_spawn_duration_ms: 30,
|
||
time_to_first_token_ms: 40,
|
||
spawn_to_first_token_ms: 50,
|
||
generation_duration_ms: 60,
|
||
tool_call_count: 2,
|
||
tool_duration_ms: 70,
|
||
finalize_duration_ms: 5,
|
||
total_duration_ms: 100,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
const metadata = (batch[0] as any).body.metadata;
|
||
expect(metadata.queue_duration_ms).toBe(10);
|
||
expect(metadata.process_spawn_duration_ms).toBe(30);
|
||
expect(metadata.time_to_first_token_ms).toBe(40);
|
||
expect(metadata.tool_call_count).toBe(2);
|
||
expect(metadata.total_duration_ms).toBe(100);
|
||
});
|
||
|
||
it('adds duration spans for run timing marks', () => {
|
||
const promptTelemetry = buildPromptStackTelemetry({
|
||
composedPrompt:
|
||
'# System\n\nUse /Users/alice/project safely\n\n---\n# User request\n\nBuild the card\n\n---\n# Attachments\n\nbrand.pdf',
|
||
sections: [
|
||
{
|
||
kind: 'daemonSystemPrompt',
|
||
content: 'Use /Users/alice/project safely',
|
||
},
|
||
{ kind: 'userRequest', content: 'Build the card' },
|
||
{
|
||
kind: 'attachments',
|
||
metadata: [{ name: 'brand.pdf', size: 1024, mime: 'application/pdf' }],
|
||
},
|
||
],
|
||
});
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: true },
|
||
promptTelemetry,
|
||
attachmentManifest: [
|
||
{
|
||
attachment_id: 'att-1',
|
||
object_class: 'attachment',
|
||
storage_ref: 'od://objects/workspaces/unknown/projects/proj-1/runs/run-spans/attachment/att-1',
|
||
status: 'ok',
|
||
project_id: 'proj-1',
|
||
run_id: 'run-spans',
|
||
workspace_id: null,
|
||
size_bytes: 1024,
|
||
sha256: 'sha256:attachment',
|
||
mime_type: 'application/pdf',
|
||
extension: 'pdf',
|
||
redacted: false,
|
||
truncated: false,
|
||
stored_in_open_design: true,
|
||
retention_policy: 'project_lifetime',
|
||
access_scope: 'project',
|
||
sensitivity: 'private',
|
||
source: 'user_upload',
|
||
expires_at: null,
|
||
approved_by: null,
|
||
},
|
||
],
|
||
run: {
|
||
runId: 'run-spans',
|
||
status: 'succeeded',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_004_500,
|
||
timingMarks: {
|
||
startChatRunStartedAt: 1_700_000_000_100,
|
||
promptBuildStartAt: 1_700_000_000_200,
|
||
promptBuildEndAt: 1_700_000_000_260,
|
||
processSpawnStartedAt: 1_700_000_000_300,
|
||
processSpawnedAt: 1_700_000_000_380,
|
||
modelCallStartAt: 1_700_000_000_420,
|
||
firstTokenAt: 1_700_000_001_000,
|
||
finalizeStartAt: 1_700_000_004_200,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
const spans = (batch as any[])
|
||
.filter((item) => item.type === 'span-create')
|
||
.map((item) => item.body);
|
||
expect(spans.map((span) => span.name)).toEqual(
|
||
expect.arrayContaining([
|
||
'queue',
|
||
'prompt-build',
|
||
'spawn',
|
||
'agent-call',
|
||
'stream-output',
|
||
'finalize',
|
||
]),
|
||
);
|
||
expect(bodyOf(batch, 'span-create', 'prompt-build')).toMatchObject({
|
||
input: {
|
||
phase: 'prompt-build',
|
||
ingredients: {
|
||
agent: 'claude',
|
||
model: 'unknown',
|
||
skill_id: null,
|
||
design_system_id: null,
|
||
user_request_available: true,
|
||
attachment_refs: [
|
||
expect.objectContaining({
|
||
attachment_id: 'att-1',
|
||
storage_ref: expect.stringContaining('/attachment/att-1'),
|
||
sha256: 'sha256:attachment',
|
||
sensitivity: 'private',
|
||
}),
|
||
],
|
||
},
|
||
},
|
||
output: {
|
||
status: 'prompt_stack_ready',
|
||
content_policy: 'redacted_prompt_stack_on_generation_input_with_object_refs',
|
||
prompt_stack_available: true,
|
||
section_count: 3,
|
||
stack_fingerprint: expect.stringMatching(/^sha256:/),
|
||
},
|
||
});
|
||
expect(bodyOf(batch, 'span-create', 'prompt-build').input.prompt_stack).toBeUndefined();
|
||
expect(bodyOf(batch, 'span-create', 'prompt-build').output.prompt_stack).toBeUndefined();
|
||
expect(bodyOf(batch, 'span-create', 'spawn')).toMatchObject({
|
||
id: 'run-spans-phase-spawn',
|
||
parentObservationId: 'run-spans-gen',
|
||
input: {
|
||
phase: 'spawn',
|
||
agent: 'claude',
|
||
cwd_ref: 'project',
|
||
raw_path_included: false,
|
||
},
|
||
output: {
|
||
duration_ms: 80,
|
||
status: 'process_spawned',
|
||
},
|
||
metadata: {
|
||
durationMs: 80,
|
||
boundary: 'processSpawnStartedAt -> processSpawnedAt',
|
||
},
|
||
});
|
||
expect(bodyOf(batch, 'span-create', 'agent-call')).toMatchObject({
|
||
input: {
|
||
phase: 'agent-call',
|
||
model: 'unknown',
|
||
tool_call_count: 2,
|
||
generation_observation: true,
|
||
},
|
||
output: {
|
||
status: 'succeeded',
|
||
tool_call_count: 2,
|
||
token_usage: {
|
||
input: 1234,
|
||
input_effective: 1484,
|
||
output: 567,
|
||
total: 2051,
|
||
},
|
||
},
|
||
});
|
||
expect(bodyOf(batch, 'span-create', 'finalize')).toMatchObject({
|
||
input: {
|
||
phase: 'finalize',
|
||
artifact_manifest_enabled: true,
|
||
},
|
||
output: {
|
||
status: 'succeeded',
|
||
artifact_count: 0,
|
||
attachment_count: 1,
|
||
manifest_completeness: 'unavailable',
|
||
},
|
||
});
|
||
expect(bodyOf(batch, 'span-create', 'tool:Bash').parentObservationId).toBe(
|
||
'run-spans-phase-agent-call',
|
||
);
|
||
});
|
||
|
||
it('nests agent status and usage events under agent-call', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
run: {
|
||
runId: 'run-agent-events',
|
||
status: 'succeeded',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_004_500,
|
||
timingMarks: {
|
||
modelCallStartAt: 1_700_000_000_420,
|
||
},
|
||
},
|
||
agentEvents: [
|
||
{
|
||
id: 'status-initializing-0',
|
||
name: 'agent-status:initializing',
|
||
timestamp: 1_700_000_000_500,
|
||
input: { source: 'claude-code-stream', event_type: 'status' },
|
||
output: { label: 'initializing', model: 'claude-opus-4-8[1m]' },
|
||
},
|
||
{
|
||
id: 'thinking-start-0',
|
||
name: 'agent-thinking-start',
|
||
timestamp: 1_700_000_000_800,
|
||
input: {
|
||
source: 'claude-code-stream',
|
||
event_type: 'thinking_start',
|
||
},
|
||
output: { status: 'started' },
|
||
},
|
||
{
|
||
id: 'usage-0',
|
||
name: 'agent-usage',
|
||
timestamp: 1_700_000_004_000,
|
||
input: { source: 'claude-code-stream', event_type: 'usage' },
|
||
output: {
|
||
usage: { input_tokens: 10, output_tokens: 20 },
|
||
cost_usd: 0.01,
|
||
stop_reason: 'end_turn',
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(bodyOf(batch, 'event-create', 'agent-status:initializing')).toMatchObject({
|
||
parentObservationId: 'run-agent-events-phase-agent-call',
|
||
input: {
|
||
source: 'claude-code-stream',
|
||
event_type: 'status',
|
||
},
|
||
output: {
|
||
label: 'initializing',
|
||
model: 'claude-opus-4-8[1m]',
|
||
},
|
||
});
|
||
expect(bodyOf(batch, 'event-create', 'agent-thinking-start')).toMatchObject({
|
||
parentObservationId: 'run-agent-events-phase-agent-call',
|
||
input: {
|
||
source: 'claude-code-stream',
|
||
event_type: 'thinking_start',
|
||
},
|
||
output: { status: 'started' },
|
||
});
|
||
expect(bodyOf(batch, 'event-create', 'agent-usage')).toMatchObject({
|
||
parentObservationId: 'run-agent-events-phase-agent-call',
|
||
input: {
|
||
source: 'claude-code-stream',
|
||
event_type: 'usage',
|
||
},
|
||
output: {
|
||
usage: { input_tokens: 10, output_tokens: 20 },
|
||
cost_usd: 0.01,
|
||
stop_reason: 'end_turn',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('nests agent diagnostics under agent-call without requiring message content', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
run: {
|
||
runId: 'run-agent-diagnostics',
|
||
status: 'succeeded',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_004_500,
|
||
timingMarks: {
|
||
modelCallStartAt: 1_700_000_000_420,
|
||
},
|
||
},
|
||
agentEvents: [
|
||
{
|
||
id: 'diagnostic-acp_artifact_text_suppression-0',
|
||
name: 'agent-diagnostic:acp_artifact_text_suppression',
|
||
timestamp: 1_700_000_003_000,
|
||
input: { source: 'amr', event_type: 'diagnostic' },
|
||
output: {
|
||
name: 'acp_artifact_text_suppression',
|
||
source: 'acp-json-rpc',
|
||
reason: 'artifact_echo',
|
||
suppressed_chars: 4096,
|
||
opened_blocks: 1,
|
||
closed_blocks: 1,
|
||
},
|
||
metadata: {
|
||
diagnostic_name: 'acp_artifact_text_suppression',
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(
|
||
bodyOf(batch, 'event-create', 'agent-diagnostic:acp_artifact_text_suppression'),
|
||
).toMatchObject({
|
||
parentObservationId: 'run-agent-diagnostics-phase-agent-call',
|
||
input: {
|
||
source: 'amr',
|
||
event_type: 'diagnostic',
|
||
},
|
||
output: {
|
||
name: 'acp_artifact_text_suppression',
|
||
source: 'acp-json-rpc',
|
||
reason: 'artifact_echo',
|
||
suppressed_chars: 4096,
|
||
opened_blocks: 1,
|
||
closed_blocks: 1,
|
||
},
|
||
metadata: {
|
||
diagnostic_name: 'acp_artifact_text_suppression',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('emits cost and performance diagnostics for cost governance', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: true },
|
||
artifacts: [
|
||
{ slug: 'index.html', type: 'html', sizeBytes: 4096 },
|
||
{ slug: 'brand-spec.md', type: 'text', sizeBytes: 1024 },
|
||
],
|
||
run: {
|
||
runId: 'run-cost-perf',
|
||
status: 'succeeded',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_006_000,
|
||
timings: {
|
||
generation_duration_ms: 5000,
|
||
tool_call_count: 2,
|
||
tool_duration_ms: 1700,
|
||
total_duration_ms: 6000,
|
||
},
|
||
timingMarks: {
|
||
promptBuildStartAt: 1_700_000_000_100,
|
||
promptBuildEndAt: 1_700_000_000_200,
|
||
modelCallStartAt: 1_700_000_000_300,
|
||
firstTokenAt: 1_700_000_001_000,
|
||
finalizeStartAt: 1_700_000_005_500,
|
||
},
|
||
},
|
||
agentEvents: [
|
||
{
|
||
id: 'usage-0',
|
||
name: 'agent-usage',
|
||
timestamp: 1_700_000_005_400,
|
||
input: { source: 'claude-code-stream', event_type: 'usage' },
|
||
output: {
|
||
usage: { input_tokens: 100, output_tokens: 200 },
|
||
cost_usd: 0.1234,
|
||
duration_ms: 5400,
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
|
||
const trace = bodyOf(batch, 'trace-create');
|
||
const generation = bodyOf(batch, 'generation-create', 'llm');
|
||
const agentCall = bodyOf(batch, 'span-create', 'agent-call');
|
||
const bash = bodyOf(batch, 'span-create', 'tool:Bash');
|
||
const write = bodyOf(batch, 'span-create', 'tool:Write');
|
||
const artifacts = bodyOf(batch, 'event-create', 'artifact-summary');
|
||
|
||
expect(trace.metadata).toMatchObject({
|
||
cost_usd: 0.1234,
|
||
currency: 'USD',
|
||
pricing_version: 'provider_reported',
|
||
cost_source: 'agent_usage_event',
|
||
cost_status: 'available',
|
||
cost_breakdown: {
|
||
cost_usd: 0.1234,
|
||
currency: 'USD',
|
||
phase_costs: {
|
||
prompt_build: {
|
||
phase: 'prompt-build',
|
||
cost_usd: null,
|
||
cost_status: 'not_metered',
|
||
},
|
||
agent_call: {
|
||
phase: 'agent-call',
|
||
cost_usd: 0.1234,
|
||
cost_status: 'available',
|
||
},
|
||
artifact_generation: {
|
||
phase: 'artifact-generation',
|
||
cost_status: 'included_in_agent_call',
|
||
},
|
||
verification: {
|
||
phase: 'verification',
|
||
cost_status: 'not_instrumented',
|
||
},
|
||
},
|
||
},
|
||
performance_diagnostics: {
|
||
tool_performance: {
|
||
tool_call_count: 2,
|
||
total_tool_duration_ms: 1700,
|
||
retry_count_available: false,
|
||
retry_count: null,
|
||
by_tool: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
tool_name: 'Bash',
|
||
call_count: 1,
|
||
total_duration_ms: 800,
|
||
failure_types: ['none'],
|
||
}),
|
||
expect.objectContaining({
|
||
tool_name: 'Write',
|
||
call_count: 1,
|
||
total_duration_ms: 900,
|
||
failure_types: ['none'],
|
||
}),
|
||
]),
|
||
},
|
||
artifact_write: {
|
||
artifact_count: 2,
|
||
total_artifact_size_bytes: 5120,
|
||
write_tool_count: 1,
|
||
write_tool_duration_ms: 900,
|
||
correlation_status: 'heuristic_by_write_tool_total',
|
||
},
|
||
preview_verify: {
|
||
status: 'not_instrumented',
|
||
screenshot_check: 'not_reported',
|
||
responsive_check: 'not_reported',
|
||
},
|
||
semantic_phases: {
|
||
semantic_phase_timing_status: 'partial',
|
||
missing_semantic_phases: expect.arrayContaining([
|
||
'route-task-kind',
|
||
'preview-verify',
|
||
'evaluator',
|
||
]),
|
||
},
|
||
},
|
||
});
|
||
expect(generation.metadata.cost_usd).toBe(0.1234);
|
||
expect(generation.metadata.performance_diagnostics.preview_verify.status).toBe(
|
||
'not_instrumented',
|
||
);
|
||
expect(agentCall.output.cost).toMatchObject({
|
||
phase: 'agent-call',
|
||
cost_usd: 0.1234,
|
||
cost_status: 'available',
|
||
});
|
||
expect(bash.metadata).toMatchObject({
|
||
durationMs: 800,
|
||
failureType: 'none',
|
||
retryCount: null,
|
||
retryDetection: 'not_instrumented',
|
||
});
|
||
expect(write.metadata).toMatchObject({
|
||
durationMs: 900,
|
||
failureType: 'none',
|
||
});
|
||
expect(artifacts.metadata.artifact_write_diagnostics).toMatchObject({
|
||
total_artifact_size_bytes: 5120,
|
||
write_tool_duration_ms: 900,
|
||
});
|
||
});
|
||
|
||
it('marks cost unavailable when the runtime does not report provider cost', () => {
|
||
const batch = buildTracePayload(makeCtx());
|
||
const trace = bodyOf(batch, 'trace-create');
|
||
expect(trace.metadata).toMatchObject({
|
||
cost_usd: null,
|
||
currency: 'USD',
|
||
pricing_version: 'unavailable',
|
||
cost_source: 'unavailable',
|
||
cost_status: 'unavailable',
|
||
cost_breakdown: {
|
||
unavailable_reason: 'agent runtime did not report total_cost_usd',
|
||
phase_costs: {
|
||
agent_call: {
|
||
cost_usd: null,
|
||
cost_status: 'unavailable',
|
||
},
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
it('keeps prompt-build ingredient keys stable when optional inputs are absent', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
run: {
|
||
runId: 'run-prompt-ingredients',
|
||
status: 'succeeded',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_001_000,
|
||
timingMarks: {
|
||
promptBuildStartAt: 1_700_000_000_100,
|
||
promptBuildEndAt: 1_700_000_000_200,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(bodyOf(batch, 'span-create', 'prompt-build').input).toMatchObject({
|
||
phase: 'prompt-build',
|
||
ingredients: {
|
||
agent: 'claude',
|
||
model: 'unknown',
|
||
skill_id: null,
|
||
design_system_id: null,
|
||
user_request_available: true,
|
||
attachment_refs: [],
|
||
},
|
||
});
|
||
});
|
||
|
||
it('passes through anonymous installationId as userId', () => {
|
||
const batch = buildTracePayload(makeCtx({ installationId: null }));
|
||
expect((batch[0] as any).body.userId).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('reportRunCompleted', () => {
|
||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||
|
||
beforeEach(() => {
|
||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
});
|
||
|
||
afterEach(() => {
|
||
warnSpy.mockRestore();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('does nothing when metrics gate is off', async () => {
|
||
const fetchSpy = vi.fn();
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: false, content: true, artifactManifest: true },
|
||
}),
|
||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||
);
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
expect(result).toEqual({
|
||
langfuse_expected: false,
|
||
langfuse_delivery_status: 'not_expected',
|
||
langfuse_drop_reason: 'metrics_consent_off',
|
||
});
|
||
});
|
||
|
||
it('does nothing when content gate is off', async () => {
|
||
const fetchSpy = vi.fn();
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: false },
|
||
}),
|
||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||
);
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
expect(result).toEqual({
|
||
langfuse_expected: false,
|
||
langfuse_delivery_status: 'not_expected',
|
||
langfuse_drop_reason: 'content_consent_off',
|
||
});
|
||
});
|
||
|
||
it('does nothing when no Langfuse config is available', async () => {
|
||
const fetchSpy = vi.fn();
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: null,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
expect(result).toEqual({
|
||
langfuse_expected: false,
|
||
langfuse_delivery_status: 'not_expected',
|
||
langfuse_drop_reason: 'missing_sink_config',
|
||
});
|
||
});
|
||
|
||
it('POSTs to /api/public/ingestion with Basic auth and a JSON batch body', async () => {
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response('{}', { status: 200 }),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||
const call = fetchSpy.mock.calls[0]!;
|
||
const url = call[0] as string;
|
||
const init = call[1] as RequestInit & { headers: Record<string, string> };
|
||
expect(url).toBe('https://us.cloud.langfuse.com/api/public/ingestion');
|
||
expect(init.method).toBe('POST');
|
||
expect(init.headers.Authorization).toBe('Basic dGVzdA==');
|
||
expect(init.headers['Content-Type']).toBe('application/json');
|
||
const body = JSON.parse(init.body as string);
|
||
expect(Array.isArray(body.batch)).toBe(true);
|
||
expect(body.batch.map((item: any) => item.type)).toEqual([
|
||
'trace-create',
|
||
'span-create',
|
||
'generation-create',
|
||
'span-create',
|
||
'span-create',
|
||
]);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'accepted',
|
||
});
|
||
});
|
||
|
||
it('keeps a max-budget prompt stack under the hard batch cap', async () => {
|
||
const maxBudgetSection = 'x'.repeat(64 * 1024);
|
||
const promptTelemetry = buildPromptStackTelemetry({
|
||
composedPrompt: maxBudgetSection.repeat(8),
|
||
sections: [
|
||
{ kind: 'daemonSystemPrompt', content: maxBudgetSection.repeat(2) },
|
||
{ kind: 'runtimeToolPrompt', content: maxBudgetSection },
|
||
{ kind: 'clientSystemPrompt', content: maxBudgetSection },
|
||
{ kind: 'skillPrompt', content: maxBudgetSection },
|
||
{ kind: 'designSystemPrompt', content: maxBudgetSection },
|
||
{ kind: 'pluginStagePrompt', content: maxBudgetSection },
|
||
{ kind: 'researchCommandContract', content: maxBudgetSection },
|
||
],
|
||
});
|
||
expect(promptTelemetry.redactedContentBytes).toBe(512 * 1024);
|
||
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response('{}', { status: 200 }),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
promptTelemetry,
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
|
||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||
const serialized = init.body as string;
|
||
expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThan(1024 * 1024);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'accepted',
|
||
});
|
||
});
|
||
|
||
it('keeps stderr out of trace input/output and stores only a redacted metadata tail', () => {
|
||
const batch = buildTracePayload(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
run: {
|
||
runId: 'run-err',
|
||
status: 'failed',
|
||
startedAt: 1_700_000_000_000,
|
||
endedAt: 1_700_000_004_500,
|
||
error: 'provider failed',
|
||
stderr: {
|
||
tail: 'HTTP 429 OPENAI_API_KEY=[REDACTED:openai_key]',
|
||
lineCount: 12,
|
||
truncated: true,
|
||
},
|
||
},
|
||
}),
|
||
) as any[];
|
||
|
||
const trace = bodyOf(batch, 'trace-create');
|
||
const generation = bodyOf(batch, 'generation-create', 'llm');
|
||
|
||
expect(trace.input).toBe('Make a landing page for a coffee shop.');
|
||
expect(trace.output).toBe('Here is a landing page draft …');
|
||
expect(generation.input).toBe('Make a landing page for a coffee shop.');
|
||
expect(generation.output).toBe('Here is a landing page draft …');
|
||
expect(trace.metadata.stderr).toEqual({
|
||
tail: 'HTTP 429 OPENAI_API_KEY=[REDACTED:openai_key]',
|
||
lineCount: 12,
|
||
truncated: true,
|
||
});
|
||
expect(JSON.stringify(batch)).not.toContain('sk-raw');
|
||
});
|
||
|
||
it('POSTs serialized ingestion batches to the Open Design telemetry relay', async () => {
|
||
const relayConfig: TelemetrySinkConfig = {
|
||
kind: 'relay',
|
||
relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
|
||
timeoutMs: 20_000,
|
||
retries: 0,
|
||
};
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response('{}', { status: 200 }),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: relayConfig,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||
const call = fetchSpy.mock.calls[0]!;
|
||
const url = call[0] as string;
|
||
const init = call[1] as RequestInit & { headers: Record<string, string> };
|
||
expect(url).toBe('https://telemetry.open-design.ai/api/langfuse');
|
||
expect(init.method).toBe('POST');
|
||
expect(init.headers.Authorization).toBeUndefined();
|
||
expect(init.headers['Content-Type']).toBe('application/json');
|
||
expect(init.headers['X-Open-Design-Telemetry']).toBe('langfuse-ingestion-v1');
|
||
const body = JSON.parse(init.body as string);
|
||
expect(Array.isArray(body.batch)).toBe(true);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'accepted',
|
||
});
|
||
});
|
||
|
||
it('warns when the relay returns per-event errors', async () => {
|
||
const relayConfig: TelemetrySinkConfig = {
|
||
kind: 'relay',
|
||
relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
|
||
timeoutMs: 20_000,
|
||
retries: 0,
|
||
};
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response(
|
||
JSON.stringify({ successes: [], errors: [{ id: 'bad', status: 400 }] }),
|
||
{ status: 207 },
|
||
),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: relayConfig,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('Relay per-event errors (1)'),
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'langfuse_4xx',
|
||
});
|
||
});
|
||
|
||
|
||
it('classifies relay 413 responses as relay_413', async () => {
|
||
const relayConfig: TelemetrySinkConfig = {
|
||
kind: 'relay',
|
||
relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
|
||
timeoutMs: 20_000,
|
||
retries: 0,
|
||
};
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response('payload too large', { status: 413 }),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: relayConfig,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'relay_413',
|
||
});
|
||
});
|
||
|
||
it('classifies relay 5xx responses as relay_5xx', async () => {
|
||
const relayConfig: TelemetrySinkConfig = {
|
||
kind: 'relay',
|
||
relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
|
||
timeoutMs: 20_000,
|
||
retries: 0,
|
||
};
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response('upstream unavailable', { status: 503 }),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: relayConfig,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'relay_5xx',
|
||
});
|
||
});
|
||
|
||
it('classifies direct Langfuse 5xx responses as langfuse_5xx', async () => {
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response('server error', { status: 503 }),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'langfuse_5xx',
|
||
});
|
||
});
|
||
|
||
it('classifies relay per-event 429s separately from generic 4xx', async () => {
|
||
const relayConfig: TelemetrySinkConfig = {
|
||
kind: 'relay',
|
||
relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
|
||
timeoutMs: 20_000,
|
||
retries: 0,
|
||
};
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response(
|
||
JSON.stringify({ successes: [], errors: [{ id: 'throttled', status: 429 }] }),
|
||
{ status: 207 },
|
||
),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: relayConfig,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'relay_429',
|
||
});
|
||
});
|
||
|
||
it('classifies direct Langfuse per-event 5xx responses as langfuse_5xx', async () => {
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response(
|
||
JSON.stringify({ successes: [], errors: [{ id: 'lf-down', status: 503 }] }),
|
||
{ status: 207 },
|
||
),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'langfuse_5xx',
|
||
});
|
||
});
|
||
|
||
it('warns and drops when serialized batch exceeds the hard cap', async () => {
|
||
// Per-field truncation already caps prompt/output, so we overflow the
|
||
// hard cap by stuffing 50 artifact entries with very long slugs while
|
||
// artifactManifest is on (50 × 30 KB ≈ 1.5 MB > 1 MB cap).
|
||
const fetchSpy = vi.fn();
|
||
const fatArtifacts = Array.from({ length: 50 }, (_, i) => ({
|
||
slug: 'a'.repeat(30_000) + i,
|
||
type: 'html',
|
||
sizeBytes: 1,
|
||
}));
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
artifacts: fatArtifacts,
|
||
prefs: { metrics: true, content: true, artifactManifest: true },
|
||
}),
|
||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||
);
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('Batch too large'),
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'payload_too_large',
|
||
});
|
||
});
|
||
|
||
it('only warns (does not throw) when fetch rejects', async () => {
|
||
const fetchSpy = vi.fn().mockRejectedValue(new Error('network down'));
|
||
await expect(
|
||
reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
),
|
||
).resolves.toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'network_error',
|
||
});
|
||
expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('Fetch error'),
|
||
);
|
||
});
|
||
|
||
it('retries once when fetch rejects before warning', async () => {
|
||
const fetchSpy = vi
|
||
.fn()
|
||
.mockRejectedValueOnce(new Error('timeout'))
|
||
.mockResolvedValueOnce(new Response('{}', { status: 207 }));
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: { ...TEST_CONFIG, retries: 1 },
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||
expect(warnSpy).not.toHaveBeenCalled();
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'accepted',
|
||
});
|
||
});
|
||
|
||
it('only warns (does not throw) when ingestion responds non-2xx', async () => {
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response('rate limited', { status: 429 }),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('Ingestion failed 429'),
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'langfuse_4xx',
|
||
});
|
||
});
|
||
|
||
it('warns when 207 Multi-Status body lists per-event errors', async () => {
|
||
// Langfuse legacy ingestion always responds with 207. response.ok is
|
||
// true, but malformed events show up in body.errors instead of as a
|
||
// top-level non-2xx. Without parsing them they'd be silently dropped.
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response(
|
||
JSON.stringify({
|
||
successes: [{ id: 'a', status: 201 }],
|
||
errors: [
|
||
{
|
||
id: 'b',
|
||
status: 400,
|
||
message: 'invalid generation usage shape',
|
||
},
|
||
],
|
||
}),
|
||
{ status: 207 },
|
||
),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('Per-event errors (1)'),
|
||
);
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'failed',
|
||
langfuse_drop_reason: 'langfuse_4xx',
|
||
});
|
||
});
|
||
|
||
it('does not warn when 207 body has empty errors array', async () => {
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response(
|
||
JSON.stringify({
|
||
successes: [
|
||
{ id: 'a', status: 201 },
|
||
{ id: 'b', status: 201 },
|
||
],
|
||
errors: [],
|
||
}),
|
||
{ status: 207 },
|
||
),
|
||
);
|
||
const result = await reportRunCompleted(
|
||
makeCtx({
|
||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||
}),
|
||
{
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
},
|
||
);
|
||
expect(warnSpy).not.toHaveBeenCalled();
|
||
expect(result).toEqual({
|
||
langfuse_expected: true,
|
||
langfuse_delivery_status: 'accepted',
|
||
});
|
||
});
|
||
});
|
||
|
||
function makeFeedbackCtx(
|
||
overrides: Partial<FeedbackReportContext> = {},
|
||
): FeedbackReportContext {
|
||
return {
|
||
runId: 'run-feedback-1',
|
||
installationId: 'install-uuid-1',
|
||
prefs: { metrics: true, content: true },
|
||
rating: 'positive',
|
||
reasonCodes: ['matched_request'],
|
||
hasCustomReason: false,
|
||
customReason: '',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('buildFeedbackPayload', () => {
|
||
it('emits a numeric user_rating score plus per-reason categorical scores', () => {
|
||
const batch = buildFeedbackPayload(
|
||
makeFeedbackCtx({
|
||
rating: 'negative',
|
||
reasonCodes: ['missed_request', 'weak_visual'],
|
||
hasCustomReason: true,
|
||
customReason: 'It got the layout wrong on tablet',
|
||
}),
|
||
) as Array<Record<string, any>>;
|
||
expect(batch).toHaveLength(3);
|
||
const ratingScore = batch[0]!;
|
||
expect(ratingScore.type).toBe('score-create');
|
||
expect(ratingScore.body.traceId).toBe('run-feedback-1');
|
||
expect(ratingScore.body.name).toBe('user_rating');
|
||
expect(ratingScore.body.value).toBe(-1);
|
||
expect(ratingScore.body.dataType).toBe('NUMERIC');
|
||
expect(ratingScore.body.comment).toBe('negative');
|
||
expect(ratingScore.body.metadata).toMatchObject({
|
||
reasonCount: 2,
|
||
customReason: 'It got the layout wrong on tablet',
|
||
hasCustomReason: true,
|
||
});
|
||
for (const reasonScore of batch.slice(1)) {
|
||
expect(reasonScore.body.name).toBe('user_rating_reason');
|
||
expect(reasonScore.body.dataType).toBe('CATEGORICAL');
|
||
expect(reasonScore.body.comment).toBe('negative');
|
||
expect(reasonScore.body.traceId).toBe('run-feedback-1');
|
||
}
|
||
expect(batch[1]!.body.value).toBe('missed_request');
|
||
expect(batch[2]!.body.value).toBe('weak_visual');
|
||
});
|
||
|
||
it('does not emit reason scores when no codes were submitted', () => {
|
||
const batch = buildFeedbackPayload(
|
||
makeFeedbackCtx({ reasonCodes: [] }),
|
||
) as Array<Record<string, any>>;
|
||
expect(batch).toHaveLength(1);
|
||
expect(batch[0]!.body.name).toBe('user_rating');
|
||
expect(batch[0]!.body.value).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('reportRunFeedback', () => {
|
||
const TEST_CONFIG: LangfuseConfig = {
|
||
baseUrl: 'https://us.cloud.langfuse.com',
|
||
authHeader: 'Basic Zm9vOmJhcg==',
|
||
retries: 0,
|
||
timeoutMs: 1000,
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('skips when metrics consent is off', async () => {
|
||
const fetchSpy = vi.fn();
|
||
await reportRunFeedback(makeFeedbackCtx({ prefs: { metrics: false, content: true } }), {
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
});
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('skips when content consent is off', async () => {
|
||
const fetchSpy = vi.fn();
|
||
await reportRunFeedback(makeFeedbackCtx({ prefs: { metrics: true, content: false } }), {
|
||
config: TEST_CONFIG,
|
||
fetchImpl: fetchSpy as any,
|
||
});
|
||
expect(fetchSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('posts a score-create batch to /api/public/ingestion when consent is on', async () => {
|
||
const fetchSpy = vi.fn().mockResolvedValue(
|
||
new Response(JSON.stringify({ successes: [], errors: [] }), { status: 207 }),
|
||
);
|
||
await reportRunFeedback(
|
||
makeFeedbackCtx({ reasonCodes: ['matched_request'] }),
|
||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||
);
|
||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||
expect(url).toBe('https://us.cloud.langfuse.com/api/public/ingestion');
|
||
expect(init.method).toBe('POST');
|
||
const body = JSON.parse(init.body);
|
||
expect(body.batch).toHaveLength(2);
|
||
expect(body.batch[0].type).toBe('score-create');
|
||
expect(body.batch[0].body.value).toBe(1);
|
||
});
|
||
});
|