@robota-sdk/agent-telemetry-otel 0.0.0-stage → 3.0.0-beta.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,82 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-telemetry-otel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "3.0.0-beta.94",
4
+ "description": "OpenTelemetry tracing and metrics for Robota agents: content-free span projection of live prompt traces, a run observer and GenAI metric samples, with agent.* or GenAI semantic-convention names",
5
+ "type": "module",
6
+ "main": "dist/node/index.js",
7
+ "types": "dist/node/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "source": "./src/index.ts",
11
+ "node": {
12
+ "import": {
13
+ "types": "./dist/node/index.d.ts",
14
+ "default": "./dist/node/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/node/index.d.cts",
18
+ "default": "./dist/node/index.cjs"
19
+ }
20
+ },
21
+ "default": {
22
+ "import": {
23
+ "types": "./dist/node/index.d.ts",
24
+ "default": "./dist/node/index.js"
25
+ },
26
+ "require": {
27
+ "types": "./dist/node/index.d.cts",
28
+ "default": "./dist/node/index.cjs"
29
+ }
30
+ }
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.12.0"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "dependencies": {
43
+ "@robota-sdk/agent-core": "3.0.0-beta.94",
44
+ "@robota-sdk/agent-interface-analytics": "3.0.0-beta.94"
45
+ },
46
+ "devDependencies": {
47
+ "@opentelemetry/api": "^1.9.1",
48
+ "@opentelemetry/sdk-trace": "^2.11.0",
49
+ "@types/node": "^22.19.21",
50
+ "rimraf": "^5.0.10",
51
+ "tsdown": "^0.22.14",
52
+ "typescript": "^6.0.3",
53
+ "vitest": "^3.2.6"
54
+ },
55
+ "license": "AGPL-3.0-only OR LicenseRef-Commercial",
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "volta": {
60
+ "extends": "../../package.json"
61
+ },
62
+ "peerDependencies": {
63
+ "@opentelemetry/api": "^1.9.0"
64
+ },
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "https://github.com/woojubb/robota"
68
+ },
69
+ "bugs": {
70
+ "url": "https://github.com/woojubb/robota/issues"
71
+ },
72
+ "homepage": "https://robota.io/",
73
+ "scripts": {
74
+ "build": "rimraf dist && tsdown",
75
+ "build:js": "pnpm run build",
76
+ "build:types": "pnpm run build",
77
+ "typecheck": "tsgo --noEmit",
78
+ "test": "vitest run",
79
+ "test:coverage": "vitest run --coverage",
80
+ "clean": "rimraf dist"
81
+ }
6
82
  }
