@robota-sdk/agent-telemetry-otel 0.0.0-stage → 3.0.0-beta.95
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/CHANGELOG.md +54 -0
- package/LICENSE +661 -0
- package/README.md +130 -2
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +142 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +142 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +79 -3
- package/src/__tests__/metrics.test.ts +247 -0
- package/src/__tests__/projection.test.ts +262 -0
- package/src/__tests__/run-tracing.test.ts +230 -0
- package/src/index.ts +31 -0
- package/src/metrics.ts +140 -0
- package/src/projection.ts +216 -0
- package/src/run-tracing.ts +255 -0
- package/src/span-ids.ts +47 -0
- package/src/timing.ts +47 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { ROOT_CONTEXT, SpanKind, SpanStatusCode, TraceFlags, isSpanContextValid, trace } from '@opentelemetry/api';
|
|
2
|
+
import {
|
|
3
|
+
AbstractPlugin,
|
|
4
|
+
PROVIDER_CALL_EVENTS,
|
|
5
|
+
PluginCategory,
|
|
6
|
+
PluginPriority,
|
|
7
|
+
spanIdFromMintedId,
|
|
8
|
+
} from '@robota-sdk/agent-core';
|
|
9
|
+
|
|
10
|
+
import { projectTraceChild } from './projection.js';
|
|
11
|
+
|
|
12
|
+
import type { IStructuralIdGenerator } from './span-ids.js';
|
|
13
|
+
import type { IOtelSpanProjection, TOtelProjectionMode, TSpannedChild } from './projection.js';
|
|
14
|
+
import type { SpanContext, Tracer } from '@opentelemetry/api';
|
|
15
|
+
import type {
|
|
16
|
+
IRunTraceContext,
|
|
17
|
+
IToolExecutionContext,
|
|
18
|
+
IToolExecutionResult,
|
|
19
|
+
TExecutionEventCallback,
|
|
20
|
+
TSubprocessTraceClass,
|
|
21
|
+
TToolParameters,
|
|
22
|
+
} from '@robota-sdk/agent-core';
|
|
23
|
+
import type { IProviderCallTraceEntry } from '@robota-sdk/agent-interface-analytics';
|
|
24
|
+
|
|
25
|
+
export interface IOtelTracingOptions {
|
|
26
|
+
/** The host's tracer; spans are recorded on it and never exported by this package. */
|
|
27
|
+
readonly tracer: Tracer;
|
|
28
|
+
readonly mode: TOtelProjectionMode;
|
|
29
|
+
/**
|
|
30
|
+
* The generator the host gave its `TracerProvider`. With it, each span carries the ID Robota sent
|
|
31
|
+
* in `traceparent`; without it, spans get the provider's own IDs and only the trace and parent match.
|
|
32
|
+
*/
|
|
33
|
+
readonly idGenerator?: IStructuralIdGenerator;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface IOtelRunPropagation {
|
|
37
|
+
/** Exact origins that may receive `traceparent` from provider calls and tool bodies. */
|
|
38
|
+
readonly allowedOrigins?: readonly string[];
|
|
39
|
+
readonly subprocessClasses?: readonly TSubprocessTraceClass[];
|
|
40
|
+
readonly onPropagationUnavailable?: (providerId: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What one `run()` takes, plus `end()` once it has returned. */
|
|
44
|
+
export interface IOtelRunTracing {
|
|
45
|
+
/** Absent when the parent span is invalid or not sampled: the run then traces and propagates nothing. */
|
|
46
|
+
readonly traceContext?: IRunTraceContext;
|
|
47
|
+
readonly onExecutionEvent: TExecutionEventCallback;
|
|
48
|
+
/** Stop attributing tool bodies to this run. */
|
|
49
|
+
end(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IOtelTracing {
|
|
53
|
+
/** Register once in the agent's `plugins`; it records tool bodies for every traced run. */
|
|
54
|
+
readonly plugin: AbstractPlugin;
|
|
55
|
+
/** Trace one run under `parent`, typically the host's own `invoke_agent` span. */
|
|
56
|
+
startRun(parent: SpanContext, propagation?: IOtelRunPropagation): IOtelRunTracing;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
60
|
+
const LABEL = /^[A-Za-z0-9._:/-]{1,128}$/u;
|
|
61
|
+
const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/u;
|
|
62
|
+
const OUTCOMES = new Set(['success', 'failure', 'interrupted']);
|
|
63
|
+
const DISPOSITIONS = new Set(['invoked', 'cache-hit', 'preflight-refused']);
|
|
64
|
+
const PROVENANCES = new Set(['complete', 'partial', 'absent']);
|
|
65
|
+
|
|
66
|
+
type TOutcome = IProviderCallTraceEntry['outcome'];
|
|
67
|
+
|
|
68
|
+
interface IRun {
|
|
69
|
+
readonly parent: SpanContext;
|
|
70
|
+
readonly executionIds: Set<string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface IPendingToolBody {
|
|
74
|
+
readonly startedAtMs: number;
|
|
75
|
+
readonly spanId: string;
|
|
76
|
+
readonly executionId: string | undefined;
|
|
77
|
+
readonly toolCallId: string | undefined;
|
|
78
|
+
readonly signal: AbortSignal | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const text = (value: unknown, pattern: RegExp): string | undefined =>
|
|
82
|
+
typeof value === 'string' && pattern.test(value) ? value : undefined;
|
|
83
|
+
const count = (value: unknown): value is number =>
|
|
84
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
85
|
+
const instant = (value: unknown): value is string =>
|
|
86
|
+
typeof value === 'string' && !Number.isNaN(Date.parse(value));
|
|
87
|
+
|
|
88
|
+
/** A provider-call child from agent-core's content-free completion event, or undefined when malformed. */
|
|
89
|
+
function providerChild(data: Record<string, unknown>, parent: SpanContext): TSpannedChild | undefined {
|
|
90
|
+
const callId = text(data['callId'], ID);
|
|
91
|
+
const spanId = callId === undefined ? undefined : spanIdFromMintedId(callId);
|
|
92
|
+
if (spanId === undefined || !SPAN_ID.test(spanId) || !instant(data['startedAt']) || !instant(data['endedAt']) ||
|
|
93
|
+
!OUTCOMES.has(data['outcome'] as string) || !count(data['round']) || data['round'] < 1) return undefined;
|
|
94
|
+
const disposition = DISPOSITIONS.has(data['disposition'] as string)
|
|
95
|
+
? data['disposition'] as IProviderCallTraceEntry['disposition'] : undefined;
|
|
96
|
+
const provenance = PROVENANCES.has(data['usageProvenance'] as string)
|
|
97
|
+
? data['usageProvenance'] as IProviderCallTraceEntry['usageProvenance'] : undefined;
|
|
98
|
+
const complete = provenance === 'complete' &&
|
|
99
|
+
count(data['promptTokens']) && count(data['completionTokens']) && count(data['totalTokens']);
|
|
100
|
+
const providerId = text(data['providerId'], LABEL);
|
|
101
|
+
const modelId = text(data['modelId'], LABEL);
|
|
102
|
+
const providerRequestId = disposition === 'invoked' ? text(data['providerRequestId'], ID) : undefined;
|
|
103
|
+
return {
|
|
104
|
+
kind: 'provider',
|
|
105
|
+
trace: {
|
|
106
|
+
callId, traceId: parent.traceId, parentSpanId: parent.spanId, spanId,
|
|
107
|
+
startedAt: data['startedAt'], endedAt: data['endedAt'], outcome: data['outcome'] as TOutcome,
|
|
108
|
+
round: data['round'],
|
|
109
|
+
...(disposition ? { disposition } : {}),
|
|
110
|
+
...(providerId ? { providerId } : {}),
|
|
111
|
+
...(modelId ? { modelId } : {}),
|
|
112
|
+
...(provenance ? { usageProvenance: complete || provenance !== 'complete' ? provenance : 'partial' } : {}),
|
|
113
|
+
...(complete ? {
|
|
114
|
+
promptTokens: data['promptTokens'] as number,
|
|
115
|
+
completionTokens: data['completionTokens'] as number,
|
|
116
|
+
totalTokens: data['totalTokens'] as number,
|
|
117
|
+
} : {}),
|
|
118
|
+
...(providerRequestId ? { providerRequestId } : {}),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function record(tracer: Tracer, ids: IStructuralIdGenerator | undefined, parent: SpanContext,
|
|
124
|
+
span: IOtelSpanProjection): void {
|
|
125
|
+
const context = trace.setSpanContext(ROOT_CONTEXT, parent);
|
|
126
|
+
const start = (): ReturnType<Tracer['startSpan']> => tracer.startSpan(span.name, {
|
|
127
|
+
kind: span.kind === 'client' ? SpanKind.CLIENT : SpanKind.INTERNAL,
|
|
128
|
+
startTime: new Date(span.startedAt),
|
|
129
|
+
attributes: span.attributes,
|
|
130
|
+
}, context);
|
|
131
|
+
const started = ids ? ids.withSpanId(span.spanId, start) : start();
|
|
132
|
+
if (span.status === 'error') started.setStatus({ code: SpanStatusCode.ERROR });
|
|
133
|
+
started.end(new Date(span.endedAt));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The run a tool body belongs to: the innermost execution on its owner path. An agent created inside
|
|
138
|
+
* another agent's tool keeps that ancestor's path in front of its own segments.
|
|
139
|
+
*/
|
|
140
|
+
function executionIdOf(context: IToolExecutionContext | undefined): string | undefined {
|
|
141
|
+
const path = context?.ownerPath ?? [];
|
|
142
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
143
|
+
if (path[index]?.type === 'execution') return path[index]?.id;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Tracing must never change a run: a failure to record drops the span, never the operation. */
|
|
149
|
+
function guarded(observe: () => void): void {
|
|
150
|
+
try {
|
|
151
|
+
observe();
|
|
152
|
+
} catch {
|
|
153
|
+
// allow-fallback: an observer failure loses one span and leaves the run untouched.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class OtelToolBodyPlugin extends AbstractPlugin {
|
|
158
|
+
readonly name = 'OtelToolBodyPlugin';
|
|
159
|
+
readonly version = '1.0.0';
|
|
160
|
+
// Keyed by the call's parameters object: agent-core hands the same object to both hooks of one
|
|
161
|
+
// call, and the vendor tool call ID can repeat within and across runs.
|
|
162
|
+
private readonly pending = new WeakMap<TToolParameters, IPendingToolBody>();
|
|
163
|
+
|
|
164
|
+
override category = PluginCategory.MONITORING;
|
|
165
|
+
override priority: number = PluginPriority.LOW;
|
|
166
|
+
|
|
167
|
+
constructor(private readonly onBody: (body: IPendingToolBody, toolName: string, outcome: TOutcome) => void) {
|
|
168
|
+
super();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override async beforeToolCall(_toolName: string, parameters: TToolParameters,
|
|
172
|
+
context?: IToolExecutionContext): Promise<void> {
|
|
173
|
+
guarded(() => {
|
|
174
|
+
const bodyId = text(context?.toolBodyId, ID);
|
|
175
|
+
const spanId = bodyId === undefined ? undefined : spanIdFromMintedId(bodyId);
|
|
176
|
+
if (spanId === undefined || !SPAN_ID.test(spanId)) return;
|
|
177
|
+
this.pending.set(parameters, {
|
|
178
|
+
startedAtMs: Date.now(), spanId, executionId: executionIdOf(context),
|
|
179
|
+
toolCallId: text(context?.executionId, ID), signal: context?.signal,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override async afterToolCall(toolName: string, parameters: TToolParameters,
|
|
185
|
+
result: IToolExecutionResult): Promise<void> {
|
|
186
|
+
guarded(() => {
|
|
187
|
+
const body = this.pending.get(parameters);
|
|
188
|
+
if (!body) return;
|
|
189
|
+
this.pending.delete(parameters);
|
|
190
|
+
this.onBody(body, toolName, body.signal?.aborted ? 'interrupted' : result.success ? 'success' : 'failure');
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Record a Robota agent's provider calls and tool bodies as OpenTelemetry spans on the host's tracer,
|
|
197
|
+
* named by `mode` exactly as {@link projectLivePromptTrace} names them.
|
|
198
|
+
*/
|
|
199
|
+
export function createOtelTracing(options: IOtelTracingOptions): IOtelTracing {
|
|
200
|
+
const { tracer, mode, idGenerator } = options;
|
|
201
|
+
const runs = new Map<string, IRun>();
|
|
202
|
+
const plugin = new OtelToolBodyPlugin((body, toolName, outcome) => {
|
|
203
|
+
const run = body.executionId === undefined ? undefined : runs.get(body.executionId);
|
|
204
|
+
if (!run) return;
|
|
205
|
+
const startedAt = new Date(body.startedAtMs).toISOString();
|
|
206
|
+
const child: TSpannedChild = {
|
|
207
|
+
kind: 'tool',
|
|
208
|
+
trace: {
|
|
209
|
+
traceId: run.parent.traceId, parentSpanId: run.parent.spanId, spanId: body.spanId,
|
|
210
|
+
startedAt, endedAt: new Date(Math.max(Date.now(), body.startedAtMs)).toISOString(), outcome,
|
|
211
|
+
...(body.toolCallId ? { toolCallId: body.toolCallId } : {}),
|
|
212
|
+
...(text(toolName, LABEL) ? { toolName } : {}),
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
record(tracer, idGenerator, run.parent, projectTraceChild(child, run.parent.traceId, run.parent.spanId, mode));
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
plugin,
|
|
219
|
+
startRun(parent, propagation = {}) {
|
|
220
|
+
// Robota always sends a sampled `traceparent`; under a parent the host dropped, recipients would
|
|
221
|
+
// record spans for a trace that does not exist, so nothing is propagated or recorded.
|
|
222
|
+
if (!isSpanContextValid(parent) || (parent.traceFlags & TraceFlags.SAMPLED) === 0) {
|
|
223
|
+
return { onExecutionEvent: () => undefined, end: () => undefined };
|
|
224
|
+
}
|
|
225
|
+
const run: IRun = { parent, executionIds: new Set() };
|
|
226
|
+
return {
|
|
227
|
+
traceContext: {
|
|
228
|
+
traceId: parent.traceId,
|
|
229
|
+
parentSpanId: parent.spanId,
|
|
230
|
+
allowedOrigins: [...(propagation.allowedOrigins ?? [])],
|
|
231
|
+
...(propagation.subprocessClasses ? { subprocessClasses: [...propagation.subprocessClasses] } : {}),
|
|
232
|
+
...(propagation.onPropagationUnavailable
|
|
233
|
+
? { onPropagationUnavailable: propagation.onPropagationUnavailable } : {}),
|
|
234
|
+
},
|
|
235
|
+
onExecutionEvent(event, data) {
|
|
236
|
+
guarded(() => {
|
|
237
|
+
// Every run event names its execution before any tool body of it is dispatched.
|
|
238
|
+
const executionId = data['executionId'];
|
|
239
|
+
if (typeof executionId === 'string' && !run.executionIds.has(executionId)) {
|
|
240
|
+
run.executionIds.add(executionId);
|
|
241
|
+
runs.set(executionId, run);
|
|
242
|
+
}
|
|
243
|
+
if (event !== PROVIDER_CALL_EVENTS.COMPLETED) return;
|
|
244
|
+
const child = providerChild(data, parent);
|
|
245
|
+
if (child) record(tracer, idGenerator, parent, projectTraceChild(child, parent.traceId, parent.spanId, mode));
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
end() {
|
|
249
|
+
for (const executionId of run.executionIds) if (runs.get(executionId) === run) runs.delete(executionId);
|
|
250
|
+
run.executionIds.clear();
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
package/src/span-ids.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenTelemetry SDK `IdGenerator` shape, declared structurally so this package does not depend
|
|
3
|
+
* on an SDK version.
|
|
4
|
+
*/
|
|
5
|
+
export interface IOtelIdGenerator {
|
|
6
|
+
generateTraceId(): string;
|
|
7
|
+
generateSpanId(): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An `IdGenerator` for a host's `TracerProvider` that can hand the next span a chosen ID. Robota
|
|
12
|
+
* derives each provider call's and tool body's span ID before the operation runs and sends it in
|
|
13
|
+
* `traceparent`; recording the span under that same ID is what lets a recipient's spans join the
|
|
14
|
+
* trace. Every other span gets a random ID.
|
|
15
|
+
*/
|
|
16
|
+
export interface IStructuralIdGenerator extends IOtelIdGenerator {
|
|
17
|
+
/** Run `start` with `spanId` as the ID of the one span it starts. */
|
|
18
|
+
withSpanId<T>(spanId: string, start: () => T): T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function randomHex(bytes: number): string {
|
|
22
|
+
const values = new Uint8Array(bytes);
|
|
23
|
+
// A valid ID is never all zeros; retry the one-in-2^64 draw that is.
|
|
24
|
+
do globalThis.crypto.getRandomValues(values); while (values.every((value) => value === 0));
|
|
25
|
+
return Array.from(values, (value) => value.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createStructuralIdGenerator(): IStructuralIdGenerator {
|
|
29
|
+
let next: string | undefined;
|
|
30
|
+
return {
|
|
31
|
+
generateTraceId: () => randomHex(16),
|
|
32
|
+
generateSpanId() {
|
|
33
|
+
const chosen = next;
|
|
34
|
+
next = undefined;
|
|
35
|
+
return chosen ?? randomHex(8);
|
|
36
|
+
},
|
|
37
|
+
withSpanId(spanId, start) {
|
|
38
|
+
next = spanId;
|
|
39
|
+
try {
|
|
40
|
+
return start();
|
|
41
|
+
} finally {
|
|
42
|
+
// A tracer that never asked for an ID (a no-op tracer) must not pass it to the next span.
|
|
43
|
+
next = undefined;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ILivePromptDurationTotal, ILivePromptQueueSummary } from '@robota-sdk/agent-interface-analytics';
|
|
2
|
+
|
|
3
|
+
interface IProjectedTimingTotals {
|
|
4
|
+
provider?: ILivePromptDurationTotal;
|
|
5
|
+
tool?: ILivePromptDurationTotal;
|
|
6
|
+
queue?: ILivePromptQueueSummary;
|
|
7
|
+
}
|
|
8
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
9
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
10
|
+
const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
11
|
+
function duration(value: unknown): ILivePromptDurationTotal | undefined {
|
|
12
|
+
const data = record(value);
|
|
13
|
+
if (!data || !count(data.durationMs) || !count(data.samples) || !count(data.invalid) ||
|
|
14
|
+
(data.samples === 0 && data.durationMs !== 0)) return undefined;
|
|
15
|
+
return { durationMs: data.durationMs, samples: data.samples, invalid: data.invalid };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Allowlisted numeric evidence only, even when an embedding host passes an untyped batch. */
|
|
19
|
+
function projectTimingTotals(value: unknown): IProjectedTimingTotals | undefined {
|
|
20
|
+
const data = record(value);
|
|
21
|
+
if (!data) return undefined;
|
|
22
|
+
const provider = duration(data.provider), tool = duration(data.tool), queueDuration = duration(data.queue);
|
|
23
|
+
const queueData = record(data.queue);
|
|
24
|
+
const queue = queueDuration && queueData && count(queueData.admissionStarted) && count(queueData.notDispatched) &&
|
|
25
|
+
queueData.admissionStarted + queueData.notDispatched === queueDuration.samples
|
|
26
|
+
? { ...queueDuration, admissionStarted: queueData.admissionStarted, notDispatched: queueData.notDispatched } : undefined;
|
|
27
|
+
if (!provider && !tool && !queue) return undefined;
|
|
28
|
+
return { ...(provider ? { provider } : {}), ...(tool ? { tool } : {}), ...(queue ? { queue } : {}) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The prompt's timing totals as `agent.timing.*` attributes; GenAI conventions have no equivalent. */
|
|
32
|
+
export function timingAttributes(value: unknown): Record<string, number> {
|
|
33
|
+
const totals = projectTimingTotals(value);
|
|
34
|
+
const attributes: Record<string, number> = {};
|
|
35
|
+
for (const kind of ['provider', 'tool', 'queue'] as const) {
|
|
36
|
+
const total = totals?.[kind];
|
|
37
|
+
if (!total) continue;
|
|
38
|
+
attributes[`agent.timing.${kind}.duration_ms`] = total.durationMs;
|
|
39
|
+
attributes[`agent.timing.${kind}.samples`] = total.samples;
|
|
40
|
+
attributes[`agent.timing.${kind}.invalid`] = total.invalid;
|
|
41
|
+
}
|
|
42
|
+
if (totals?.queue) {
|
|
43
|
+
attributes['agent.timing.queue.admission_started'] = totals.queue.admissionStarted;
|
|
44
|
+
attributes['agent.timing.queue.not_dispatched'] = totals.queue.notDispatched;
|
|
45
|
+
}
|
|
46
|
+
return attributes;
|
|
47
|
+
}
|