@noetaris/harness-otel 0.2.0 → 0.3.0
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/dist/index.d.ts +27 -14
- package/dist/index.js +67 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -5,36 +5,49 @@ import { Observer } from '@noetaris/harness';
|
|
|
5
5
|
* Options for {@link createOtelObserver}.
|
|
6
6
|
*/
|
|
7
7
|
interface OtelObserverOptions {
|
|
8
|
-
/** OTel MeterProvider
|
|
8
|
+
/** OTel MeterProvider for metrics. If absent, metrics are skipped. */
|
|
9
9
|
meterProvider?: MeterProvider;
|
|
10
10
|
/**
|
|
11
|
-
* Explicit parent context for the root span. When provided, the root
|
|
12
|
-
*
|
|
13
|
-
* When absent, `context.active()` is used (ambient context from OTel middleware).
|
|
11
|
+
* Explicit parent context for the root span. When provided, the root span is
|
|
12
|
+
* created as a child of this context. When absent, `context.active()` is used.
|
|
14
13
|
*/
|
|
15
14
|
parentContext?: Context;
|
|
15
|
+
/** Extra attributes merged onto the root span; built-in attributes win on conflict. */
|
|
16
|
+
attributes?: Attributes;
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* When `true`, serialises `messages` from `"llm.request"` into a
|
|
19
|
+
* `gen_ai.content.prompt` span event. Default: `false`.
|
|
19
20
|
*/
|
|
20
|
-
|
|
21
|
+
captureInputs?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* When `true`, serialises `output` from `"llm.response"` into a
|
|
24
|
+
* `gen_ai.content.completion` span event. Default: `false`.
|
|
25
|
+
*/
|
|
26
|
+
captureOutputs?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* When `true`, serialises `input` / `result` from `"tool.call"` / `"tool.result"`
|
|
29
|
+
* into `gen_ai.tool.input` / `gen_ai.tool.output` span events. Default: `false`.
|
|
30
|
+
*/
|
|
31
|
+
captureToolIO?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Maximum characters for any serialised content-capture payload.
|
|
34
|
+
* Payloads longer than this are truncated with a `…` suffix. Default: `8192`.
|
|
35
|
+
*/
|
|
36
|
+
maxContentLength?: number;
|
|
21
37
|
}
|
|
22
38
|
/**
|
|
23
39
|
* Create an {@link Observer} that records traces and metrics via OpenTelemetry
|
|
24
40
|
* following the GenAI semantic conventions.
|
|
25
41
|
*
|
|
26
42
|
* **Spans produced:**
|
|
27
|
-
* - `invoke_agent {agentId}` — root span, one per `agent.run()
|
|
28
|
-
* - `harness.step {stepName}` — child span, one per step
|
|
29
|
-
* - `chat {modelId}` — INTERNAL child span,
|
|
30
|
-
* - `execute_tool {toolName}` — INTERNAL child span, one per `"tool.call"
|
|
43
|
+
* - `invoke_agent {agentId}` — root span, one per `agent.run()`.
|
|
44
|
+
* - `harness.step {stepName}` — child span, one per step.
|
|
45
|
+
* - `chat {modelId}` — INTERNAL child span, opened on `"llm.request"`, closed on `"llm.response"`.
|
|
46
|
+
* - `execute_tool {toolName}` — INTERNAL child span, one per `"tool.call"`.
|
|
31
47
|
*
|
|
32
48
|
* **Metrics produced** (requires `options.meterProvider`):
|
|
33
49
|
* - `gen_ai.client.token.usage` (histogram, `{token}`) — input/output tokens per inference call.
|
|
34
50
|
* - `gen_ai.client.operation.duration` (histogram, `s`) — agent invocation duration.
|
|
35
|
-
*
|
|
36
|
-
* @param tracer - An OTel `Tracer` instance from your SDK.
|
|
37
|
-
* @param options - Optional meter provider, parent context, and extra span attributes.
|
|
38
51
|
*/
|
|
39
52
|
declare function createOtelObserver(tracer: Tracer, options?: OtelObserverOptions): Observer;
|
|
40
53
|
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,14 @@ function createOtelObserver(tracer, options) {
|
|
|
4
4
|
let rootSpan;
|
|
5
5
|
let stepSpan;
|
|
6
6
|
const toolSpans = /* @__PURE__ */ new Map();
|
|
7
|
+
const inferenceSpans = /* @__PURE__ */ new Map();
|
|
8
|
+
let llmCallCounter = 0;
|
|
7
9
|
let tokenHistogram;
|
|
8
10
|
let durationHistogram;
|
|
11
|
+
const maxLen = options?.maxContentLength ?? 8192;
|
|
12
|
+
function truncate(s) {
|
|
13
|
+
return s.length > maxLen ? s.slice(0, maxLen) + "\u2026" : s;
|
|
14
|
+
}
|
|
9
15
|
if (options?.meterProvider) {
|
|
10
16
|
const meter = options.meterProvider.getMeter("@noetaris/harness-otel", "0.1.0");
|
|
11
17
|
tokenHistogram = meter.createHistogram("gen_ai.client.token.usage", { unit: "{token}" });
|
|
@@ -29,9 +35,14 @@ function createOtelObserver(tracer, options) {
|
|
|
29
35
|
durationHistogram?.record(event.durationMs / 1e3, { "gen_ai.operation.name": "invoke_agent" });
|
|
30
36
|
},
|
|
31
37
|
onStepStart(ctx) {
|
|
38
|
+
llmCallCounter = 0;
|
|
32
39
|
if (!rootSpan) return;
|
|
33
40
|
const childCtx = trace.setSpan(context.active(), rootSpan);
|
|
34
|
-
stepSpan = tracer.startSpan(
|
|
41
|
+
stepSpan = tracer.startSpan(
|
|
42
|
+
`harness.step ${ctx.stepName}`,
|
|
43
|
+
{ attributes: { "gen_ai.step.name": ctx.stepName } },
|
|
44
|
+
childCtx
|
|
45
|
+
);
|
|
35
46
|
},
|
|
36
47
|
onStepEnd(_ctx, _event) {
|
|
37
48
|
if (!stepSpan) return;
|
|
@@ -47,27 +58,56 @@ function createOtelObserver(tracer, options) {
|
|
|
47
58
|
span.end();
|
|
48
59
|
},
|
|
49
60
|
onEvent(_ctx, type, payload) {
|
|
50
|
-
if (type === "llm.
|
|
61
|
+
if (type === "llm.request") {
|
|
51
62
|
const shaped = payload;
|
|
63
|
+
if (typeof shaped?.modelId !== "string" || typeof shaped?.providerName !== "string") return;
|
|
52
64
|
const parentSpan = stepSpan ?? rootSpan;
|
|
53
|
-
if (parentSpan
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
inferenceSpan.
|
|
61
|
-
}
|
|
62
|
-
if (typeof shaped?.stopReason === "string") {
|
|
63
|
-
inferenceSpan.setAttribute("gen_ai.response.finish_reasons", [shaped.stopReason]);
|
|
65
|
+
if (!parentSpan) return;
|
|
66
|
+
const childCtx = trace.setSpan(context.active(), parentSpan);
|
|
67
|
+
const inferenceSpan = tracer.startSpan(`chat ${shaped.modelId}`, { kind: SpanKind.INTERNAL }, childCtx);
|
|
68
|
+
llmCallCounter++;
|
|
69
|
+
inferenceSpans.set(llmCallCounter, inferenceSpan);
|
|
70
|
+
if (options?.captureInputs && shaped.messages !== void 0) {
|
|
71
|
+
try {
|
|
72
|
+
inferenceSpan.addEvent("gen_ai.content.prompt", { "gen_ai.prompt": truncate(JSON.stringify(shaped.messages)) });
|
|
73
|
+
} catch {
|
|
64
74
|
}
|
|
65
|
-
inferenceSpan.end();
|
|
66
75
|
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (type === "llm.response") {
|
|
79
|
+
const shaped = payload;
|
|
67
80
|
if (tokenHistogram && typeof shaped?.tokens?.input === "number" && typeof shaped?.tokens?.output === "number") {
|
|
68
81
|
tokenHistogram.record(shaped.tokens.input, { "gen_ai.token.type": "input" });
|
|
69
82
|
tokenHistogram.record(shaped.tokens.output, { "gen_ai.token.type": "output" });
|
|
70
83
|
}
|
|
84
|
+
if (typeof shaped?.modelId !== "string" || typeof shaped?.providerName !== "string") return;
|
|
85
|
+
let inferenceSpan = inferenceSpans.get(llmCallCounter);
|
|
86
|
+
if (inferenceSpan) {
|
|
87
|
+
inferenceSpans.delete(llmCallCounter);
|
|
88
|
+
llmCallCounter--;
|
|
89
|
+
} else {
|
|
90
|
+
const parentSpan = stepSpan ?? rootSpan;
|
|
91
|
+
if (!parentSpan) return;
|
|
92
|
+
const childCtx = trace.setSpan(context.active(), parentSpan);
|
|
93
|
+
inferenceSpan = tracer.startSpan(`chat ${shaped.modelId}`, { kind: SpanKind.INTERNAL }, childCtx);
|
|
94
|
+
}
|
|
95
|
+
inferenceSpan.setAttribute("gen_ai.request.model", shaped.modelId);
|
|
96
|
+
inferenceSpan.setAttribute("gen_ai.provider.name", shaped.providerName);
|
|
97
|
+
if (typeof shaped?.tokens?.input === "number" && typeof shaped?.tokens?.output === "number") {
|
|
98
|
+
inferenceSpan.setAttribute("gen_ai.usage.input_tokens", shaped.tokens.input);
|
|
99
|
+
inferenceSpan.setAttribute("gen_ai.usage.output_tokens", shaped.tokens.output);
|
|
100
|
+
}
|
|
101
|
+
if (typeof shaped?.stopReason === "string") {
|
|
102
|
+
inferenceSpan.setAttribute("gen_ai.response.finish_reasons", [shaped.stopReason]);
|
|
103
|
+
}
|
|
104
|
+
if (options?.captureOutputs && shaped.output !== void 0) {
|
|
105
|
+
try {
|
|
106
|
+
inferenceSpan.addEvent("gen_ai.content.completion", { "gen_ai.completion": truncate(JSON.stringify(shaped.output)) });
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
inferenceSpan.end();
|
|
71
111
|
return;
|
|
72
112
|
}
|
|
73
113
|
if (type === "tool.call") {
|
|
@@ -78,12 +118,15 @@ function createOtelObserver(tracer, options) {
|
|
|
78
118
|
const childCtx = trace.setSpan(context.active(), parentSpan);
|
|
79
119
|
const toolSpan = tracer.startSpan("execute_tool " + shaped.toolName, {
|
|
80
120
|
kind: SpanKind.INTERNAL,
|
|
81
|
-
attributes: {
|
|
82
|
-
"gen_ai.tool.name": shaped.toolName,
|
|
83
|
-
"gen_ai.operation.name": "execute_tool"
|
|
84
|
-
}
|
|
121
|
+
attributes: { "gen_ai.tool.name": shaped.toolName, "gen_ai.operation.name": "execute_tool" }
|
|
85
122
|
}, childCtx);
|
|
86
123
|
toolSpans.set(shaped.toolCallId, toolSpan);
|
|
124
|
+
if (options?.captureToolIO && shaped.input !== void 0) {
|
|
125
|
+
try {
|
|
126
|
+
toolSpan.addEvent("gen_ai.tool.input", { "gen_ai.tool.input": truncate(JSON.stringify(shaped.input)) });
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
87
130
|
return;
|
|
88
131
|
}
|
|
89
132
|
if (type === "tool.result") {
|
|
@@ -95,6 +138,12 @@ function createOtelObserver(tracer, options) {
|
|
|
95
138
|
if (shaped.error !== void 0) {
|
|
96
139
|
toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(shaped.error) });
|
|
97
140
|
}
|
|
141
|
+
if (options?.captureToolIO && shaped.result !== void 0) {
|
|
142
|
+
try {
|
|
143
|
+
toolSpan.addEvent("gen_ai.tool.output", { "gen_ai.tool.output": truncate(JSON.stringify(shaped.result)) });
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
98
147
|
toolSpan.end();
|
|
99
148
|
return;
|
|
100
149
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/observer/otel-observer.ts"],"sourcesContent":["import type { Tracer, MeterProvider, Context, Attributes, Span, Histogram } from '@opentelemetry/api'\nimport { SpanStatusCode, SpanKind, context, trace } from '@opentelemetry/api'\nimport type { Observer, RunContext, StepContext } from '@noetaris/harness'\n\n/**\n * Options for {@link createOtelObserver}.\n */\nexport interface OtelObserverOptions {\n /** OTel MeterProvider to use for metrics. If absent, metrics are skipped. */\n meterProvider?: MeterProvider\n /**\n * Explicit parent context for the root span. When provided, the root\n * \"invoke_agent {agentId}\" span is created as a child of the span in this context.\n * When absent, `context.active()` is used (ambient context from OTel middleware).\n */\n parentContext?: Context\n /**\n * Extra attributes merged onto the root span at start time.\n * These are merged with the built-in attributes; built-in attributes take precedence.\n */\n attributes?: Attributes\n}\n\n// Local shape guards — avoid importing @noetaris/harness-types\ntype LLMUsageShape = {\n modelId?: unknown\n providerName?: unknown\n stopReason?: unknown\n tokens?: { input?: unknown; output?: unknown } | null\n}\ntype ToolCallShape = { toolName?: unknown; toolCallId?: unknown }\ntype ToolResultShape = { toolName?: unknown; toolCallId?: unknown; durationMs?: unknown; error?: unknown }\n\n/**\n * Create an {@link Observer} that records traces and metrics via OpenTelemetry\n * following the GenAI semantic conventions.\n *\n * **Spans produced:**\n * - `invoke_agent {agentId}` — root span, one per `agent.run()` invocation.\n * - `harness.step {stepName}` — child span, one per step execution.\n * - `chat {modelId}` — INTERNAL child span, one per `\"llm.response\"` event.\n * - `execute_tool {toolName}` — INTERNAL child span, one per `\"tool.call\"` event.\n *\n * **Metrics produced** (requires `options.meterProvider`):\n * - `gen_ai.client.token.usage` (histogram, `{token}`) — input/output tokens per inference call.\n * - `gen_ai.client.operation.duration` (histogram, `s`) — agent invocation duration.\n *\n * @param tracer - An OTel `Tracer` instance from your SDK.\n * @param options - Optional meter provider, parent context, and extra span attributes.\n */\nexport function createOtelObserver(tracer: Tracer, options?: OtelObserverOptions): Observer {\n let rootSpan: Span | undefined\n let stepSpan: Span | undefined\n const toolSpans = new Map<string, Span>()\n\n let tokenHistogram: Histogram | undefined\n let durationHistogram: Histogram | undefined\n\n if (options?.meterProvider) {\n const meter = options.meterProvider.getMeter('@noetaris/harness-otel', '0.1.0')\n tokenHistogram = meter.createHistogram('gen_ai.client.token.usage', { unit: '{token}' })\n durationHistogram = meter.createHistogram('gen_ai.client.operation.duration', { unit: 's' })\n }\n\n return {\n onRunStart(ctx: RunContext): void {\n const builtIn: Attributes = {\n 'gen_ai.agent.id': ctx.agentId,\n 'gen_ai.conversation.id': ctx.sessionId,\n 'gen_ai.operation.name': 'invoke_agent',\n }\n const merged: Attributes = { ...options?.attributes, ...builtIn }\n const parentCtx = options?.parentContext ?? context.active()\n rootSpan = tracer.startSpan(`invoke_agent ${ctx.agentId}`, { attributes: merged }, parentCtx)\n },\n\n onRunEnd(_ctx: RunContext, event: { signal: string; durationMs: number }): void {\n if (!rootSpan) return\n rootSpan.end()\n rootSpan = undefined\n durationHistogram?.record(event.durationMs / 1000, { 'gen_ai.operation.name': 'invoke_agent' })\n },\n\n onStepStart(ctx: StepContext): void {\n if (!rootSpan) return\n const childCtx = trace.setSpan(context.active(), rootSpan)\n stepSpan = tracer.startSpan(`harness.step ${ctx.stepName}`, { attributes: { 'gen_ai.step.name': ctx.stepName } }, childCtx)\n },\n\n onStepEnd(_ctx: StepContext, _event: { durationMs: number }): void {\n if (!stepSpan) return\n const span = stepSpan\n stepSpan = undefined\n span.end()\n },\n\n onStepError(_ctx: StepContext, event: { error: unknown; durationMs: number }): void {\n if (!stepSpan) return\n const span = stepSpan\n stepSpan = undefined\n span.setStatus({ code: SpanStatusCode.ERROR, message: String(event.error) })\n span.end()\n },\n\n onEvent(_ctx: StepContext, type: string, payload: unknown): void {\n if (type === 'llm.response') {\n const shaped = payload as LLMUsageShape // as: payload is unknown; guards below validate the shape before use\n\n // inference span — requires string modelId and providerName\n const parentSpan = stepSpan ?? rootSpan\n if (parentSpan && typeof shaped?.modelId === 'string' && typeof shaped?.providerName === 'string') {\n const childCtx = trace.setSpan(context.active(), parentSpan)\n const inferenceSpan = tracer.startSpan(`chat ${shaped.modelId}`, { kind: SpanKind.INTERNAL }, childCtx)\n inferenceSpan.setAttribute('gen_ai.request.model', shaped.modelId)\n inferenceSpan.setAttribute('gen_ai.provider.name', shaped.providerName)\n if (typeof shaped?.tokens?.input === 'number' && typeof shaped?.tokens?.output === 'number') {\n inferenceSpan.setAttribute('gen_ai.usage.input_tokens', shaped.tokens.input)\n inferenceSpan.setAttribute('gen_ai.usage.output_tokens', shaped.tokens.output)\n }\n if (typeof shaped?.stopReason === 'string') {\n inferenceSpan.setAttribute('gen_ai.response.finish_reasons', [shaped.stopReason])\n }\n inferenceSpan.end()\n }\n\n // token histogram — independent of span creation\n if (tokenHistogram && typeof shaped?.tokens?.input === 'number' && typeof shaped?.tokens?.output === 'number') {\n tokenHistogram.record(shaped.tokens.input, { 'gen_ai.token.type': 'input' })\n tokenHistogram.record(shaped.tokens.output, { 'gen_ai.token.type': 'output' })\n }\n return\n }\n\n if (type === 'tool.call') {\n const shaped = payload as ToolCallShape // as: payload is unknown; guard below validates the shape before use\n if (typeof shaped?.toolName !== 'string' || typeof shaped?.toolCallId !== 'string') return\n\n const parentSpan = stepSpan ?? rootSpan\n if (!parentSpan) return\n\n const childCtx = trace.setSpan(context.active(), parentSpan)\n const toolSpan = tracer.startSpan('execute_tool ' + shaped.toolName, {\n kind: SpanKind.INTERNAL,\n attributes: {\n 'gen_ai.tool.name': shaped.toolName,\n 'gen_ai.operation.name': 'execute_tool',\n },\n }, childCtx)\n toolSpans.set(shaped.toolCallId, toolSpan)\n return\n }\n\n if (type === 'tool.result') {\n const shaped = payload as ToolResultShape // as: payload is unknown; guard below validates the shape before use\n if (typeof shaped?.toolCallId !== 'string') return\n\n const toolSpan = toolSpans.get(shaped.toolCallId)\n if (!toolSpan) return\n\n toolSpans.delete(shaped.toolCallId)\n\n if (shaped.error !== undefined) {\n toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(shaped.error) })\n }\n toolSpan.end()\n return\n }\n\n const activeSpan = stepSpan ?? rootSpan\n activeSpan?.addEvent(type)\n },\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/observer/otel-observer.ts"],"sourcesContent":["import type { Tracer, MeterProvider, Context, Attributes, Span, Histogram } from '@opentelemetry/api'\nimport { SpanStatusCode, SpanKind, context, trace } from '@opentelemetry/api'\nimport type { Observer, RunContext, StepContext } from '@noetaris/harness'\n\n/**\n * Options for {@link createOtelObserver}.\n */\nexport interface OtelObserverOptions {\n /** OTel MeterProvider for metrics. If absent, metrics are skipped. */\n meterProvider?: MeterProvider\n /**\n * Explicit parent context for the root span. When provided, the root span is\n * created as a child of this context. When absent, `context.active()` is used.\n */\n parentContext?: Context\n /** Extra attributes merged onto the root span; built-in attributes win on conflict. */\n attributes?: Attributes\n /**\n * When `true`, serialises `messages` from `\"llm.request\"` into a\n * `gen_ai.content.prompt` span event. Default: `false`.\n */\n captureInputs?: boolean\n /**\n * When `true`, serialises `output` from `\"llm.response\"` into a\n * `gen_ai.content.completion` span event. Default: `false`.\n */\n captureOutputs?: boolean\n /**\n * When `true`, serialises `input` / `result` from `\"tool.call\"` / `\"tool.result\"`\n * into `gen_ai.tool.input` / `gen_ai.tool.output` span events. Default: `false`.\n */\n captureToolIO?: boolean\n /**\n * Maximum characters for any serialised content-capture payload.\n * Payloads longer than this are truncated with a `…` suffix. Default: `8192`.\n */\n maxContentLength?: number\n}\n\n// Local shape guards — avoid importing @noetaris/harness-types\ntype LLMRequestShape = { modelId?: unknown; providerName?: unknown; messages?: unknown }\ntype LLMResponseShape = { modelId?: unknown; providerName?: unknown; stopReason?: unknown; tokens?: { input?: unknown; output?: unknown } | null; output?: unknown }\ntype ToolCallShape = { toolName?: unknown; toolCallId?: unknown; input?: unknown }\ntype ToolResultShape = { toolCallId?: unknown; error?: unknown; result?: unknown }\n\n/**\n * Create an {@link Observer} that records traces and metrics via OpenTelemetry\n * following the GenAI semantic conventions.\n *\n * **Spans produced:**\n * - `invoke_agent {agentId}` — root span, one per `agent.run()`.\n * - `harness.step {stepName}` — child span, one per step.\n * - `chat {modelId}` — INTERNAL child span, opened on `\"llm.request\"`, closed on `\"llm.response\"`.\n * - `execute_tool {toolName}` — INTERNAL child span, one per `\"tool.call\"`.\n *\n * **Metrics produced** (requires `options.meterProvider`):\n * - `gen_ai.client.token.usage` (histogram, `{token}`) — input/output tokens per inference call.\n * - `gen_ai.client.operation.duration` (histogram, `s`) — agent invocation duration.\n */\nexport function createOtelObserver(tracer: Tracer, options?: OtelObserverOptions): Observer {\n let rootSpan: Span | undefined\n let stepSpan: Span | undefined\n const toolSpans = new Map<string, Span>()\n const inferenceSpans = new Map<number, Span>()\n let llmCallCounter = 0\n\n let tokenHistogram: Histogram | undefined\n let durationHistogram: Histogram | undefined\n\n const maxLen = options?.maxContentLength ?? 8192\n\n function truncate(s: string): string {\n return s.length > maxLen ? s.slice(0, maxLen) + '…' : s\n }\n\n if (options?.meterProvider) {\n const meter = options.meterProvider.getMeter('@noetaris/harness-otel', '0.1.0')\n tokenHistogram = meter.createHistogram('gen_ai.client.token.usage', { unit: '{token}' })\n durationHistogram = meter.createHistogram('gen_ai.client.operation.duration', { unit: 's' })\n }\n\n return {\n onRunStart(ctx: RunContext): void {\n const builtIn: Attributes = {\n 'gen_ai.agent.id': ctx.agentId,\n 'gen_ai.conversation.id': ctx.sessionId,\n 'gen_ai.operation.name': 'invoke_agent',\n }\n const merged = { ...options?.attributes, ...builtIn }\n const parentCtx = options?.parentContext ?? context.active()\n rootSpan = tracer.startSpan(`invoke_agent ${ctx.agentId}`, { attributes: merged }, parentCtx)\n },\n\n onRunEnd(_ctx: RunContext, event: { signal: string; durationMs: number }): void {\n if (!rootSpan) return\n rootSpan.end()\n rootSpan = undefined\n durationHistogram?.record(event.durationMs / 1000, { 'gen_ai.operation.name': 'invoke_agent' })\n },\n\n onStepStart(ctx: StepContext): void {\n llmCallCounter = 0 // unconditional reset — even when no root span is active\n if (!rootSpan) return\n const childCtx = trace.setSpan(context.active(), rootSpan)\n stepSpan = tracer.startSpan(\n `harness.step ${ctx.stepName}`,\n { attributes: { 'gen_ai.step.name': ctx.stepName } },\n childCtx,\n )\n },\n\n onStepEnd(_ctx: StepContext, _event: { durationMs: number }): void {\n if (!stepSpan) return\n const span = stepSpan\n stepSpan = undefined\n span.end()\n },\n\n onStepError(_ctx: StepContext, event: { error: unknown; durationMs: number }): void {\n if (!stepSpan) return\n const span = stepSpan\n stepSpan = undefined\n span.setStatus({ code: SpanStatusCode.ERROR, message: String(event.error) })\n span.end()\n },\n\n onEvent(_ctx: StepContext, type: string, payload: unknown): void {\n // ── \"llm.request\" — open inference span ──────────────────────────────────\n if (type === 'llm.request') {\n const shaped = payload as LLMRequestShape\n if (typeof shaped?.modelId !== 'string' || typeof shaped?.providerName !== 'string') return\n\n const parentSpan = stepSpan ?? rootSpan\n if (!parentSpan) return\n\n const childCtx = trace.setSpan(context.active(), parentSpan)\n const inferenceSpan = tracer.startSpan(`chat ${shaped.modelId}`, { kind: SpanKind.INTERNAL }, childCtx)\n llmCallCounter++\n inferenceSpans.set(llmCallCounter, inferenceSpan)\n\n if (options?.captureInputs && shaped.messages !== undefined) {\n try {\n inferenceSpan.addEvent('gen_ai.content.prompt', { 'gen_ai.prompt': truncate(JSON.stringify(shaped.messages)) })\n } catch { /* non-serialisable payload — skip event */ }\n }\n return\n }\n\n // ── \"llm.response\" — close inference span ────────────────────────────────\n if (type === 'llm.response') {\n const shaped = payload as LLMResponseShape\n\n // token histogram — independent of span logic\n if (tokenHistogram && typeof shaped?.tokens?.input === 'number' && typeof shaped?.tokens?.output === 'number') {\n tokenHistogram.record(shaped.tokens.input, { 'gen_ai.token.type': 'input' })\n tokenHistogram.record(shaped.tokens.output, { 'gen_ai.token.type': 'output' })\n }\n\n if (typeof shaped?.modelId !== 'string' || typeof shaped?.providerName !== 'string') return\n\n // LIFO lookup: retrieve the most-recently-opened inference span\n let inferenceSpan: Span | undefined = inferenceSpans.get(llmCallCounter)\n if (inferenceSpan) {\n inferenceSpans.delete(llmCallCounter)\n llmCallCounter--\n } else {\n // legacy fallback — adapter did not emit \"llm.request\"\n const parentSpan = stepSpan ?? rootSpan\n if (!parentSpan) return\n const childCtx = trace.setSpan(context.active(), parentSpan)\n inferenceSpan = tracer.startSpan(`chat ${shaped.modelId}`, { kind: SpanKind.INTERNAL }, childCtx)\n }\n\n inferenceSpan.setAttribute('gen_ai.request.model', shaped.modelId)\n inferenceSpan.setAttribute('gen_ai.provider.name', shaped.providerName)\n if (typeof shaped?.tokens?.input === 'number' && typeof shaped?.tokens?.output === 'number') {\n inferenceSpan.setAttribute('gen_ai.usage.input_tokens', shaped.tokens.input)\n inferenceSpan.setAttribute('gen_ai.usage.output_tokens', shaped.tokens.output)\n }\n if (typeof shaped?.stopReason === 'string') {\n inferenceSpan.setAttribute('gen_ai.response.finish_reasons', [shaped.stopReason])\n }\n\n if (options?.captureOutputs && shaped.output !== undefined) {\n try {\n inferenceSpan.addEvent('gen_ai.content.completion', { 'gen_ai.completion': truncate(JSON.stringify(shaped.output)) })\n } catch { /* non-serialisable payload — skip event */ }\n }\n\n inferenceSpan.end()\n return\n }\n\n // ── \"tool.call\" — open tool span ─────────────────────────────────────────\n if (type === 'tool.call') {\n const shaped = payload as ToolCallShape\n if (typeof shaped?.toolName !== 'string' || typeof shaped?.toolCallId !== 'string') return\n\n const parentSpan = stepSpan ?? rootSpan\n if (!parentSpan) return\n\n const childCtx = trace.setSpan(context.active(), parentSpan)\n const toolSpan = tracer.startSpan('execute_tool ' + shaped.toolName, {\n kind: SpanKind.INTERNAL,\n attributes: { 'gen_ai.tool.name': shaped.toolName, 'gen_ai.operation.name': 'execute_tool' },\n }, childCtx)\n toolSpans.set(shaped.toolCallId, toolSpan)\n\n if (options?.captureToolIO && shaped.input !== undefined) {\n try {\n toolSpan.addEvent('gen_ai.tool.input', { 'gen_ai.tool.input': truncate(JSON.stringify(shaped.input)) })\n } catch { /* non-serialisable payload — skip event */ }\n }\n return\n }\n\n // ── \"tool.result\" — close tool span ──────────────────────────────────────\n if (type === 'tool.result') {\n const shaped = payload as ToolResultShape\n if (typeof shaped?.toolCallId !== 'string') return\n\n const toolSpan = toolSpans.get(shaped.toolCallId)\n if (!toolSpan) return\n toolSpans.delete(shaped.toolCallId)\n\n if (shaped.error !== undefined) {\n toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(shaped.error) })\n }\n\n if (options?.captureToolIO && shaped.result !== undefined) {\n try {\n toolSpan.addEvent('gen_ai.tool.output', { 'gen_ai.tool.output': truncate(JSON.stringify(shaped.result)) })\n } catch { /* non-serialisable payload — skip event */ }\n }\n\n toolSpan.end()\n return\n }\n\n // ── non-reserved event fallthrough ────────────────────────────────────────\n const activeSpan = stepSpan ?? rootSpan\n activeSpan?.addEvent(type)\n },\n }\n}\n"],"mappings":";AACA,SAAS,gBAAgB,UAAU,SAAS,aAAa;AA0DlD,SAAS,mBAAmB,QAAgB,SAAyC;AAC1F,MAAI;AACJ,MAAI;AACJ,QAAM,YAAiB,oBAAI,IAAkB;AAC7C,QAAM,iBAAiB,oBAAI,IAAkB;AAC7C,MAAI,iBAAmB;AAEvB,MAAI;AACJ,MAAI;AAEJ,QAAM,SAAS,SAAS,oBAAoB;AAE5C,WAAS,SAAS,GAAmB;AACnC,WAAO,EAAE,SAAS,SAAS,EAAE,MAAM,GAAG,MAAM,IAAI,WAAM;AAAA,EACxD;AAEA,MAAI,SAAS,eAAe;AAC1B,UAAM,QAAQ,QAAQ,cAAc,SAAS,0BAA0B,OAAO;AAC9E,qBAAoB,MAAM,gBAAgB,6BAAoC,EAAE,MAAM,UAAU,CAAC;AACjG,wBAAoB,MAAM,gBAAgB,oCAAoC,EAAE,MAAM,IAAI,CAAC;AAAA,EAC7F;AAEA,SAAO;AAAA,IACL,WAAW,KAAuB;AAChC,YAAM,UAAsB;AAAA,QAC1B,mBAA0B,IAAI;AAAA,QAC9B,0BAA0B,IAAI;AAAA,QAC9B,yBAA0B;AAAA,MAC5B;AACA,YAAM,SAAY,EAAE,GAAG,SAAS,YAAY,GAAG,QAAQ;AACvD,YAAM,YAAY,SAAS,iBAAiB,QAAQ,OAAO;AAC3D,iBAAW,OAAO,UAAU,gBAAgB,IAAI,OAAO,IAAI,EAAE,YAAY,OAAO,GAAG,SAAS;AAAA,IAC9F;AAAA,IAEA,SAAS,MAAkB,OAAqD;AAC9E,UAAI,CAAC,SAAU;AACf,eAAS,IAAI;AACb,iBAAW;AACX,yBAAmB,OAAO,MAAM,aAAa,KAAM,EAAE,yBAAyB,eAAe,CAAC;AAAA,IAChG;AAAA,IAEA,YAAY,KAAwB;AAClC,uBAAiB;AACjB,UAAI,CAAC,SAAU;AACf,YAAM,WAAW,MAAM,QAAQ,QAAQ,OAAO,GAAG,QAAQ;AACzD,iBAAW,OAAO;AAAA,QAChB,gBAAgB,IAAI,QAAQ;AAAA,QAC5B,EAAE,YAAY,EAAE,oBAAoB,IAAI,SAAS,EAAE;AAAA,QACnD;AAAA,MACF;AAAA,IACF;AAAA,IAEA,UAAU,MAAmB,QAAsC;AACjE,UAAI,CAAC,SAAU;AACf,YAAM,OAAO;AACb,iBAAW;AACX,WAAK,IAAI;AAAA,IACX;AAAA,IAEA,YAAY,MAAmB,OAAqD;AAClF,UAAI,CAAC,SAAU;AACf,YAAM,OAAO;AACb,iBAAW;AACX,WAAK,UAAU,EAAE,MAAM,eAAe,OAAO,SAAS,OAAO,MAAM,KAAK,EAAE,CAAC;AAC3E,WAAK,IAAI;AAAA,IACX;AAAA,IAEA,QAAQ,MAAmB,MAAc,SAAwB;AAE/D,UAAI,SAAS,eAAe;AAC1B,cAAM,SAAS;AACf,YAAI,OAAO,QAAQ,YAAY,YAAY,OAAO,QAAQ,iBAAiB,SAAU;AAErF,cAAM,aAAa,YAAY;AAC/B,YAAI,CAAC,WAAY;AAEjB,cAAM,WAAiB,MAAM,QAAQ,QAAQ,OAAO,GAAG,UAAU;AACjE,cAAM,gBAAiB,OAAO,UAAU,QAAQ,OAAO,OAAO,IAAI,EAAE,MAAM,SAAS,SAAS,GAAG,QAAQ;AACvG;AACA,uBAAe,IAAI,gBAAgB,aAAa;AAEhD,YAAI,SAAS,iBAAiB,OAAO,aAAa,QAAW;AAC3D,cAAI;AACF,0BAAc,SAAS,yBAAyB,EAAE,iBAAiB,SAAS,KAAK,UAAU,OAAO,QAAQ,CAAC,EAAE,CAAC;AAAA,UAChH,QAAQ;AAAA,UAA8C;AAAA,QACxD;AACA;AAAA,MACF;AAGA,UAAI,SAAS,gBAAgB;AAC3B,cAAM,SAAS;AAGf,YAAI,kBAAkB,OAAO,QAAQ,QAAQ,UAAU,YAAY,OAAO,QAAQ,QAAQ,WAAW,UAAU;AAC7G,yBAAe,OAAO,OAAO,OAAO,OAAQ,EAAE,qBAAqB,QAAQ,CAAC;AAC5E,yBAAe,OAAO,OAAO,OAAO,QAAQ,EAAE,qBAAqB,SAAS,CAAC;AAAA,QAC/E;AAEA,YAAI,OAAO,QAAQ,YAAY,YAAY,OAAO,QAAQ,iBAAiB,SAAU;AAGrF,YAAI,gBAAkC,eAAe,IAAI,cAAc;AACvE,YAAI,eAAe;AACjB,yBAAe,OAAO,cAAc;AACpC;AAAA,QACF,OAAO;AAEL,gBAAM,aAAa,YAAY;AAC/B,cAAI,CAAC,WAAY;AACjB,gBAAM,WAAW,MAAM,QAAQ,QAAQ,OAAO,GAAG,UAAU;AAC3D,0BAAgB,OAAO,UAAU,QAAQ,OAAO,OAAO,IAAI,EAAE,MAAM,SAAS,SAAS,GAAG,QAAQ;AAAA,QAClG;AAEA,sBAAc,aAAa,wBAAyB,OAAO,OAAO;AAClE,sBAAc,aAAa,wBAAyB,OAAO,YAAY;AACvE,YAAI,OAAO,QAAQ,QAAQ,UAAU,YAAY,OAAO,QAAQ,QAAQ,WAAW,UAAU;AAC3F,wBAAc,aAAa,6BAA8B,OAAO,OAAO,KAAK;AAC5E,wBAAc,aAAa,8BAA8B,OAAO,OAAO,MAAM;AAAA,QAC/E;AACA,YAAI,OAAO,QAAQ,eAAe,UAAU;AAC1C,wBAAc,aAAa,kCAAkC,CAAC,OAAO,UAAU,CAAC;AAAA,QAClF;AAEA,YAAI,SAAS,kBAAkB,OAAO,WAAW,QAAW;AAC1D,cAAI;AACF,0BAAc,SAAS,6BAA6B,EAAE,qBAAqB,SAAS,KAAK,UAAU,OAAO,MAAM,CAAC,EAAE,CAAC;AAAA,UACtH,QAAQ;AAAA,UAA8C;AAAA,QACxD;AAEA,sBAAc,IAAI;AAClB;AAAA,MACF;AAGA,UAAI,SAAS,aAAa;AACxB,cAAM,SAAS;AACf,YAAI,OAAO,QAAQ,aAAa,YAAY,OAAO,QAAQ,eAAe,SAAU;AAEpF,cAAM,aAAa,YAAY;AAC/B,YAAI,CAAC,WAAY;AAEjB,cAAM,WAAW,MAAM,QAAQ,QAAQ,OAAO,GAAG,UAAU;AAC3D,cAAM,WAAW,OAAO,UAAU,kBAAkB,OAAO,UAAU;AAAA,UACnE,MAAM,SAAS;AAAA,UACf,YAAY,EAAE,oBAAoB,OAAO,UAAU,yBAAyB,eAAe;AAAA,QAC7F,GAAG,QAAQ;AACX,kBAAU,IAAI,OAAO,YAAY,QAAQ;AAEzC,YAAI,SAAS,iBAAiB,OAAO,UAAU,QAAW;AACxD,cAAI;AACF,qBAAS,SAAS,qBAAqB,EAAE,qBAAqB,SAAS,KAAK,UAAU,OAAO,KAAK,CAAC,EAAE,CAAC;AAAA,UACxG,QAAQ;AAAA,UAA8C;AAAA,QACxD;AACA;AAAA,MACF;AAGA,UAAI,SAAS,eAAe;AAC1B,cAAM,SAAS;AACf,YAAI,OAAO,QAAQ,eAAe,SAAU;AAE5C,cAAM,WAAW,UAAU,IAAI,OAAO,UAAU;AAChD,YAAI,CAAC,SAAU;AACf,kBAAU,OAAO,OAAO,UAAU;AAElC,YAAI,OAAO,UAAU,QAAW;AAC9B,mBAAS,UAAU,EAAE,MAAM,eAAe,OAAO,SAAS,OAAO,OAAO,KAAK,EAAE,CAAC;AAAA,QAClF;AAEA,YAAI,SAAS,iBAAiB,OAAO,WAAW,QAAW;AACzD,cAAI;AACF,qBAAS,SAAS,sBAAsB,EAAE,sBAAsB,SAAS,KAAK,UAAU,OAAO,MAAM,CAAC,EAAE,CAAC;AAAA,UAC3G,QAAQ;AAAA,UAA8C;AAAA,QACxD;AAEA,iBAAS,IAAI;AACb;AAAA,MACF;AAGA,YAAM,aAAa,YAAY;AAC/B,kBAAY,SAAS,IAAI;AAAA,IAC3B;AAAA,EACF;AACF;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noetaris/harness-otel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "OpenTelemetry observer bridge for @noetaris/harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@opentelemetry/api": ">=1.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@noetaris/harness": "^0.3.
|
|
46
|
+
"@noetaris/harness": "^0.3.2",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1",
|
|
48
48
|
"@types/node": "^25.8.0",
|
|
49
49
|
"tsup": "^8.5.1",
|