@@ -0,0 +1,247 @@
1
+ import type { ILivePromptTraceBatch } from '@robota-sdk/agent-interface-analytics';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ GEN_AI_AGENT_DURATION_BUCKETS_S,
6
+ GEN_AI_CALL_COUNT_BUCKETS,
7
+ GEN_AI_DURATION_BUCKETS_S,
8
+ GEN_AI_TOKEN_BUCKETS,
9
+ projectLivePromptMetricSamples,
10
+ } from '../index.js';
11
+ import type { IOtelMetricSample } from '../index.js';
12
+
13
+ const TRACE = '0123456789abcdef0123456789abcdef';
14
+ const ROOT = '1111111111111111';
15
+
16
+ type TChild = ILivePromptTraceBatch['children'][number];
17
+ type TProviderTrace = Extract<TChild, { kind: 'provider' }>['trace'];
18
+
19
+ function provider(overrides: Partial<TProviderTrace> = {}): TChild {
20
+ return {
21
+ kind: 'provider',
22
+ trace: {
23
+ traceId: TRACE, parentSpanId: ROOT, spanId: '2222222222222222', outcome: 'success', round: 1,
24
+ startedAt: '2026-10-17T00:00:00.100Z', endedAt: '2026-10-17T00:00:01.300Z',
25
+ disposition: 'invoked', providerId: 'anthropic', modelId: 'claude-sonnet-4-5',
26
+ usageProvenance: 'complete', promptTokens: 120, completionTokens: 30, totalTokens: 150,
27
+ ...overrides,
28
+ },
29
+ };
30
+ }
31
+
32
+ function tool(overrides: Partial<Extract<TChild, { kind: 'tool' }>['trace']> = {}): TChild {
33
+ return {
34
+ kind: 'tool',
35
+ trace: {
36
+ traceId: TRACE, parentSpanId: ROOT, spanId: '3333333333333333', outcome: 'failure',
37
+ startedAt: '2026-10-17T00:00:01.500Z', endedAt: '2026-10-17T00:00:01.800Z',
38
+ toolCallId: 'toolu_01', toolName: 'Read', ...overrides,
39
+ },
40
+ };
41
+ }
42
+
43
+ function batch(children: TChild[], overrides: Partial<ILivePromptTraceBatch> = {}): ILivePromptTraceBatch {
44
+ return {
45
+ schemaVersion: 1, sessionId: 'session-1', turnId: 'turn-1',
46
+ root: {
47
+ traceId: TRACE, spanId: ROOT, outcome: 'failure',
48
+ startedAt: '2026-10-17T00:00:00.000Z', endedAt: '2026-10-17T00:00:03.000Z',
49
+ },
50
+ children,
51
+ omittedChildren: { provider: 0, tool: 0, permission: 0 },
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ const names = (samples: IOtelMetricSample[]): string[] => samples.map((sample) => sample.name);
57
+ const only = (samples: IOtelMetricSample[], name: string): IOtelMetricSample[] =>
58
+ samples.filter((sample) => sample.name === name);
59
+
60
+ describe('projectLivePromptMetricSamples', () => {
61
+ it('projects every GenAI measurement for a rich batch', () => {
62
+ const permission: TChild = {
63
+ kind: 'permission',
64
+ decision: { traceId: TRACE, parentSpanId: ROOT, decidedAt: '2026-10-17T00:00:01.400Z', decision: 'allowed' },
65
+ };
66
+ const samples = projectLivePromptMetricSamples(batch([
67
+ provider(),
68
+ permission,
69
+ tool(),
70
+ provider({ outcome: 'failure', providerId: 'my-gateway', modelId: undefined, usageProvenance: 'partial',
71
+ promptTokens: undefined, completionTokens: undefined, totalTokens: undefined,
72
+ startedAt: '2026-10-17T00:00:01.900Z', endedAt: '2026-10-17T00:00:02.500Z' }),
73
+ ]), { mode: 'gen_ai', agentName: 'robota' });
74
+
75
+ const ok = {
76
+ 'gen_ai.operation.name': 'chat', 'gen_ai.provider.name': 'anthropic', 'gen_ai.request.model': 'claude-sonnet-4-5',
77
+ 'agent.provider.id': 'anthropic',
78
+ };
79
+ const failed = { 'gen_ai.operation.name': 'chat', 'agent.provider.id': 'my-gateway', 'error.type': '_OTHER' };
80
+ expect(samples).toEqual([
81
+ { name: 'gen_ai.client.inference.duration', instrument: 'histogram', unit: 's', value: 1.2, attributes: ok, boundaries: GEN_AI_DURATION_BUCKETS_S },
82
+ { name: 'gen_ai.client.inference.usage.input_tokens', instrument: 'counter', unit: '{token}', value: 120, attributes: { ...ok, 'gen_ai.token.modality': 'unknown' } },
83
+ { name: 'gen_ai.client.inference.usage.output_tokens', instrument: 'counter', unit: '{token}', value: 30, attributes: { ...ok, 'gen_ai.token.modality': 'unknown' } },
84
+ { name: 'gen_ai.client.inference.operation.input_tokens', instrument: 'histogram', unit: '{token}', value: 120, attributes: ok, boundaries: GEN_AI_TOKEN_BUCKETS },
85
+ { name: 'gen_ai.client.inference.operation.output_tokens', instrument: 'histogram', unit: '{token}', value: 30, attributes: ok, boundaries: GEN_AI_TOKEN_BUCKETS },
86
+ { name: 'gen_ai.client.inference.duration', instrument: 'histogram', unit: 's', value: 0.6, attributes: failed, boundaries: GEN_AI_DURATION_BUCKETS_S },
87
+ { name: 'gen_ai.client.inference.usage.input_tokens', instrument: 'counter', unit: '{token}', value: 0, attributes: { 'gen_ai.operation.name': 'chat', 'agent.provider.id': 'my-gateway', 'gen_ai.token.modality': 'unknown' } },
88
+ { name: 'gen_ai.client.inference.usage.output_tokens', instrument: 'counter', unit: '{token}', value: 0, attributes: { 'gen_ai.operation.name': 'chat', 'agent.provider.id': 'my-gateway', 'gen_ai.token.modality': 'unknown' } },
89
+ { name: 'gen_ai.client.inference.operation.input_tokens', instrument: 'histogram', unit: '{token}', value: 0, attributes: failed, boundaries: GEN_AI_TOKEN_BUCKETS },
90
+ { name: 'gen_ai.client.inference.operation.output_tokens', instrument: 'histogram', unit: '{token}', value: 0, attributes: failed, boundaries: GEN_AI_TOKEN_BUCKETS },
91
+ { name: 'gen_ai.execute_tool.duration', instrument: 'histogram', unit: 's', value: 0.3,
92
+ attributes: { 'gen_ai.tool.name': 'Read', 'gen_ai.agent.name': 'robota', 'error.type': '_OTHER' }, boundaries: GEN_AI_DURATION_BUCKETS_S },
93
+ { name: 'gen_ai.invoke_agent.duration', instrument: 'histogram', unit: 's', value: 3,
94
+ attributes: { 'gen_ai.agent.name': 'robota', 'error.type': '_OTHER' }, boundaries: GEN_AI_AGENT_DURATION_BUCKETS_S },
95
+ { name: 'gen_ai.invoke_agent.inference_calls', instrument: 'histogram', unit: '{inference_call}', value: 2,
96
+ attributes: { 'gen_ai.agent.name': 'robota' }, boundaries: GEN_AI_CALL_COUNT_BUCKETS },
97
+ { name: 'gen_ai.invoke_agent.tool_calls', instrument: 'histogram', unit: '{tool_call}', value: 1,
98
+ attributes: { 'gen_ai.agent.name': 'robota' }, boundaries: GEN_AI_CALL_COUNT_BUCKETS },
99
+ ]);
100
+ });
101
+
102
+ it('pins the recommended bucket boundaries', () => {
103
+ expect(GEN_AI_DURATION_BUCKETS_S).toEqual([0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92]);
104
+ expect(GEN_AI_AGENT_DURATION_BUCKETS_S).toEqual([0.1, 0.2, 0.4, 0.8, 1.6, 3.2, 6.4, 12.8, 25.6, 51.2, 102.4, 204.8, 409.6]);
105
+ expect(GEN_AI_TOKEN_BUCKETS).toEqual([1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]);
106
+ expect(GEN_AI_CALL_COUNT_BUCKETS).toEqual([1, 2, 4, 8, 16, 32, 64, 128]);
107
+ });
108
+
109
+ it('records 0 tokens under the unknown modality unless usage is complete', () => {
110
+ for (const usage of [
111
+ { usageProvenance: 'partial' as const, promptTokens: 7, completionTokens: 3 },
112
+ { usageProvenance: 'absent' as const, promptTokens: undefined, completionTokens: undefined },
113
+ { usageProvenance: undefined, promptTokens: 7, completionTokens: 3 },
114
+ { usageProvenance: 'complete' as const, promptTokens: 7, completionTokens: undefined },
115
+ { usageProvenance: 'complete' as const, promptTokens: -1, completionTokens: 3 },
116
+ { usageProvenance: 'complete' as const, promptTokens: 1.5, completionTokens: 3 },
117
+ { usageProvenance: 'complete' as const, promptTokens: Number.MAX_SAFE_INTEGER + 1, completionTokens: 3 },
118
+ ]) {
119
+ const samples = projectLivePromptMetricSamples(batch([provider({ ...usage, totalTokens: undefined })]), { mode: 'gen_ai' });
120
+ const tokens = samples.filter((sample) => sample.name.includes('tokens'));
121
+ expect(tokens).toHaveLength(4);
122
+ expect(tokens.map((sample) => sample.value)).toEqual([0, 0, 0, 0]);
123
+ for (const counter of tokens.filter((sample) => sample.instrument === 'counter')) {
124
+ expect(counter.attributes).toMatchObject({ 'gen_ai.token.modality': 'unknown' });
125
+ }
126
+ }
127
+ });
128
+
129
+ it('keeps error.type off the token counters and the success path', () => {
130
+ const failed = projectLivePromptMetricSamples(batch([provider({ outcome: 'failure' })]), { mode: 'gen_ai' });
131
+ for (const sample of failed.filter((item) => item.name.startsWith('gen_ai.client.'))) {
132
+ const hasError = 'error.type' in sample.attributes;
133
+ expect(hasError).toBe(!sample.name.includes('.usage.'));
134
+ }
135
+ const interrupted = projectLivePromptMetricSamples(batch([provider({ outcome: 'interrupted' })]), { mode: 'gen_ai' });
136
+ expect(interrupted.some((item) => 'error.type' in item.attributes && item.name.startsWith('gen_ai.client.'))).toBe(false);
137
+ });
138
+
139
+ it('emits no provider-derived sample when provider children were omitted', () => {
140
+ const samples = projectLivePromptMetricSamples(
141
+ batch([provider(), tool()], { omittedChildren: { provider: 1, tool: 0, permission: 0 } }), { mode: 'gen_ai', agentName: 'robota' });
142
+ expect(names(samples)).toEqual([
143
+ 'gen_ai.execute_tool.duration', 'gen_ai.invoke_agent.duration', 'gen_ai.invoke_agent.tool_calls',
144
+ ]);
145
+ });
146
+
147
+ it('emits no tool-derived sample when tool children were omitted', () => {
148
+ const samples = projectLivePromptMetricSamples(
149
+ batch([provider(), tool()], { omittedChildren: { provider: 0, tool: 2, permission: 0 } }), { mode: 'gen_ai', agentName: 'robota' });
150
+ expect(names(samples)).not.toContain('gen_ai.execute_tool.duration');
151
+ expect(names(samples)).not.toContain('gen_ai.invoke_agent.tool_calls');
152
+ expect(names(samples)).toContain('gen_ai.invoke_agent.inference_calls');
153
+ expect(names(samples)).toContain('gen_ai.client.inference.duration');
154
+ });
155
+
156
+ it('keeps the root duration when children were omitted, and counts nothing it cannot prove', () => {
157
+ const samples = projectLivePromptMetricSamples(
158
+ batch([], { omittedChildren: { provider: 3, tool: 3, permission: 0 } }), { mode: 'gen_ai' });
159
+ expect(names(samples)).toEqual(['gen_ai.invoke_agent.duration']);
160
+ });
161
+
162
+ it('excludes cache hits and refused calls from every inference measurement', () => {
163
+ const samples = projectLivePromptMetricSamples(batch([
164
+ provider({ disposition: 'cache-hit' }),
165
+ provider({ disposition: 'preflight-refused', outcome: 'failure' }),
166
+ provider({ disposition: undefined }),
167
+ provider(),
168
+ ]), { mode: 'gen_ai' });
169
+ expect(only(samples, 'gen_ai.client.inference.duration')).toHaveLength(1);
170
+ expect(only(samples, 'gen_ai.invoke_agent.inference_calls')[0]!.value).toBe(1);
171
+ });
172
+
173
+ it('records zero calls for a prompt without provider or tool children', () => {
174
+ const samples = projectLivePromptMetricSamples(batch([]), { mode: 'gen_ai' });
175
+ expect(only(samples, 'gen_ai.invoke_agent.inference_calls')[0]!.value).toBe(0);
176
+ expect(only(samples, 'gen_ai.invoke_agent.tool_calls')[0]!.value).toBe(0);
177
+ });
178
+
179
+ it('drops a model, tool name or provider that is not safe to label', () => {
180
+ const samples = projectLivePromptMetricSamples(batch([
181
+ provider({ modelId: 'private prompt text', providerId: 'constructor' }),
182
+ provider({ modelId: 'private prompt text', providerId: 'private provider text' }),
183
+ tool({ toolName: 'private tool output' }),
184
+ ]), { mode: 'gen_ai/dup', agentName: 'robota' });
185
+ const [constructorProvider, unsafeProvider] = only(samples, 'gen_ai.client.inference.duration');
186
+ // `constructor` is a label-safe id, just not a known GenAI provider name.
187
+ expect(constructorProvider!.attributes).toEqual({ 'gen_ai.operation.name': 'chat', 'agent.provider.id': 'constructor' });
188
+ expect(unsafeProvider!.attributes).toEqual({ 'gen_ai.operation.name': 'chat' });
189
+ expect(only(samples, 'gen_ai.execute_tool.duration')[0]!.attributes)
190
+ .toEqual({ 'gen_ai.agent.name': 'robota', 'error.type': '_OTHER' });
191
+ expect(JSON.stringify(samples)).not.toMatch(/private/u);
192
+ });
193
+
194
+ it('keeps the Robota provider id in both GenAI modes and the model id only in gen_ai/dup', () => {
195
+ const inference = (mode: 'gen_ai' | 'gen_ai/dup') => projectLivePromptMetricSamples(
196
+ batch([provider(), provider({ providerId: 'my-gateway', modelId: 'gateway-model' })]), { mode })
197
+ .filter((sample) => sample.name.startsWith('gen_ai.client.inference.'));
198
+ const providers = (mode: 'gen_ai' | 'gen_ai/dup') => inference(mode).map((sample) => sample.attributes['agent.provider.id']);
199
+ const models = (mode: 'gen_ai' | 'gen_ai/dup') => inference(mode).map((sample) => sample.attributes['agent.model.id']);
200
+ // All five inference metrics, for each of the two calls.
201
+ expect(inference('gen_ai')).toHaveLength(10);
202
+ for (const mode of ['gen_ai', 'gen_ai/dup'] as const) {
203
+ expect(providers(mode)).toEqual(['anthropic', 'anthropic', 'anthropic', 'anthropic', 'anthropic',
204
+ 'my-gateway', 'my-gateway', 'my-gateway', 'my-gateway', 'my-gateway']);
205
+ }
206
+ expect(models('gen_ai').every((model) => model === undefined)).toBe(true);
207
+ expect(models('gen_ai/dup')).toEqual([
208
+ ...Array<string>(5).fill('claude-sonnet-4-5'), ...Array<string>(5).fill('gateway-model')]);
209
+ // The convention name for the model is present in both modes.
210
+ for (const mode of ['gen_ai', 'gen_ai/dup'] as const) {
211
+ expect(inference(mode).map((sample) => sample.attributes['gen_ai.request.model']))
212
+ .toEqual([...Array<string>(5).fill('claude-sonnet-4-5'), ...Array<string>(5).fill('gateway-model')]);
213
+ }
214
+ });
215
+
216
+ it('puts the Robota ids on no tool or agent metric', () => {
217
+ const samples = projectLivePromptMetricSamples(batch([provider(), tool()]), { mode: 'gen_ai/dup', agentName: 'robota' });
218
+ for (const sample of samples.filter((item) => !item.name.startsWith('gen_ai.client.inference.'))) {
219
+ expect(sample.attributes).not.toHaveProperty('agent.provider.id');
220
+ expect(sample.attributes).not.toHaveProperty('agent.model.id');
221
+ }
222
+ });
223
+
224
+ it('names a Gemini or Google provider with the convention name for any Google endpoint', () => {
225
+ const [gemini, google] = ['gemini', 'google'].map((providerId) =>
226
+ only(projectLivePromptMetricSamples(batch([provider({ providerId })]), { mode: 'gen_ai' }), 'gen_ai.client.inference.duration')[0]!);
227
+ expect(gemini!.attributes).toMatchObject({ 'gen_ai.provider.name': 'gcp.gen_ai' });
228
+ expect(google!.attributes).toMatchObject({ 'gen_ai.provider.name': 'gcp.gen_ai' });
229
+ });
230
+
231
+ it('omits the agent name when the host gives none and drops unmeasurable durations', () => {
232
+ const samples = projectLivePromptMetricSamples(batch([
233
+ tool({ startedAt: 'not a time' }),
234
+ provider({ endedAt: '2026-10-16T00:00:00.000Z' }),
235
+ ], { root: { traceId: TRACE, spanId: ROOT, outcome: 'success', startedAt: '2026-10-17T00:00:03.000Z', endedAt: '2026-10-17T00:00:00.000Z' } }), { mode: 'gen_ai' });
236
+ expect(names(samples)).not.toContain('gen_ai.execute_tool.duration');
237
+ expect(names(samples)).not.toContain('gen_ai.client.inference.duration');
238
+ expect(names(samples)).not.toContain('gen_ai.invoke_agent.duration');
239
+ expect(only(samples, 'gen_ai.invoke_agent.tool_calls')[0]!.attributes).toEqual({});
240
+ });
241
+
242
+ it('is pure: the same batch and options project to equal samples', () => {
243
+ const input = batch([provider(), tool()]);
244
+ expect(projectLivePromptMetricSamples(input, { mode: 'gen_ai', agentName: 'robota' }))
245
+ .toEqual(projectLivePromptMetricSamples(input, { mode: 'gen_ai', agentName: 'robota' }));
246
+ });
247
+ });
@@ -0,0 +1,262 @@
1
+ import type { ILivePromptTraceBatch } from '@robota-sdk/agent-interface-analytics';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { GEN_AI_SCHEMA_URL, projectLivePromptTrace } from '../index.js';
5
+
6
+ const TRACE = '0123456789abcdef0123456789abcdef';
7
+ const ROOT = '1111111111111111';
8
+ const PROVIDER = '2222222222222222';
9
+ const TOOL = '3333333333333333';
10
+
11
+ function batch(overrides: Partial<ILivePromptTraceBatch> = {}): ILivePromptTraceBatch {
12
+ return {
13
+ schemaVersion: 1,
14
+ sessionId: 'session-1',
15
+ turnId: 'turn-1',
16
+ root: {
17
+ traceId: TRACE, spanId: ROOT, outcome: 'failure',
18
+ startedAt: '2026-10-17T00:00:00.000Z', endedAt: '2026-10-17T00:00:03.000Z',
19
+ },
20
+ timingTotals: {
21
+ provider: { durationMs: 1200, samples: 1, invalid: 0 },
22
+ tool: { durationMs: 300, samples: 1, invalid: 0 },
23
+ queue: { durationMs: 5, samples: 1, invalid: 0, admissionStarted: 1, notDispatched: 0 },
24
+ },
25
+ children: [
26
+ {
27
+ kind: 'provider',
28
+ trace: {
29
+ traceId: TRACE, parentSpanId: ROOT, spanId: PROVIDER, outcome: 'success', round: 1,
30
+ startedAt: '2026-10-17T00:00:00.100Z', endedAt: '2026-10-17T00:00:01.300Z',
31
+ disposition: 'invoked', providerId: 'anthropic', modelId: 'claude-sonnet-4-5',
32
+ usageProvenance: 'complete', promptTokens: 120, completionTokens: 30, totalTokens: 150,
33
+ providerRequestId: 'req_01',
34
+ },
35
+ },
36
+ {
37
+ kind: 'permission',
38
+ decision: { traceId: TRACE, parentSpanId: ROOT, decidedAt: '2026-10-17T00:00:01.400Z', decision: 'allowed' },
39
+ },
40
+ {
41
+ kind: 'tool',
42
+ trace: {
43
+ traceId: TRACE, parentSpanId: ROOT, spanId: TOOL, outcome: 'failure',
44
+ startedAt: '2026-10-17T00:00:01.500Z', endedAt: '2026-10-17T00:00:01.800Z',
45
+ toolCallId: 'toolu_01', toolName: 'Read',
46
+ },
47
+ },
48
+ ],
49
+ omittedChildren: { provider: 0, tool: 1, permission: 2 },
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ const ROOT_TIMING = {
55
+ 'agent.timing.provider.duration_ms': 1200,
56
+ 'agent.timing.provider.samples': 1,
57
+ 'agent.timing.provider.invalid': 0,
58
+ 'agent.timing.tool.duration_ms': 300,
59
+ 'agent.timing.tool.samples': 1,
60
+ 'agent.timing.tool.invalid': 0,
61
+ 'agent.timing.queue.duration_ms': 5,
62
+ 'agent.timing.queue.samples': 1,
63
+ 'agent.timing.queue.invalid': 0,
64
+ 'agent.timing.queue.admission_started': 1,
65
+ 'agent.timing.queue.not_dispatched': 0,
66
+ };
67
+ const ROOT_SHARED = {
68
+ 'agent.turn.id': 'turn-1',
69
+ 'agent.outcome': 'failure',
70
+ 'agent.omitted.provider_count': 0,
71
+ 'agent.omitted.tool_count': 1,
72
+ 'agent.omitted.permission_count': 2,
73
+ ...ROOT_TIMING,
74
+ };
75
+ const PROVIDER_SHARED = {
76
+ 'agent.outcome': 'success',
77
+ 'agent.provider.round': 1,
78
+ 'agent.provider.disposition': 'invoked',
79
+ 'agent.provider.id': 'anthropic',
80
+ 'agent.usage.provenance': 'complete',
81
+ 'agent.usage.total_tokens': 150,
82
+ 'agent.provider.request_id': 'req_01',
83
+ };
84
+ const PROVIDER_AGENT = {
85
+ 'agent.model.id': 'claude-sonnet-4-5',
86
+ 'agent.usage.input_tokens': 120,
87
+ 'agent.usage.output_tokens': 30,
88
+ };
89
+ const PROVIDER_GEN_AI = {
90
+ 'gen_ai.operation.name': 'chat',
91
+ 'gen_ai.provider.name': 'anthropic',
92
+ 'gen_ai.request.model': 'claude-sonnet-4-5',
93
+ 'gen_ai.usage.input_tokens': 120,
94
+ 'gen_ai.usage.output_tokens': 30,
95
+ };
96
+ const TOOL_GEN_AI = {
97
+ 'gen_ai.operation.name': 'execute_tool',
98
+ 'gen_ai.tool.name': 'Read',
99
+ 'gen_ai.tool.call.id': 'toolu_01',
100
+ };
101
+
102
+ describe('projectLivePromptTrace', () => {
103
+ it('reproduces the CLI agent.* spans in agent mode, skipping permission decisions', () => {
104
+ expect(projectLivePromptTrace(batch(), { mode: 'agent' })).toEqual({
105
+ root: {
106
+ name: 'agent.prompt_execution', kind: 'internal', traceId: TRACE, spanId: ROOT, status: 'error',
107
+ startedAt: '2026-10-17T00:00:00.000Z', endedAt: '2026-10-17T00:00:03.000Z',
108
+ attributes: { 'agent.session.id': 'session-1', ...ROOT_SHARED },
109
+ },
110
+ children: [
111
+ {
112
+ name: 'agent.provider_call', kind: 'internal', traceId: TRACE, spanId: PROVIDER, parentSpanId: ROOT,
113
+ status: 'unset', startedAt: '2026-10-17T00:00:00.100Z', endedAt: '2026-10-17T00:00:01.300Z',
114
+ attributes: { ...PROVIDER_SHARED, ...PROVIDER_AGENT },
115
+ },
116
+ {
117
+ name: 'agent.tool_body', kind: 'internal', traceId: TRACE, spanId: TOOL, parentSpanId: ROOT,
118
+ status: 'error', startedAt: '2026-10-17T00:00:01.500Z', endedAt: '2026-10-17T00:00:01.800Z',
119
+ attributes: { 'agent.outcome': 'failure', 'agent.tool.call_id': 'toolu_01' },
120
+ },
121
+ ],
122
+ });
123
+ });
124
+
125
+ it('keeps the CLI attribute order and no error.type in agent mode', () => {
126
+ const projection = projectLivePromptTrace(batch(), { mode: 'agent' });
127
+ expect(Object.keys(projection.children[0]?.attributes ?? {})).toEqual([
128
+ 'agent.outcome', 'agent.provider.round', 'agent.provider.disposition', 'agent.provider.id', 'agent.model.id',
129
+ 'agent.usage.provenance', 'agent.usage.input_tokens', 'agent.usage.output_tokens', 'agent.usage.total_tokens',
130
+ 'agent.provider.request_id',
131
+ ]);
132
+ expect(Object.keys(projection.root.attributes).slice(0, 3)).toEqual([
133
+ 'agent.session.id', 'agent.turn.id', 'agent.outcome',
134
+ ]);
135
+ expect(projection.root.attributes).not.toHaveProperty('error.type');
136
+ });
137
+
138
+ it('puts every child in the root trace, as children of the root', () => {
139
+ const base = batch();
140
+ const projection = projectLivePromptTrace(batch({
141
+ children: [{
142
+ kind: 'tool',
143
+ trace: { ...(base.children[2] as { trace: object }).trace, traceId: 'f'.repeat(32), parentSpanId: '4'.repeat(16) } as never,
144
+ }],
145
+ }), { mode: 'agent' });
146
+ expect(projection.children[0]).toMatchObject({ traceId: TRACE, parentSpanId: ROOT });
147
+ });
148
+
149
+ it('uses GenAI span names, kinds and attributes in gen_ai mode, keeping Robota-only facts as agent.*', () => {
150
+ const projection = projectLivePromptTrace(batch(), { mode: 'gen_ai', agentName: 'robota' });
151
+ expect(projection.schemaUrl).toBe(GEN_AI_SCHEMA_URL);
152
+ expect(projection.root).toMatchObject({ name: 'invoke_agent robota', kind: 'internal' });
153
+ expect(projection.root.attributes).toEqual({
154
+ 'gen_ai.operation.name': 'invoke_agent',
155
+ 'gen_ai.agent.name': 'robota',
156
+ 'gen_ai.conversation.id': 'session-1',
157
+ ...ROOT_SHARED,
158
+ 'error.type': '_OTHER',
159
+ });
160
+ const [provider, tool] = projection.children;
161
+ expect(provider).toMatchObject({ name: 'chat claude-sonnet-4-5', kind: 'client', parentSpanId: ROOT });
162
+ expect(provider?.attributes).toEqual({ ...PROVIDER_GEN_AI, ...PROVIDER_SHARED });
163
+ expect(tool).toMatchObject({ name: 'execute_tool Read', kind: 'internal', status: 'error' });
164
+ expect(tool?.attributes).toEqual({ ...TOOL_GEN_AI, 'agent.outcome': 'failure', 'error.type': '_OTHER' });
165
+ });
166
+
167
+ it('emits both attribute sets under GenAI names in gen_ai/dup mode', () => {
168
+ const projection = projectLivePromptTrace(batch(), { mode: 'gen_ai/dup', agentName: 'robota' });
169
+ expect(projection.schemaUrl).toBe(GEN_AI_SCHEMA_URL);
170
+ expect(projection.root.name).toBe('invoke_agent robota');
171
+ expect(projection.root.attributes).toEqual({
172
+ 'gen_ai.operation.name': 'invoke_agent',
173
+ 'gen_ai.agent.name': 'robota',
174
+ 'gen_ai.conversation.id': 'session-1',
175
+ 'agent.session.id': 'session-1',
176
+ ...ROOT_SHARED,
177
+ 'error.type': '_OTHER',
178
+ });
179
+ const [provider, tool] = projection.children;
180
+ expect(provider?.kind).toBe('client');
181
+ expect(provider?.attributes).toEqual({ ...PROVIDER_GEN_AI, ...PROVIDER_SHARED, ...PROVIDER_AGENT });
182
+ expect(tool?.attributes).toEqual({
183
+ ...TOOL_GEN_AI, 'agent.outcome': 'failure', 'agent.tool.call_id': 'toolu_01', 'error.type': '_OTHER',
184
+ });
185
+ });
186
+
187
+ it('falls back to bare operation names when the agent, model or tool name is unknown', () => {
188
+ const base = batch();
189
+ const projection = projectLivePromptTrace(batch({
190
+ children: [
191
+ { kind: 'provider', trace: { ...(base.children[0] as { trace: object }).trace, modelId: undefined } as never },
192
+ { kind: 'tool', trace: { ...(base.children[2] as { trace: object }).trace, toolName: 'bad name\n' } as never },
193
+ ],
194
+ }), { mode: 'gen_ai' });
195
+ expect(projection.root.name).toBe('invoke_agent');
196
+ expect(projection.root.attributes).not.toHaveProperty('gen_ai.agent.name');
197
+ expect(projection.children.map((child) => child.name)).toEqual(['chat', 'execute_tool']);
198
+ expect(projection.children[0]?.attributes).not.toHaveProperty('gen_ai.request.model');
199
+ expect(projection.children[1]?.attributes).not.toHaveProperty('gen_ai.tool.name');
200
+ });
201
+
202
+ it.each([
203
+ ['anthropic', 'anthropic'],
204
+ ['openai', 'openai'],
205
+ ['deepseek', 'deepseek'],
206
+ ['gemini', 'gcp.gen_ai'],
207
+ ['google', 'gcp.gen_ai'],
208
+ ['qwen', undefined],
209
+ ['my-gateway', undefined],
210
+ ])('names provider %s as GenAI provider %s', (providerId, expected) => {
211
+ const base = batch();
212
+ const projection = projectLivePromptTrace(batch({
213
+ children: [{ kind: 'provider', trace: { ...(base.children[0] as { trace: object }).trace, providerId } as never }],
214
+ }), { mode: 'gen_ai' });
215
+ expect(projection.children[0]?.attributes['gen_ai.provider.name']).toBe(expected);
216
+ expect(projection.children[0]?.attributes['agent.provider.id']).toBe(providerId);
217
+ });
218
+
219
+ it.each(['constructor', 'hasOwnProperty'])('does not mistake the inherited property %s for a known provider', (providerId) => {
220
+ const base = batch();
221
+ for (const mode of ['gen_ai', 'gen_ai/dup'] as const) {
222
+ const projection = projectLivePromptTrace(batch({
223
+ children: [{ kind: 'provider', trace: { ...(base.children[0] as { trace: object }).trace, providerId } as never }],
224
+ }), { mode });
225
+ expect(projection.children[0]?.attributes).not.toHaveProperty('gen_ai.provider.name');
226
+ expect(projection.children[0]?.attributes['agent.provider.id']).toBe(providerId);
227
+ }
228
+ });
229
+
230
+ it('omits token counts unless usage is complete, and drops IDs outside the opaque-ID shape', () => {
231
+ const base = batch();
232
+ const projection = projectLivePromptTrace(batch({
233
+ children: [
234
+ {
235
+ kind: 'provider',
236
+ trace: {
237
+ ...(base.children[0] as { trace: object }).trace,
238
+ usageProvenance: 'partial', providerRequestId: 'req 01\nsecret',
239
+ } as never,
240
+ },
241
+ { kind: 'tool', trace: { ...(base.children[2] as { trace: object }).trace, toolCallId: 'x'.repeat(129) } as never },
242
+ ],
243
+ }), { mode: 'gen_ai/dup' });
244
+ const [provider, tool] = projection.children;
245
+ expect(Object.keys(provider?.attributes ?? {}).filter((key) => key.includes('tokens'))).toEqual([]);
246
+ expect(provider?.attributes).not.toHaveProperty('agent.provider.request_id');
247
+ expect(tool?.attributes).not.toHaveProperty('agent.tool.call_id');
248
+ expect(tool?.attributes).not.toHaveProperty('gen_ai.tool.call.id');
249
+ });
250
+
251
+ it('drops malformed timing totals and treats an interrupted outcome as no error', () => {
252
+ const projection = projectLivePromptTrace(batch({
253
+ root: { ...batch().root, outcome: 'interrupted' },
254
+ timingTotals: { provider: { durationMs: 5, samples: 0, invalid: 0 }, tool: { durationMs: -1, samples: 1, invalid: 0 } },
255
+ children: [],
256
+ }), { mode: 'agent' });
257
+ expect(projection.root.status).toBe('unset');
258
+ expect(Object.keys(projection.root.attributes).filter((key) => key.startsWith('agent.timing.'))).toEqual([]);
259
+ expect(projection.children).toEqual([]);
260
+ expect(projection).not.toHaveProperty('schemaUrl');
261
+ });
262
+ });