@ap3x/observe-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AP3X
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @ap3x/observe-otel
2
+
3
+ Exports `@ap3x/observe` events to any OpenTelemetry-compatible backend.
4
+
5
+ Part of [AP3X](https://github.com/AP3X-Dev/AP3X) — a TypeScript multi-agent framework.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @ap3x/observe-otel
11
+ ```
12
+
13
+ ## What's inside
14
+
15
+ - **`OtelExporter`** — implements the `@ap3x/observe` `Exporter` interface over `@opentelemetry/api`.
16
+
17
+ Bring your own OpenTelemetry SDK and configure it as usual; this package only emits.
18
+
19
+ ## Documentation
20
+
21
+ See the [AP3X repository](https://github.com/AP3X-Dev/AP3X) for architecture notes, the full package map, and examples.
22
+
23
+ ## License
24
+
25
+ MIT
@@ -0,0 +1,33 @@
1
+ import type { Exporter, TraceEvent } from "@ap3x/observe";
2
+ import type { Tracer } from "@opentelemetry/api";
3
+ export interface OtelExporterOptions {
4
+ tracer?: Tracer;
5
+ }
6
+ /**
7
+ * Reconstructs OTel spans from AP3X's point-event trace stream. AP3X emits a flat
8
+ * sequence of start/end/point events per span id; OTel wants intervals. Paired
9
+ * `*_start`/`*_end` events become real spans, keyed by spanId+stem+disambiguator:
10
+ * `tool_execution` uses its `toolCallId`; `member_run`/`struct_run` (which carry no
11
+ * per-invocation id) use the agent/struct name instead. Each key holds a FIFO queue
12
+ * of open spans rather than a single slot, so same-key concurrent starts (e.g.
13
+ * `SubagentRegistry.gather()` fan-out) each get their own span instead of the second
14
+ * start clobbering the first. Everything else is attached as a span event on the
15
+ * innermost open span.
16
+ *
17
+ * ponytail: parent linkage is recorded as the `ap3x.parent_span_id` attribute rather
18
+ * than wired through OTel's Context/trace.setSpan machinery. The injectable `tracer`
19
+ * here is typed against @opentelemetry/api's `Tracer`/`Span` interfaces only (no SDK
20
+ * dependency), and tests inject a hand-rolled fake tracer whose `startSpan` doesn't
21
+ * participate in real Context propagation — attribute-based linkage is what a
22
+ * downstream OTel backend can reconstruct real parent/child spans from, without this
23
+ * package needing a live Context API. Upgrade path: thread `context.active()` +
24
+ * `trace.setSpan` once this exporter runs against a real SDK-backed tracer.
25
+ */
26
+ export declare class OtelExporter implements Exporter {
27
+ #private;
28
+ constructor(options?: OtelExporterOptions);
29
+ get dropped(): number;
30
+ onEvent(e: TraceEvent): void;
31
+ flush(): Promise<void>;
32
+ }
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,KAAK,EAAoB,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAGnE,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAyFD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,YAAa,YAAW,QAAQ;;gBAM/B,OAAO,CAAC,EAAE,mBAAmB;IAIzC,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,OAAO,CAAC,CAAC,EAAE,UAAU,GAAG,IAAI;IAiBtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAmE7B"}
package/dist/index.js ADDED
@@ -0,0 +1,154 @@
1
+ // src/index.ts
2
+ import { SpanStatusCode, trace } from "@opentelemetry/api";
3
+ function stemOf(name) {
4
+ return name.replace(/_(start|end)$/, "");
5
+ }
6
+ function isStart(name) {
7
+ return name.endsWith("_start");
8
+ }
9
+ function isEnd(name) {
10
+ return name.endsWith("_end");
11
+ }
12
+ var PAIRED_STEMS = /* @__PURE__ */ new Set(["agent", "turn", "tool_execution", "member_run", "struct_run"]);
13
+ function toolCallIdOf(data) {
14
+ if (data && typeof data === "object" && "toolCallId" in data) {
15
+ const id = data.toolCallId;
16
+ if (typeof id === "string") return id;
17
+ }
18
+ return "";
19
+ }
20
+ function disambiguatorOf(e, stem) {
21
+ if (stem === "member_run") return e.agent?.name ?? "";
22
+ if (stem === "struct_run") {
23
+ const data = e.data;
24
+ if (data && typeof data === "object" && "struct" in data) {
25
+ const struct = data.struct;
26
+ if (typeof struct === "string") return struct;
27
+ }
28
+ return "";
29
+ }
30
+ return toolCallIdOf(e.data);
31
+ }
32
+ function isErrorEvent(data) {
33
+ return Boolean(
34
+ data && typeof data === "object" && data.isError === true
35
+ );
36
+ }
37
+ function flatAttributes(data) {
38
+ if (!data || typeof data !== "object") return {};
39
+ const out = {};
40
+ for (const [key, value] of Object.entries(data)) {
41
+ if (value === null || value === void 0) continue;
42
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
43
+ out[key] = value;
44
+ } else {
45
+ try {
46
+ out[key] = JSON.stringify(value);
47
+ } catch {
48
+ out[key] = String(value);
49
+ }
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ function startAttributes(e) {
55
+ return {
56
+ "ap3x.trace_id": e.traceId,
57
+ "ap3x.span_id": e.spanId,
58
+ ...e.parentSpanId ? { "ap3x.parent_span_id": e.parentSpanId } : {},
59
+ "ap3x.seq": e.seq,
60
+ "ap3x.source": e.source,
61
+ ...e.agent?.name ? { "agent.name": e.agent.name } : {},
62
+ ...e.agent?.model ? { "agent.model": e.agent.model } : {}
63
+ };
64
+ }
65
+ var OtelExporter = class {
66
+ #tracer;
67
+ // ponytail: no invocation id in the stream — identical concurrent twins may swap durations; exact match needs an id on the events.
68
+ #open = /* @__PURE__ */ new Map();
69
+ #dropped = 0;
70
+ constructor(options) {
71
+ this.#tracer = options?.tracer ?? trace.getTracer("@ap3x/observe-otel");
72
+ }
73
+ get dropped() {
74
+ return this.#dropped;
75
+ }
76
+ onEvent(e) {
77
+ try {
78
+ const stem = stemOf(e.name);
79
+ if (isStart(e.name) && PAIRED_STEMS.has(stem)) {
80
+ this.#handleStart(e, stem);
81
+ return;
82
+ }
83
+ if (isEnd(e.name) && PAIRED_STEMS.has(stem)) {
84
+ this.#handleEnd(e, stem);
85
+ return;
86
+ }
87
+ this.#handlePoint(e);
88
+ } catch {
89
+ this.#dropped += 1;
90
+ }
91
+ }
92
+ async flush() {
93
+ const now = Date.now();
94
+ for (const queue of this.#open.values()) {
95
+ for (const { span } of queue) span.end(now);
96
+ }
97
+ this.#open.clear();
98
+ }
99
+ #keyOf(spanId, stem, disambiguator) {
100
+ return `${spanId}:${stem}:${disambiguator}`;
101
+ }
102
+ #handleStart(e, stem) {
103
+ const key = this.#keyOf(e.spanId, stem, disambiguatorOf(e, stem));
104
+ const span = this.#tracer.startSpan(stem, { startTime: e.ts, attributes: startAttributes(e) });
105
+ const queue = this.#open.get(key);
106
+ if (queue) queue.push({ span, spanId: e.spanId, stem });
107
+ else this.#open.set(key, [{ span, spanId: e.spanId, stem }]);
108
+ }
109
+ #handleEnd(e, stem) {
110
+ const key = this.#keyOf(e.spanId, stem, disambiguatorOf(e, stem));
111
+ const queue = this.#open.get(key);
112
+ const open = queue?.shift();
113
+ if (!open) return;
114
+ open.span.setAttributes({
115
+ "ap3x.end_seq": e.seq,
116
+ ...e.usage ? {
117
+ "gen_ai.usage.input_tokens": e.usage.input,
118
+ "gen_ai.usage.output_tokens": e.usage.output
119
+ } : {}
120
+ });
121
+ if (isErrorEvent(e.data)) open.span.setStatus({ code: SpanStatusCode.ERROR });
122
+ open.span.end(e.ts);
123
+ if (queue && queue.length === 0) this.#open.delete(key);
124
+ }
125
+ #handlePoint(e) {
126
+ const target = this.#innermostOpenSpan(e.spanId);
127
+ if (target) {
128
+ target.addEvent(e.name, flatAttributes(e.data), e.ts);
129
+ return;
130
+ }
131
+ const span = this.#tracer.startSpan(e.name, {
132
+ startTime: e.ts,
133
+ attributes: startAttributes(e)
134
+ });
135
+ span.end(e.ts);
136
+ }
137
+ /** Most-recently-opened span whose key belongs to this spanId. */
138
+ #innermostOpenSpan(spanId) {
139
+ const prefix = `${spanId}:`;
140
+ const entries = [...this.#open.entries()];
141
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
142
+ const entry = entries[i];
143
+ if (entry?.[0].startsWith(prefix)) {
144
+ const queue = entry[1];
145
+ const last = queue[queue.length - 1];
146
+ if (last) return last.span;
147
+ }
148
+ }
149
+ return void 0;
150
+ }
151
+ };
152
+ export {
153
+ OtelExporter
154
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@ap3x/observe-otel",
3
+ "version": "0.1.0",
4
+ "description": "OpenTelemetry exporter for @ap3x/observe",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/AP3X-Dev/AP3X.git",
8
+ "directory": "packages/observe-otel"
9
+ },
10
+ "license": "MIT",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": ["dist"],
24
+ "scripts": {
25
+ "build": "tsup src/index.ts --format esm --clean --tsconfig tsconfig.build.json && tsc -p tsconfig.build.json",
26
+ "prepublishOnly": "node ../../scripts/publish-gate.mjs",
27
+ "test": "vitest run"
28
+ },
29
+ "dependencies": {
30
+ "@ap3x/observe": "0.1.0",
31
+ "@opentelemetry/api": "^1.9.0"
32
+ }
33
+ }