@vauban-org/agent-sdk 1.2.0 → 1.4.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.
Files changed (82) hide show
  1. package/CONTRACT.md +595 -7
  2. package/dist/index.d.ts +4 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/orchestration/ooda/agent.d.ts.map +1 -1
  7. package/dist/orchestration/ooda/agent.js +77 -0
  8. package/dist/orchestration/ooda/agent.js.map +1 -1
  9. package/dist/orchestration/ooda/types.d.ts +11 -0
  10. package/dist/orchestration/ooda/types.d.ts.map +1 -1
  11. package/dist/skills/_secrets.d.ts +16 -0
  12. package/dist/skills/_secrets.d.ts.map +1 -0
  13. package/dist/skills/_secrets.js +20 -0
  14. package/dist/skills/_secrets.js.map +1 -0
  15. package/dist/skills/alpaca-quote.d.ts +2 -2
  16. package/dist/skills/alpaca-quote.d.ts.map +1 -1
  17. package/dist/skills/alpaca-quote.js +51 -20
  18. package/dist/skills/alpaca-quote.js.map +1 -1
  19. package/dist/skills/send-email.d.ts +2 -2
  20. package/dist/skills/send-email.d.ts.map +1 -1
  21. package/dist/skills/send-email.js +1 -12
  22. package/dist/skills/send-email.js.map +1 -1
  23. package/dist/skills/slack-notify.d.ts.map +1 -1
  24. package/dist/skills/slack-notify.js +52 -21
  25. package/dist/skills/slack-notify.js.map +1 -1
  26. package/dist/skills/telegram-notify.d.ts.map +1 -1
  27. package/dist/skills/telegram-notify.js +48 -19
  28. package/dist/skills/telegram-notify.js.map +1 -1
  29. package/dist/skills/web-search.d.ts.map +1 -1
  30. package/dist/skills/web-search.js +85 -40
  31. package/dist/skills/web-search.js.map +1 -1
  32. package/dist/telemetry/bus.d.ts +54 -0
  33. package/dist/telemetry/bus.d.ts.map +1 -0
  34. package/dist/telemetry/bus.js +159 -0
  35. package/dist/telemetry/bus.js.map +1 -0
  36. package/dist/telemetry/index.d.ts +35 -0
  37. package/dist/telemetry/index.d.ts.map +1 -0
  38. package/dist/telemetry/index.js +30 -0
  39. package/dist/telemetry/index.js.map +1 -0
  40. package/dist/telemetry/port.d.ts +121 -0
  41. package/dist/telemetry/port.d.ts.map +1 -0
  42. package/dist/telemetry/port.js +48 -0
  43. package/dist/telemetry/port.js.map +1 -0
  44. package/dist/telemetry/sinks/otlp.d.ts +45 -0
  45. package/dist/telemetry/sinks/otlp.d.ts.map +1 -0
  46. package/dist/telemetry/sinks/otlp.js +195 -0
  47. package/dist/telemetry/sinks/otlp.js.map +1 -0
  48. package/dist/telemetry/sinks/sqlite.d.ts +32 -0
  49. package/dist/telemetry/sinks/sqlite.d.ts.map +1 -0
  50. package/dist/telemetry/sinks/sqlite.js +170 -0
  51. package/dist/telemetry/sinks/sqlite.js.map +1 -0
  52. package/dist/telemetry/sinks/stdout.d.ts +22 -0
  53. package/dist/telemetry/sinks/stdout.d.ts.map +1 -0
  54. package/dist/telemetry/sinks/stdout.js +38 -0
  55. package/dist/telemetry/sinks/stdout.js.map +1 -0
  56. package/docs/telemetry/migration.md +155 -0
  57. package/docs/telemetry/overview.md +154 -0
  58. package/docs/telemetry/privacy.md +127 -0
  59. package/docs/telemetry/sinks/cc.md +155 -0
  60. package/docs/telemetry/sinks/otlp.md +146 -0
  61. package/docs/telemetry/sinks/sqlite.md +126 -0
  62. package/docs/telemetry/sinks/stdout.md +82 -0
  63. package/package.json +18 -19
  64. package/src/index.ts +30 -1
  65. package/src/orchestration/ooda/agent.ts +105 -0
  66. package/src/orchestration/ooda/types.ts +12 -0
  67. package/src/skills/_secrets.ts +25 -0
  68. package/src/skills/alpaca-quote.ts +68 -23
  69. package/src/skills/send-email.ts +1 -12
  70. package/src/skills/slack-notify.ts +73 -30
  71. package/src/skills/telegram-notify.ts +70 -24
  72. package/src/skills/web-search.ts +132 -50
  73. package/src/telemetry/bus.test.ts +231 -0
  74. package/src/telemetry/bus.ts +241 -0
  75. package/src/telemetry/index.ts +49 -0
  76. package/src/telemetry/port.ts +160 -0
  77. package/src/telemetry/sinks/otlp.test.ts +146 -0
  78. package/src/telemetry/sinks/otlp.ts +250 -0
  79. package/src/telemetry/sinks/sqlite.test.ts +121 -0
  80. package/src/telemetry/sinks/sqlite.ts +260 -0
  81. package/src/telemetry/sinks/stdout.test.ts +109 -0
  82. package/src/telemetry/sinks/stdout.ts +59 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Public telemetry API for agent-sdk consumers.
