@noetaris/harness-otel 0.1.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/README.md +80 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +67 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @noetaris/harness-otel
|
|
2
|
+
|
|
3
|
+
OpenTelemetry observer bridge for [@noetaris/harness](../core).
|
|
4
|
+
|
|
5
|
+
> **Status:** not yet released. Implementation tracked in F23.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
`@noetaris/harness-otel` bridges the harness `Observer` telemetry API to OpenTelemetry. It translates harness lifecycle events into OTel spans and metrics, giving you distributed tracing and token usage metrics with zero changes to your agent code.
|
|
10
|
+
|
|
11
|
+
Takes `@opentelemetry/api` as a peer dependency — works with any OTel SDK implementation (Node SDK, collector exporters, etc.).
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add @noetaris/harness-otel
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Peer dependencies:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add @noetaris/harness @opentelemetry/api
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires Node.js ≥ 22.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createOtelObserver } from '@noetaris/harness-otel'
|
|
31
|
+
import { trace, metrics } from '@opentelemetry/api'
|
|
32
|
+
|
|
33
|
+
const observer = createOtelObserver(
|
|
34
|
+
trace.getTracer('my-agent'),
|
|
35
|
+
{ meterProvider: metrics.getMeterProvider() },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
h.observe(observer)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
### `createOtelObserver(tracer, options?)`
|
|
44
|
+
|
|
45
|
+
Returns an `Observer` that maps harness events to OTel spans and metrics.
|
|
46
|
+
|
|
47
|
+
**Span hierarchy:**
|
|
48
|
+
|
|
49
|
+
| Harness event | OTel action |
|
|
50
|
+
|---------------|-------------|
|
|
51
|
+
| `onRunStart` | Creates root span `"agent.run"` with `agent.id` and `session.id` attributes |
|
|
52
|
+
| `onStepStart` | Creates child span `"agent.step"` with `step.name` attribute |
|
|
53
|
+
| `onStepEnd` | Closes the step span with `durationMs` |
|
|
54
|
+
| `onStepError` | Sets step span status to error |
|
|
55
|
+
| `onRunEnd` | Closes the root span |
|
|
56
|
+
|
|
57
|
+
**Metrics** (requires `options.meterProvider`):
|
|
58
|
+
|
|
59
|
+
| Harness event | OTel metric |
|
|
60
|
+
|---------------|-------------|
|
|
61
|
+
| `onEvent("llm.response", { tokens })` | Increments token counter (input and output) |
|
|
62
|
+
| `onStepEnd` | Records step duration to a histogram |
|
|
63
|
+
|
|
64
|
+
**Options:**
|
|
65
|
+
|
|
66
|
+
| Option | Type | Description |
|
|
67
|
+
|--------|------|-------------|
|
|
68
|
+
| `parentContext` | `Context` | OTel context for the root span. Defaults to `context.active()`. |
|
|
69
|
+
| `meterProvider` | `MeterProvider` | Enables metrics. Omit to skip metric recording. |
|
|
70
|
+
|
|
71
|
+
## Related Packages
|
|
72
|
+
|
|
73
|
+
- [`@noetaris/harness`](https://github.com/noetaris-lab/harness) — core execution engine
|
|
74
|
+
- [`@noetaris/harness-anthropic`](https://github.com/noetaris-lab/harness-anthropic) — Anthropic Claude adapter (emits `"llm.response"` events)
|
|
75
|
+
- [`@noetaris/harness-openai`](https://github.com/noetaris-lab/harness-openai) — OpenAI adapter (emits `"llm.response"` events)
|
|
76
|
+
- [`@noetaris/harness-google`](https://github.com/noetaris-lab/harness-google) — Google Gemini adapter (emits `"llm.response"` events)
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { MeterProvider, Context, Attributes, Tracer } from '@opentelemetry/api';
|
|
2
|
+
import { Observer } from '@noetaris/harness';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for {@link createOtelObserver}.
|
|
6
|
+
*/
|
|
7
|
+
interface OtelObserverOptions {
|
|
8
|
+
/** OTel MeterProvider to use for metrics. If absent, metrics are skipped. */
|
|
9
|
+
meterProvider?: MeterProvider;
|
|
10
|
+
/**
|
|
11
|
+
* Explicit parent context for the root span. When provided, the root
|
|
12
|
+
* `agent.run` span is created as a child of the span in this context.
|
|
13
|
+
* When absent, `context.active()` is used (ambient context from OTel middleware).
|
|
14
|
+
*/
|
|
15
|
+
parentContext?: Context;
|
|
16
|
+
/**
|
|
17
|
+
* Extra attributes merged onto the root `agent.run` span at start time.
|
|
18
|
+
* These are merged with the built-in attributes (`agent.id`, `session.id`);
|
|
19
|
+
* if a key conflicts, the built-in attribute takes precedence.
|
|
20
|
+
*/
|
|
21
|
+
attributes?: Attributes;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create an {@link Observer} that records traces and metrics via OpenTelemetry.
|
|
25
|
+
*
|
|
26
|
+
* **Spans produced:**
|
|
27
|
+
* - `agent.run` — root span, one per `agent.run()` invocation.
|
|
28
|
+
* - `agent.step` — child span, one per step execution.
|
|
29
|
+
*
|
|
30
|
+
* **Metrics produced** (requires `options.meterProvider`):
|
|
31
|
+
* - `agent.llm.tokens` (counter) — input/output tokens, tagged by `token.type`.
|
|
32
|
+
* - `agent.step.duration` (histogram, ms) — step duration, tagged by `step.name`.
|
|
33
|
+
*
|
|
34
|
+
* @param tracer - An OTel `Tracer` instance from your SDK.
|
|
35
|
+
* @param options - Optional meter provider, parent context, and extra span attributes.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const observer = createOtelObserver(trace.getTracer('my-agent'), {
|
|
40
|
+
* meterProvider: metrics.getMeterProvider(),
|
|
41
|
+
* })
|
|
42
|
+
* agent.run({}, { llm, observer })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
declare function createOtelObserver(tracer: Tracer, options?: OtelObserverOptions): Observer;
|
|
46
|
+
|
|
47
|
+
export { type OtelObserverOptions, createOtelObserver };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/observer/otel-observer.ts
|
|
2
|
+
import { SpanStatusCode, context, trace } from "@opentelemetry/api";
|
|
3
|
+
function createOtelObserver(tracer, options) {
|
|
4
|
+
let rootSpan;
|
|
5
|
+
let stepSpan;
|
|
6
|
+
let counter;
|
|
7
|
+
let histogram;
|
|
8
|
+
if (options?.meterProvider) {
|
|
9
|
+
const meter = options.meterProvider.getMeter("@noetaris/harness-otel", "0.1.0");
|
|
10
|
+
counter = meter.createCounter("agent.llm.tokens");
|
|
11
|
+
histogram = meter.createHistogram("agent.step.duration", { unit: "ms" });
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
onRunStart(ctx) {
|
|
15
|
+
const builtIn = {
|
|
16
|
+
"agent.id": ctx.agentId,
|
|
17
|
+
"session.id": ctx.sessionId
|
|
18
|
+
};
|
|
19
|
+
const merged = { ...options?.attributes, ...builtIn };
|
|
20
|
+
const parentCtx = options?.parentContext ?? context.active();
|
|
21
|
+
rootSpan = tracer.startSpan("agent.run", { attributes: merged }, parentCtx);
|
|
22
|
+
},
|
|
23
|
+
onRunEnd(_ctx, _event) {
|
|
24
|
+
if (!rootSpan) return;
|
|
25
|
+
rootSpan.end();
|
|
26
|
+
rootSpan = void 0;
|
|
27
|
+
},
|
|
28
|
+
onStepStart(ctx) {
|
|
29
|
+
if (!rootSpan) return;
|
|
30
|
+
const childCtx = trace.setSpan(context.active(), rootSpan);
|
|
31
|
+
stepSpan = tracer.startSpan("agent.step", { attributes: { "step.name": ctx.stepName } }, childCtx);
|
|
32
|
+
},
|
|
33
|
+
onStepEnd(ctx, event) {
|
|
34
|
+
if (!stepSpan) return;
|
|
35
|
+
const span = stepSpan;
|
|
36
|
+
stepSpan = void 0;
|
|
37
|
+
histogram?.record(event.durationMs, { "step.name": ctx.stepName });
|
|
38
|
+
span.end();
|
|
39
|
+
},
|
|
40
|
+
onStepError(ctx, event) {
|
|
41
|
+
if (!stepSpan) return;
|
|
42
|
+
const span = stepSpan;
|
|
43
|
+
stepSpan = void 0;
|
|
44
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: String(event.error) });
|
|
45
|
+
histogram?.record(event.durationMs, { "step.name": ctx.stepName });
|
|
46
|
+
span.end();
|
|
47
|
+
},
|
|
48
|
+
onEvent(_ctx, type, payload) {
|
|
49
|
+
if (type === "llm.response") {
|
|
50
|
+
if (counter) {
|
|
51
|
+
const shaped = payload;
|
|
52
|
+
if (typeof shaped?.tokens?.input === "number" && typeof shaped?.tokens?.output === "number") {
|
|
53
|
+
counter.add(shaped.tokens.input, { "token.type": "input" });
|
|
54
|
+
counter.add(shaped.tokens.output, { "token.type": "output" });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const activeSpan = stepSpan ?? rootSpan;
|
|
60
|
+
activeSpan?.addEvent(type);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export {
|
|
65
|
+
createOtelObserver
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/observer/otel-observer.ts"],"sourcesContent":["import type { Tracer, MeterProvider, Context, Attributes, Span, Counter, Histogram } from '@opentelemetry/api'\nimport { SpanStatusCode, 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 * `agent.run` 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 `agent.run` span at start time.\n * These are merged with the built-in attributes (`agent.id`, `session.id`);\n * if a key conflicts, the built-in attribute takes precedence.\n */\n attributes?: Attributes\n}\n\n// Local shape guard — avoids importing @noetaris/harness-types\ntype LLMUsageShape = { tokens?: { input?: unknown; output?: unknown } | null }\n\n/**\n * Create an {@link Observer} that records traces and metrics via OpenTelemetry.\n *\n * **Spans produced:**\n * - `agent.run` — root span, one per `agent.run()` invocation.\n * - `agent.step` — child span, one per step execution.\n *\n * **Metrics produced** (requires `options.meterProvider`):\n * - `agent.llm.tokens` (counter) — input/output tokens, tagged by `token.type`.\n * - `agent.step.duration` (histogram, ms) — step duration, tagged by `step.name`.\n *\n * @param tracer - An OTel `Tracer` instance from your SDK.\n * @param options - Optional meter provider, parent context, and extra span attributes.\n *\n * @example\n * ```ts\n * const observer = createOtelObserver(trace.getTracer('my-agent'), {\n * meterProvider: metrics.getMeterProvider(),\n * })\n * agent.run({}, { llm, observer })\n * ```\n */\nexport function createOtelObserver(tracer: Tracer, options?: OtelObserverOptions): Observer {\n let rootSpan: Span | undefined\n let stepSpan: Span | undefined\n\n let counter: Counter | undefined\n let histogram: Histogram | undefined\n\n if (options?.meterProvider) {\n const meter = options.meterProvider.getMeter('@noetaris/harness-otel', '0.1.0')\n counter = meter.createCounter('agent.llm.tokens')\n histogram = meter.createHistogram('agent.step.duration', { unit: 'ms' })\n }\n\n return {\n onRunStart(ctx: RunContext): void {\n const builtIn: Attributes = {\n 'agent.id': ctx.agentId,\n 'session.id': ctx.sessionId,\n }\n const merged: Attributes = { ...options?.attributes, ...builtIn }\n const parentCtx = options?.parentContext ?? context.active()\n rootSpan = tracer.startSpan('agent.run', { 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 },\n\n onStepStart(ctx: StepContext): void {\n if (!rootSpan) return\n const childCtx = trace.setSpan(context.active(), rootSpan)\n stepSpan = tracer.startSpan('agent.step', { attributes: { '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 histogram?.record(event.durationMs, { 'step.name': ctx.stepName })\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 histogram?.record(event.durationMs, { 'step.name': ctx.stepName })\n span.end()\n },\n\n onEvent(_ctx: StepContext, type: string, payload: unknown): void {\n if (type === 'llm.response') {\n if (counter) {\n const shaped = payload as LLMUsageShape // as: payload is unknown; guard below validates the shape before use\n if (typeof shaped?.tokens?.input === 'number' && typeof shaped?.tokens?.output === 'number') {\n counter.add(shaped.tokens.input, { 'token.type': 'input' })\n counter.add(shaped.tokens.output, { 'token.type': 'output' })\n }\n }\n return\n }\n\n const activeSpan = stepSpan ?? rootSpan\n activeSpan?.addEvent(type)\n },\n }\n}\n"],"mappings":";AACA,SAAS,gBAAgB,SAAS,aAAa;AAgDxC,SAAS,mBAAmB,QAAgB,SAAyC;AAC1F,MAAI;AACJ,MAAI;AAEJ,MAAI;AACJ,MAAI;AAEJ,MAAI,SAAS,eAAe;AAC1B,UAAM,QAAQ,QAAQ,cAAc,SAAS,0BAA0B,OAAO;AAC9E,cAAU,MAAM,cAAc,kBAAkB;AAChD,gBAAY,MAAM,gBAAgB,uBAAuB,EAAE,MAAM,KAAK,CAAC;AAAA,EACzE;AAEA,SAAO;AAAA,IACL,WAAW,KAAuB;AAChC,YAAM,UAAsB;AAAA,QAC1B,YAAY,IAAI;AAAA,QAChB,cAAc,IAAI;AAAA,MACpB;AACA,YAAM,SAAqB,EAAE,GAAG,SAAS,YAAY,GAAG,QAAQ;AAChE,YAAM,YAAY,SAAS,iBAAiB,QAAQ,OAAO;AAC3D,iBAAW,OAAO,UAAU,aAAa,EAAE,YAAY,OAAO,GAAG,SAAS;AAAA,IAC5E;AAAA,IAEA,SAAS,MAAkB,QAAsD;AAC/E,UAAI,CAAC,SAAU;AACf,eAAS,IAAI;AACb,iBAAW;AAAA,IACb;AAAA,IAEA,YAAY,KAAwB;AAClC,UAAI,CAAC,SAAU;AACf,YAAM,WAAW,MAAM,QAAQ,QAAQ,OAAO,GAAG,QAAQ;AACzD,iBAAW,OAAO,UAAU,cAAc,EAAE,YAAY,EAAE,aAAa,IAAI,SAAS,EAAE,GAAG,QAAQ;AAAA,IACnG;AAAA,IAEA,UAAU,KAAkB,OAAqC;AAC/D,UAAI,CAAC,SAAU;AACf,YAAM,OAAO;AACb,iBAAW;AACX,iBAAW,OAAO,MAAM,YAAY,EAAE,aAAa,IAAI,SAAS,CAAC;AACjE,WAAK,IAAI;AAAA,IACX;AAAA,IAEA,YAAY,KAAkB,OAAqD;AACjF,UAAI,CAAC,SAAU;AACf,YAAM,OAAO;AACb,iBAAW;AACX,WAAK,UAAU,EAAE,MAAM,eAAe,OAAO,SAAS,OAAO,MAAM,KAAK,EAAE,CAAC;AAC3E,iBAAW,OAAO,MAAM,YAAY,EAAE,aAAa,IAAI,SAAS,CAAC;AACjE,WAAK,IAAI;AAAA,IACX;AAAA,IAEA,QAAQ,MAAmB,MAAc,SAAwB;AAC/D,UAAI,SAAS,gBAAgB;AAC3B,YAAI,SAAS;AACX,gBAAM,SAAS;AACf,cAAI,OAAO,QAAQ,QAAQ,UAAU,YAAY,OAAO,QAAQ,QAAQ,WAAW,UAAU;AAC3F,oBAAQ,IAAI,OAAO,OAAO,OAAO,EAAE,cAAc,QAAQ,CAAC;AAC1D,oBAAQ,IAAI,OAAO,OAAO,QAAQ,EAAE,cAAc,SAAS,CAAC;AAAA,UAC9D;AAAA,QACF;AACA;AAAA,MACF;AAEA,YAAM,aAAa,YAAY;AAC/B,kBAAY,SAAS,IAAI;AAAA,IAC3B;AAAA,EACF;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noetaris/harness-otel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenTelemetry observer bridge for @noetaris/harness",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/noetaris-lab/harness-otel.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/noetaris-lab/harness-otel#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/noetaris-lab/harness-otel/issues"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"prepublishOnly": "pnpm build && pnpm test",
|
|
31
|
+
"publish:npm": "pnpm publish --access public --no-git-checks"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"pnpm": {
|
|
37
|
+
"onlyBuiltDependencies": [
|
|
38
|
+
"esbuild"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@noetaris/harness": ">=0.1.0",
|
|
43
|
+
"@opentelemetry/api": ">=1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@noetaris/harness": "^0.2.0",
|
|
47
|
+
"@opentelemetry/api": "^1.9.1",
|
|
48
|
+
"@types/node": "^25.8.0",
|
|
49
|
+
"tsup": "^8.5.1",
|
|
50
|
+
"typescript": "^6.0.3",
|
|
51
|
+
"vitest": "^4.1.6"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": null
|
|
54
|
+
}
|