3
+ *
4
+ * Per ADR-ECO-039. Sovereign multi-sink observability for OODA agents.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import {
9
+ * createOODAAgent,
10
+ * stdoutTelemetrySink,
11
+ * localSqliteTelemetrySink,
12
+ * } from "@vauban-org/agent-sdk";
13
+ *
14
+ * createOODAAgent({
15
+ * agentId: "my-agent",
16
+ * telemetry: {
17
+ * sinks: [
18
+ * stdoutTelemetrySink(),
19
+ * localSqliteTelemetrySink(),
20
+ * ],
21
+ * },
22
+ * });
23
+ * ```
24
+ */
25
+
26
+ export { NOOP_TELEMETRY_SINK, TelemetrySinkError } from "./port.js";
27
+ export type {
28
+ TelemetryRunStatus,
29
+ TelemetryRunFinish,
30
+ TelemetryRunStart,
31
+ TelemetryRunStep,
32
+ TelemetrySink,
33
+ } from "./port.js";
34
+
35
+ export { createTelemetryBus } from "./bus.js";
36
+ export type {
37
+ TelemetryBusOptions,
38
+ TelemetryCounters,
39
+ TelemetryLogger,
40
+ } from "./bus.js";
41
+
42
+ export { stdoutTelemetrySink } from "./sinks/stdout.js";
43
+ export type { StdoutTelemetrySinkOptions } from "./sinks/stdout.js";
44
+
45
+ export { localSqliteTelemetrySink } from "./sinks/sqlite.js";
46
+ export type { LocalSqliteTelemetrySinkOptions } from "./sinks/sqlite.js";
47
+
48
+ export { otlpTelemetrySink } from "./sinks/otlp.js";
49
+ export type { OtlpTelemetrySinkOptions } from "./sinks/otlp.js";
@@ -0,0 +1,160 @@
1
+ /**
2
+ * TelemetryPort — sovereign multi-sink observability for agent runs.
3
+ *
4
+ * Per ADR-ECO-039 (accepted 2026-05-16). Sink-based interface replaces the
5
+ * implicit coupling between agent-sdk and the CC `agent_run` table (legacy
6
+ * `AgentRunTracker`). Callers configure 0..N sinks; the {@link TelemetryBus}
7
+ * fans events out non-blockingly.
8
+ *
9
+ * Three modes :
10
+ * 1. Standalone : `[stdoutTelemetrySink(), localSqliteTelemetrySink()]`
11
+ * 2. + CC SaaS : add `commandCenterTelemetrySink({apiKey})`
12
+ * 3. Tiered : same code, API key changes entitlement server-side
13
+ *
14
+ * Ref: command-center:sprint-693:port-interface
15
+ */
16
+
17
+ // ─── Run lifecycle types (compatible with legacy AgentRunTracker) ────────────
18
+
19
+ export interface TelemetryRunStart {
20
+ /** Caller-generated run id (matches the OODA loop's runId / trace). */
21
+ runId: string;
22
+ agentId: string;
23
+ agentVersion: string;
24
+ model: string;
25
+ provider: string;
26
+ /** Optional tenant scoping — required by the CC sink. */
27
+ tenantId?: string;
28
+ /** OTel trace id for replay linkage. */
29
+ traceId?: string;
30
+ /** ISO8601 timestamp of cycle start. */
31
+ startedAt: string;
32
+ }
33
+
34
+ export interface TelemetryRunStep {
35
+ /** Monotonic step index within the run, starting at 0. */
36
+ stepIndex: number;
37
+ /** OODA phase or step kind ("observe" | "orient" | "decide" | "act" | ...). */
38
+ kind: string;
39
+ /** Status of THIS step (not the whole run). */
40
+ status: "completed" | "failed" | "skipped";
41
+ inputTokens: number;
42
+ outputTokens: number;
43
+ toolCalls?: number;
44
+ /**
45
+ * USD cost delta. MUST be >= 0. Use 0 for non-LLM steps.
46
+ * Fixed(6) precision recommended.
47
+ */
48
+ costUsd: number;
49
+ /** Optional duration in ms — useful for OTLP span span_start/span_end. */
50
+ durationMs?: number;
51
+ /** Optional metadata. Hashed at sink boundary unless `includePayloads`. */
52
+ metadata?: Record<string, unknown>;
53
+ }
54
+
55
+ /**
56
+ * Run final status.
57
+ *
58
+ * NEW (sprint-693) : "skipped" — added so that session_guard /
59
+ * risk_guard / heap_exceeded short-circuits are observably distinct
60
+ * from "success" or "failed".
61
+ */
62
+ export type TelemetryRunStatus =
63
+ | "success"
64
+ | "failed"
65
+ | "skipped"
66
+ | "timeout"
67
+ | "incoherent";
68
+
69
+ export interface TelemetryRunFinish {
70
+ status: TelemetryRunStatus;
71
+ /** Free-text reason — e.g. "session_guard:business-hours". */
72
+ stopReason?: string;
73
+ /** Only set when status === "failed" | "incoherent". */
74
+ errorMessage?: string;
75
+ /** ISO8601 timestamp of cycle finish. */
76
+ finishedAt: string;
77
+ /** Optional cumulative totals — sinks may compute or trust caller. */
78
+ totalInputTokens?: number;
79
+ totalOutputTokens?: number;
80
+ totalCostUsd?: number;
81
+ totalToolCalls?: number;
82
+ }
83
+
84
+ // ─── Sink interface ──────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * TelemetrySink — pluggable destination for run lifecycle events.
88
+ *
89
+ * Contract :
90
+ * - `start` is called ONCE per run before any `step`.
91
+ * - `step` MAY be called 0..N times after start; the bus serialises per-run.
92
+ * - `finish` is called ONCE after the last `step` (or directly after `start`
93
+ * for skipped runs).
94
+ * - All methods MUST be idempotent w.r.t. their (runId, stepIndex) pair —
95
+ * the bus may retry on transient sink failures.
96
+ * - Implementations MUST NOT throw on transient errors ; return a rejected
97
+ * Promise instead so the bus can isolate the failure.
98
+ *
99
+ * Implementations should NOT block the agent loop. Long-running I/O should
100
+ * be batched/queued internally.
101
+ */
102
+ export interface TelemetrySink {
103
+ /** Unique sink identifier — used for audit log + Prometheus labels. */
104
+ readonly name: string;
105
+
106
+ /**
107
+ * Record run start. Returns a sink-local reference (e.g. DB UUID, file
108
+ * offset, span id) the bus may pass back to `step` / `finish` for
109
+ * correlation. Return value is opaque to the bus.
110
+ */
111
+ start(event: TelemetryRunStart): Promise<string | void>;
112
+
113
+ /** Record a single OODA step delta. */
114
+ step(runId: string, delta: TelemetryRunStep): Promise<void>;
115
+
116
+ /** Record run finish. */
117
+ finish(runId: string, event: TelemetryRunFinish): Promise<void>;
118
+
119
+ /**
120
+ * Flush pending I/O (best-effort). Called on agent shutdown.
121
+ * Implementations without a buffer can no-op.
122
+ */
123
+ flush?(): Promise<void>;
124
+ }
125
+
126
+ // ─── NOOP — default when no sinks configured ─────────────────────────────────
127
+
128
+ /**
129
+ * No-op sink. The bus uses this when no sinks are configured, so callers
130
+ * never need to null-check.
131
+ */
132
+ export const NOOP_TELEMETRY_SINK: TelemetrySink = {
133
+ name: "noop",
134
+ async start() {
135
+ /* no-op */
136
+ },
137
+ async step() {
138
+ /* no-op */
139
+ },
140
+ async finish() {
141
+ /* no-op */
142
+ },
143
+ };
144
+
145
+ // ─── Error type ──────────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Thrown only at the BUS boundary when ALL sinks fail. Individual sink
149
+ * failures are warned and isolated (see {@link TelemetryBus}).
150
+ */
151
+ export class TelemetrySinkError extends Error {
152
+ constructor(
153
+ message: string,
154
+ public readonly sinkName: string,
155
+ public readonly cause?: unknown
156
+ ) {
157
+ super(`[telemetry:${sinkName}] ${message}`);
158
+ this.name = "TelemetrySinkError";
159
+ }
160
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * otlpTelemetrySink — payload shape + fetch mocking tests.
3
+ *
4
+ * Ref: command-center:sprint-693:sink-otlp
5
+ */
6
+
7
+ import { describe, expect, it, vi } from "vitest";
8
+
9
+ import { otlpTelemetrySink } from "./otlp.js";
10
+
11
+ const RUN_START = {
12
+ runId: "11111111-1111-1111-1111-111111111111",
13
+ agentId: "agent-x",
14
+ agentVersion: "1.0.0",
15
+ model: "groq-llama-3.3-70b",
16
+ provider: "groq",
17
+ startedAt: "2026-05-16T17:00:00.000Z",
18
+ };
19
+
20
+ describe("otlpTelemetrySink", () => {
21
+ it("posts to {url}/v1/traces on finish", async () => {
22
+ const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
23
+ const sink = otlpTelemetrySink({
24
+ url: "https://collector.example.com",
25
+ fetchImpl: fetchImpl as unknown as typeof fetch,
26
+ });
27
+
28
+ await sink.start(RUN_START);
29
+ await sink.finish(RUN_START.runId, {
30
+ status: "success",
31
+ finishedAt: "2026-05-16T17:00:05.000Z",
32
+ });
33
+
34
+ // start should NOT post — only finish does (start collected, finish emits).
35
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
36
+ const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
37
+ expect(url).toBe("https://collector.example.com/v1/traces");
38
+ expect((init as { method: string }).method).toBe("POST");
39
+
40
+ const body = JSON.parse((init.body as string) ?? "{}");
41
+ expect(body.resourceSpans).toHaveLength(1);
42
+ const span = body.resourceSpans[0].scopeSpans[0].spans[0];
43
+ expect(span.name).toContain("ooda.cycle");
44
+ expect(span.traceId).toMatch(/^[0-9a-f]{32}$/);
45
+ expect(span.spanId).toMatch(/^[0-9a-f]{16}$/);
46
+ });
47
+
48
+ it("posts a child span per step", async () => {
49
+ const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
50
+ const sink = otlpTelemetrySink({
51
+ url: "https://collector.example.com",
52
+ fetchImpl: fetchImpl as unknown as typeof fetch,
53
+ });
54
+
55
+ await sink.start(RUN_START);
56
+ await sink.step(RUN_START.runId, {
57
+ stepIndex: 0,
58
+ kind: "observe",
59
+ status: "completed",
60
+ inputTokens: 5,
61
+ outputTokens: 10,
62
+ costUsd: 0,
63
+ });
64
+ await sink.finish(RUN_START.runId, {
65
+ status: "success",
66
+ finishedAt: "2026-05-16T17:00:05.000Z",
67
+ });
68
+
69
+ expect(fetchImpl).toHaveBeenCalledTimes(2); // step + finish
70
+ const stepBody = JSON.parse(
71
+ (fetchImpl.mock.calls[0]?.[1] as RequestInit | undefined)?.body as string
72
+ );
73
+ const stepSpan = stepBody.resourceSpans[0].scopeSpans[0].spans[0];
74
+ expect(stepSpan.name).toBe("ooda.observe");
75
+ expect(stepSpan.parentSpanId).toMatch(/^[0-9a-f]{16}$/);
76
+ });
77
+
78
+ it("includes static headers", async () => {
79
+ const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
80
+ const sink = otlpTelemetrySink({
81
+ url: "https://collector.example.com",
82
+ headers: { Authorization: "Bearer secret" },
83
+ fetchImpl: fetchImpl as unknown as typeof fetch,
84
+ });
85
+ await sink.start(RUN_START);
86
+ await sink.finish(RUN_START.runId, {
87
+ status: "success",
88
+ finishedAt: "2026-05-16T17:00:05.000Z",
89
+ });
90
+ const init = fetchImpl.mock.calls[0]?.[1] as RequestInit;
91
+ expect((init.headers as Record<string, string>).Authorization).toBe(
92
+ "Bearer secret"
93
+ );
94
+ });
95
+
96
+ it("rejects when receiver returns non-2xx", async () => {
97
+ const fetchImpl = vi.fn(async () => new Response("bad", { status: 500 }));
98
+ const sink = otlpTelemetrySink({
99
+ url: "https://collector.example.com",
100
+ fetchImpl: fetchImpl as unknown as typeof fetch,
101
+ });
102
+ await sink.start(RUN_START);
103
+ await expect(
104
+ sink.finish(RUN_START.runId, {
105
+ status: "success",
106
+ finishedAt: "2026-05-16T17:00:05.000Z",
107
+ })
108
+ ).rejects.toThrow(/OTLP 500/);
109
+ });
110
+
111
+ it("preserves the caller-provided trace id", async () => {
112
+ const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
113
+ const traceId = "abcdef1234567890abcdef1234567890";
114
+ const sink = otlpTelemetrySink({
115
+ url: "https://collector.example.com",
116
+ fetchImpl: fetchImpl as unknown as typeof fetch,
117
+ });
118
+ await sink.start({ ...RUN_START, traceId });
119
+ await sink.finish(RUN_START.runId, {
120
+ status: "success",
121
+ finishedAt: "2026-05-16T17:00:05.000Z",
122
+ });
123
+ const body = JSON.parse(
124
+ (fetchImpl.mock.calls[0]?.[1] as RequestInit | undefined)?.body as string
125
+ );
126
+ expect(body.resourceSpans[0].scopeSpans[0].spans[0].traceId).toBe(traceId);
127
+ });
128
+
129
+ it("status code maps failed → ERROR(2)", async () => {
130
+ const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
131
+ const sink = otlpTelemetrySink({
132
+ url: "https://c.example.com",
133
+ fetchImpl: fetchImpl as unknown as typeof fetch,
134
+ });
135
+ await sink.start(RUN_START);
136
+ await sink.finish(RUN_START.runId, {
137
+ status: "failed",
138
+ errorMessage: "boom",
139
+ finishedAt: "2026-05-16T17:00:05.000Z",
140
+ });
141
+ const body = JSON.parse(
142
+ (fetchImpl.mock.calls[0]?.[1] as RequestInit | undefined)?.body as string
143
+ );
144
+ expect(body.resourceSpans[0].scopeSpans[0].spans[0].status.code).toBe(2);
145
+ });
146
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * otlpTelemetrySink — OTLP/HTTP exporter for agent runs.
3
+ *
4
+ * Maps {@link TelemetryRunStart} / Step / Finish to OpenTelemetry spans using
5
+ * GenAI semantic conventions. Sends as protobuf-over-HTTP to any compliant
6
+ * OTLP receiver (Tempo, Jaeger, Langfuse self-host, Honeycomb…).
7
+ *
8
+ * Lightweight implementation : JSON over HTTP `application/json` rather than
9
+ * protobuf to avoid pulling in `@opentelemetry/exporter-trace-otlp-proto`.
10
+ * The OTel spec allows JSON-encoded OTLP as a first-class transport (since
11
+ * v1.0). This keeps the SDK zero-dep — no `@opentelemetry/*` runtime needed.
12
+ *
13
+ * Ref: command-center:sprint-693:sink-otlp
14
+ */
15
+
16
+ import type {
17
+ TelemetryRunFinish,
18
+ TelemetryRunStart,
19
+ TelemetryRunStep,
20
+ TelemetrySink,
21
+ } from "../port.js";
22
+
23
+ export interface OtlpTelemetrySinkOptions {
24
+ /**
25
+ * OTLP/HTTP endpoint base URL (no trailing slash).
26
+ * Example : `https://langfuse.vauban.tech/api/public/otel`
27
+ * Path `/v1/traces` is appended automatically.
28
+ */
29
+ url: string;
30
+ /** Static headers (Authorization, x-tenant-id, …). */
31
+ headers?: Record<string, string>;
32
+ /**
33
+ * Service name attached to every span. Defaults to `"vauban-agent-sdk"`.
34
+ * Override per-deployment to disambiguate in your tracing backend.
35
+ */
36
+ serviceName?: string;
37
+ /**
38
+ * fetch implementation override (for tests). Defaults to global `fetch`.
39
+ */
40
+ fetchImpl?: typeof fetch;
41
+ /**
42
+ * Request timeout in ms. Defaults to 5000.
43
+ */
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ // ─── Run state cache (correlate step→span) ───────────────────────────────────
48
+
49
+ interface RunState {
50
+ traceId: string;
51
+ spanId: string;
52
+ startUnixNano: string;
53
+ attributes: Record<string, string | number>;
54
+ steps: number;
55
+ }
56
+
57
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
58
+
59
+ const HEX = "0123456789abcdef";
60
+
61
+ function randHex(bytes: number): string {
62
+ let s = "";
63
+ for (let i = 0; i < bytes * 2; i++) {
64
+ s += HEX[Math.floor(Math.random() * 16)];
65
+ }
66
+ return s;
67
+ }
68
+
69
+ function isoToUnixNano(iso: string): string {
70
+ const ms = Date.parse(iso);
71
+ if (Number.isNaN(ms)) return `${Date.now() * 1_000_000}`;
72
+ return `${ms * 1_000_000}`;
73
+ }
74
+
75
+ function toKeyValue(attrs: Record<string, unknown>): Array<{
76
+ key: string;
77
+ value: { stringValue?: string; intValue?: string; doubleValue?: number };
78
+ }> {
79
+ const out: ReturnType<typeof toKeyValue> = [];
80
+ for (const [k, v] of Object.entries(attrs)) {
81
+ if (v === null || v === undefined) continue;
82
+ if (typeof v === "number") {
83
+ out.push({
84
+ key: k,
85
+ value: Number.isInteger(v)
86
+ ? { intValue: String(v) }
87
+ : { doubleValue: v },
88
+ });
89
+ } else {
90
+ out.push({ key: k, value: { stringValue: String(v) } });
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // ─── Factory ─────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Create an OTLP/HTTP sink. The sink batches at the bus level (FIFO), so
100
+ * one HTTP request is sent per event in this minimal implementation.
101
+ * High-volume deployments should wrap with an upstream batcher.
102
+ */
103
+ export function otlpTelemetrySink(
104
+ opts: OtlpTelemetrySinkOptions
105
+ ): TelemetrySink {
106
+ const baseUrl = opts.url.replace(/\/+$/, "");
107
+ const tracesUrl = `${baseUrl}/v1/traces`;
108
+ const headers = {
109
+ "Content-Type": "application/json",
110
+ ...(opts.headers ?? {}),
111
+ };
112
+ const serviceName = opts.serviceName ?? "vauban-agent-sdk";
113
+ const fetchImpl = opts.fetchImpl ?? fetch;
114
+ const timeoutMs = opts.timeoutMs ?? 5000;
115
+
116
+ const runStates = new Map<string, RunState>();
117
+
118
+ async function postSpan(span: Record<string, unknown>): Promise<void> {
119
+ const body = {
120
+ resourceSpans: [
121
+ {
122
+ resource: {
123
+ attributes: toKeyValue({
124
+ "service.name": serviceName,
125
+ "telemetry.sdk.name": "vauban-agent-sdk",
126
+ "telemetry.sdk.language": "nodejs",
127
+ }),
128
+ },
129
+ scopeSpans: [
130
+ {
131
+ scope: { name: "vauban.agent.ooda", version: "1.3.0" },
132
+ spans: [span],
133
+ },
134
+ ],
135
+ },
136
+ ],
137
+ };
138
+
139
+ const ctrl = new AbortController();
140
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
141
+ try {
142
+ const res = await fetchImpl(tracesUrl, {
143
+ method: "POST",
144
+ headers,
145
+ body: JSON.stringify(body),
146
+ signal: ctrl.signal,
147
+ });
148
+ if (!res.ok) {
149
+ const text = await res.text().catch(() => "");
150
+ throw new Error(`OTLP ${res.status}: ${text.slice(0, 200)}`);
151
+ }
152
+ } finally {
153
+ clearTimeout(t);
154
+ }
155
+ }
156
+
157
+ return {
158
+ name: "otlp",
159
+
160
+ async start(event: TelemetryRunStart) {
161
+ const traceId = event.traceId ?? randHex(16);
162
+ const spanId = randHex(8);
163
+ runStates.set(event.runId, {
164
+ traceId,
165
+ spanId,
166
+ startUnixNano: isoToUnixNano(event.startedAt),
167
+ attributes: {
168
+ "vauban.run_id": event.runId,
169
+ "vauban.agent.id": event.agentId,
170
+ "vauban.agent.version": event.agentVersion,
171
+ "gen_ai.system": event.provider,
172
+ "gen_ai.request.model": event.model,
173
+ ...(event.tenantId ? { "vauban.tenant.id": event.tenantId } : {}),
174
+ },
175
+ steps: 0,
176
+ });
177
+ // Don't post yet — wait for finish to send full span (start+end).
178
+ },
179
+
180
+ async step(runId: string, delta: TelemetryRunStep) {
181
+ const state = runStates.get(runId);
182
+ if (!state) return;
183
+ state.steps += 1;
184
+ // Emit a child span per step for granular tracing.
185
+ const stepStart = isoToUnixNano(new Date().toISOString());
186
+ const durationNs = (delta.durationMs ?? 0) * 1_000_000;
187
+ const stepSpan = {
188
+ traceId: state.traceId,
189
+ spanId: randHex(8),
190
+ parentSpanId: state.spanId,
191
+ name: `ooda.${delta.kind}`,
192
+ kind: 1, // SPAN_KIND_INTERNAL
193
+ startTimeUnixNano: stepStart,
194
+ endTimeUnixNano: String(BigInt(stepStart) + BigInt(durationNs)),
195
+ attributes: toKeyValue({
196
+ "vauban.step.index": delta.stepIndex,
197
+ "vauban.step.kind": delta.kind,
198
+ "vauban.step.status": delta.status,
199
+ "gen_ai.usage.input_tokens": delta.inputTokens,
200
+ "gen_ai.usage.output_tokens": delta.outputTokens,
201
+ "vauban.step.tool_calls": delta.toolCalls ?? 0,
202
+ "vauban.step.cost_usd": delta.costUsd,
203
+ }),
204
+ status: {
205
+ code: delta.status === "failed" ? 2 : 1, // ERROR=2, OK=1
206
+ },
207
+ };
208
+ await postSpan(stepSpan);
209
+ },
210
+
211
+ async finish(runId: string, event: TelemetryRunFinish) {
212
+ const state = runStates.get(runId);
213
+ if (!state) return;
214
+ runStates.delete(runId);
215
+
216
+ const endNano = isoToUnixNano(event.finishedAt);
217
+ const span = {
218
+ traceId: state.traceId,
219
+ spanId: state.spanId,
220
+ name: `ooda.cycle.${state.attributes["vauban.agent.id"] ?? "agent"}`,
221
+ kind: 1,
222
+ startTimeUnixNano: state.startUnixNano,
223
+ endTimeUnixNano: endNano,
224
+ attributes: toKeyValue({
225
+ ...state.attributes,
226
+ "vauban.run.status": event.status,
227
+ ...(event.stopReason
228
+ ? { "vauban.run.stop_reason": event.stopReason }
229
+ : {}),
230
+ ...(event.totalInputTokens !== undefined
231
+ ? { "gen_ai.usage.input_tokens": event.totalInputTokens }
232
+ : {}),
233
+ ...(event.totalOutputTokens !== undefined
234
+ ? { "gen_ai.usage.output_tokens": event.totalOutputTokens }
235
+ : {}),
236
+ ...(event.totalCostUsd !== undefined
237
+ ? { "vauban.run.cost_usd": event.totalCostUsd }
238
+ : {}),
239
+ "vauban.run.steps": state.steps,
240
+ }),
241
+ status: {
242
+ code:
243
+ event.status === "failed" || event.status === "incoherent" ? 2 : 1,
244
+ ...(event.errorMessage ? { message: event.errorMessage } : {}),
245
+ },
246
+ };
247
+ await postSpan(span);
248
+ },
249
+ };
250
+ }