@gr8ful/spf 0.17.0 → 0.19.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/core/otel.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OpenTelemetry span export (v1): a config-gated, lossy, fire-and-forget
2
+ * OpenTelemetry span export (v2): a config-gated, lossy, fire-and-forget
3
3
  * PROJECTION of the trace SQLite already holds. Read this header before
4
4
  * changing anything here — every paragraph is a constraint that survived an
5
5
  * adversarial review, not a preference.
@@ -12,35 +12,84 @@
12
12
  * ever throw into a caller, block a caller, or be awaited by a caller other
13
13
  * than the two shutdown paths named under LIFECYCLE below.
14
14
  *
15
- * SPANS ONLY. No `resourceMetrics`, no `resourceLogs`. The OTLP metrics data
16
- * model (temporality, monotonicity, cumulative-vs-delta) is exactly where a
17
- * hand-rolled encoder produces numbers a backend silently misreads, and a
18
- * wrong cost number is worse than no cost number. Token counts and dollars
19
- * ride as span ATTRIBUTES instead. Do not "just add metrics" here.
15
+ * v2 CHANGE (SDK ENCODER SWAP). v1 hand-rolled the entire OTLP/HTTP-JSON wire
16
+ * format with its own `fetch()` call. v2 keeps every invariant below —
17
+ * public API, the deterministic sha256 id scheme, the attribute allowlist,
18
+ * the bounded queue, the per-run lifecycle byte-for-byte, and replaces
19
+ * ONLY the encoder: spans are now plain objects that structurally satisfy
20
+ * `@opentelemetry/sdk-trace`'s `ReadableSpan` interface (that package's own
21
+ * concrete `Span`/`SpanImpl` class is NOT part of its public API surface —
22
+ * only the type is exported — so a duck-typed object is not a workaround,
23
+ * it is the intended integration point), handed to a real
24
+ * `@opentelemetry/exporter-trace-otlp-http` `OTLPTraceExporter` instance.
25
+ * `IdGenerator.generateSpanId()` takes no arguments and cannot be handed our
26
+ * sha256 ids any other way — this is why the SDK is used AROUND our own ids
27
+ * rather than asked to generate them.
28
+ *
29
+ * VERIFIED WIRE-SHAPE DIFFERENCES from the old hand-rolled encoder (proven
30
+ * against a real in-process OTLP/HTTP receiver in `src/test/otel.test.ts`,
31
+ * not assumed from docs — this was v1's #1 documented open risk):
32
+ * - `intValue` is a JSON NUMBER (`{"intValue":1234}`), not a numeric
33
+ * STRING. The real JSON serializer's `toAnyValue()` (`@opentelemetry/
34
+ * otlp-transformer`) picks `intValue` whenever `Number.isInteger(value)`
35
+ * and never stringifies it — proto3 JSON's "int64 as string" rule is a
36
+ * PROTOBUF-JSON convention this exporter's plain-JSON path does not
37
+ * follow. A whole-number COST (e.g. exactly `$2`) is therefore
38
+ * indistinguishable on the wire from an integer attribute — a real,
39
+ * accepted limitation of `number`-typed OTel attributes, not a bug
40
+ * introduced here.
41
+ * - `startTimeUnixNano`/`endTimeUnixNano`/event `timeUnixNano` ARE
42
+ * STRINGS (`encodeAsString` — nanoseconds via `BigInt`, so no
43
+ * precision loss past 2^53), matching v1's own precision-driven choice.
44
+ * - trace/span ids are lowercase hex STRINGS (the JSON encoder's
45
+ * `encodeSpanContext` is `identity` — our own hex ids pass straight
46
+ * through), matching v1 exactly.
47
+ * - a ROOT span's `parentSpanId` is OMITTED from the wire object entirely
48
+ * (no key at all) rather than v1's explicit `""` — both spellings mean
49
+ * "no parent" per the OTLP proto3-JSON mapping (proto3 JSON drops
50
+ * zero-value/unset fields by default); `src/test/otel.test.ts` asserts
51
+ * `undefined`, not `""`, for a root span now.
52
+ * - extra fields the real exporter adds that v1 never had (`flags`,
53
+ * `traceState`, `droppedAttributesCount`, `droppedEventsCount`,
54
+ * `droppedLinksCount`, `links: []`) are additive and harmless — nothing
55
+ * downstream reads a fixed field LIST, only named fields.
56
+ *
57
+ * SPANS ONLY (from THIS module's own per-run exporter). No `resourceLogs`.
58
+ * Metrics are now real (see `otel_metrics.ts`) but live on their own
59
+ * PROCESS-scoped pipeline with their own real `@opentelemetry/sdk-metrics`
60
+ * temporality/aggregation handling — never hand-rolled, and never mixed into
61
+ * this module's `resourceSpans` payload.
20
62
  *
21
63
  * EXPLICIT CONFIG ONLY. Activation requires `observability.otel.endpoint` in
22
64
  * the config file. This module NEVER reads `OTEL_EXPORTER_OTLP_ENDPOINT` or
23
- * any other ambient exporter variable: an unrelated shell variable inherited
24
- * from a CI image or a coworker's dotfiles must not be able to turn a repo's
25
- * telemetry egress on. (`SPF_CLAUDE_CMD` is not a precedent for the opposite:
26
- * that variable is SPF-namespaced and only redirects a LOCAL subprocess — it
27
- * moves no data off the machine.)
65
+ * any other ambient exporter variable AS AN ACTIVATION SWITCH: an unrelated
66
+ * shell variable inherited from a CI image or a coworker's dotfiles must not
67
+ * be able to turn a repo's telemetry egress on. `observability.otel.
68
+ * allow_env` (see `data_types.ts`'s `OTelConfigSchema`) is the one narrow,
69
+ * opt-in exception: when `true` AND the block is ALREADY active (`endpoint`
70
+ * set), `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` may
71
+ * SUPPLEMENT it (an env-injected token in CI, say) — never activate it from
72
+ * nothing, and config-declared values always win over the env on conflict.
73
+ * (`SPF_CLAUDE_CMD` is not a precedent for the opposite: that variable is
74
+ * SPF-namespaced and only redirects a LOCAL subprocess — it moves no data
75
+ * off the machine.)
28
76
  *
29
77
  * ATTRIBUTE ALLOWLIST — exfiltration is the top risk here, because
30
78
  * `EventRecord.payload` carries the repository's own source code (tool args,
31
79
  * result snippets, diffs, prompts, envelope contents, the operator's request
32
80
  * text). The allowlist, in full: phase name/kind/owner/status/seq/attempt,
33
- * chain name, adw_id, agent name/model/coding_agent, gate name + passed +
34
- * violation COUNT, token counts (UsageBreakdown fields) + costs, durations
35
- * (implied by span start/end), and event TYPE. Everything else is excluded by
36
- * construction, not by filtering:
81
+ * chain name, adw_id, agent name/model/coding_agent/lora_adapter, gate name +
82
+ * passed + violation COUNT, token counts (UsageBreakdown fields) + costs,
83
+ * durations (implied by span start/end), and event TYPE. Everything else is
84
+ * excluded by construction, not by filtering:
37
85
  * - This module reads `EventRecord.payload` for FINITE NUMBERS ONLY (see
38
86
  * `numOrNull`) and only under known UsageBreakdown/cost keys. A string can
39
87
  * never reach an attribute through the payload path. Do not add a
40
- * `stringValue` read from `payload` — that single line is the whole
88
+ * string read from `payload` — that single line is the whole
41
89
  * exfiltration bug.
42
- * - Agent model/coding_agent come from the typed `AgentConfig` handed to
43
- * `recordAgentSession` (config data), NOT from the `agent_start` payload.
90
+ * - Agent model/coding_agent/lora_adapter come from the typed `AgentConfig`
91
+ * handed to `recordAgentSession` (config data), NOT from the
92
+ * `agent_start` payload.
44
93
  * - Tool spans are named from `record.name`'s prefix up to the first ":"
45
94
  * (see `toolSpanName`). The full `record.name` is a HUMAN LABEL built from
46
95
  * real tool arguments (`agent_flue.ts`'s `labelFor` -> "bash: cat
@@ -71,7 +120,9 @@
71
120
  * `agent:<phase_id>:<agent>:<n>` for an agent call, `tool:<phase_id>:<event_id>`
72
121
  * for a tool call). Determinism means a re-export of the same run lands on the
73
122
  * same ids instead of duplicating the trace, and a child span can name its
74
- * parent's id without waiting for the parent to be emitted.
123
+ * parent's id without waiting for the parent to be emitted. UNCHANGED in v2:
124
+ * the SDK is used to ENCODE spans we already fully control, never to
125
+ * generate their ids.
75
126
  * `EventRecord.parent_id` is structurally ALWAYS EMPTY today (SPF's phases are
76
127
  * flat siblings; nothing writes nesting), so there is no recorded hierarchy to
77
128
  * mine — the parenting above is reconstructed from phase_id + agent-call
@@ -79,9 +130,9 @@
79
130
  *
80
131
  * PHASE SPANS ARE EMITTED AT PHASE END ONLY. A hung or killed phase is
81
132
  * therefore INVISIBLE to the backend (its buffered span events die with it),
82
- * while SQLite still shows it as `running`. Deliberate v1 trade: streaming a
83
- * span at phase start would require mutating an already-sent span, which OTLP
84
- * has no notion of. Recorded here so it is a known gap, not a surprise.
133
+ * while SQLite still shows it as `running`. Deliberate v1 trade, unchanged:
134
+ * streaming a span at phase start would require mutating an already-sent
135
+ * span, which OTLP has no notion of.
85
136
  *
86
137
  * INBOUND TRACEPARENT. When a valid W3C `traceparent` is present in the
87
138
  * environment, its trace-id becomes this run's trace-id and the run's root
@@ -92,6 +143,37 @@
92
143
  * silently (see `parseTraceparent`) — a malformed variable must degrade to
93
144
  * "own root", never to an error.
94
145
  *
146
+ * OUTBOUND PROPAGATION (new in v2). `agentCallTraceContext()` hands back the
147
+ * CURRENTLY OPEN agent call's own trace context (same traceId, same sha256
148
+ * span id already computed by `openAgentCall`) so a caller can propagate it
149
+ * onward — `agents.ts`'s `send()` reads it into `AgentRequest.otel`, which
150
+ * `agent_cc.ts`'s single `spawn()` choke point turns into `TRACEPARENT` +
151
+ * `ANTHROPIC_CUSTOM_HEADERS` env vars for the `claude` CLI subprocess (see
152
+ * that module's own header for the verified env var format). This is a
153
+ * READ of state this exporter already tracks for its own id scheme — it
154
+ * does not change what gets exported, and it is `null` (a silent no-op)
155
+ * whenever no agent call is currently open.
156
+ *
157
+ * LORA ADAPTER ATTRIBUTE. `loraAdapterFor()` resolves `spf.lora_adapter` —
158
+ * see its own doc comment for the two zero-config conventions plus the
159
+ * explicit `AgentConfig.lora_adapter` override, checked in that order. The
160
+ * attribute is omitted entirely when nothing resolves — never a blind copy
161
+ * of a non-LoRA model id.
162
+ *
163
+ * METRICS FAN-OUT (new in v2). `recordPhase`/`recordGate`/`closeAgentCall`
164
+ * additionally fan out to an OPTIONAL, PROCESS-scoped `OtelMetrics` handle
165
+ * (see `otel_metrics.ts`) — `spf.phase.duration`, `spf.gate.result`,
166
+ * `spf.tokens`, `spf.cost_usd`, `spf.agent.calls`. `resolveOtelExporter`
167
+ * resolves it once via `otel_metrics.resolveOtelMetrics(cfg)` and holds the
168
+ * reference; every OTHER call site (`tracer.ts`, `agents.ts`) is BYTE-
169
+ * IDENTICAL to before metrics existed. The v1 "dropped spans" resource
170
+ * attribute hack is GONE (a `Resource` is immutable per-exporter-instance in
171
+ * the real SDK — there is no home for a value that changes after
172
+ * construction) — dropped-span/dropped-event counts now ride as real
173
+ * Counters on the metrics pipeline instead, recorded once (same "once, on
174
+ * the final flush" cadence the warn log already used), with the warn log
175
+ * itself UNCHANGED as the fallback when metrics are off.
176
+ *
95
177
  * LIFECYCLE (copied from `notify/notifier.ts`'s discipline, with one
96
178
  * addition `notify` doesn't need — see RUN-SCOPED CLEANUP below). A
97
179
  * module-level LIVE registry holds every exporter this process created;
@@ -133,22 +215,34 @@
133
215
  * Spans go into a BOUNDED queue (`MAX_QUEUED_SPANS`, drop-OLDEST) and leave in
134
216
  * batches (`BATCH_SPANS`, or `FLUSH_INTERVAL_MS`, whichever comes first) via an
135
217
  * UNREF'D timer that can never hold the process open. Dropped spans are
136
- * counted, reported once as a warn line, and exported as a resource attribute
137
- * on the final flush so the gap is visible in the backend too. The size
138
- * trigger schedules a timer rather than flushing inline, which also means a
139
- * synchronous burst of thousands of events exercises the bound (see the queue
140
- * test) instead of interleaving sends.
218
+ * counted and reported once as a warn line (and once as a metric, when one is
219
+ * configured see METRICS FAN-OUT above). The size trigger schedules a timer
220
+ * rather than flushing inline, which also means a synchronous burst of
221
+ * thousands of events exercises the bound (see the queue test) instead of
222
+ * interleaving sends.
141
223
  *
142
- * WIRE FORMAT is hand-rolled OTLP/HTTP with a JSON body — no new npm
143
- * dependency for an optional, lossy projection. The shape that matters:
144
- * `{resourceSpans:[{resource:{attributes:[KeyValue]},scopeSpans:[{scope,spans:[Span]}]}]}`,
145
- * every attribute value wrapped in an AnyValue (`{stringValue}`/`{intValue}`/
146
- * `{doubleValue}`/`{boolValue}`), trace/span ids as lowercase hex strings, and
147
- * every uint64 nanosecond timestamp AS A STRING (a JSON number would lose
148
- * precision past 2^53 and backends reject it). `src/test/otel.test.ts` pins
149
- * this shape against an in-process receiver.
224
+ * WIRE TRANSPORT is `@opentelemetry/exporter-trace-otlp-http`'s real
225
+ * `OTLPTraceExporter`, JSON-encoded (its default) against the resolved
226
+ * `/v1/traces` URL — see the VERIFIED WIRE-SHAPE DIFFERENCES note above for
227
+ * exactly how its bytes differ from v1's hand-rolled ones.
228
+ * `keepAlive: false` is passed explicitly: the real Node HTTP agent defaults
229
+ * `keepAlive: true`, which would hold an open socket past this exporter's own
230
+ * bounded `drain()` the same "must never be the reason a `spf` process
231
+ * lingers" requirement the unref'd flush timer already exists for.
232
+ * `timeoutMillis: SEND_TIMEOUT_MS` bounds the exporter's own internal
233
+ * retrying transport (up to 5 attempts, capped by this same deadline across
234
+ * all of them — verified against `@opentelemetry/otlp-exporter-base`'s
235
+ * `RetryingTransport` source) to the same budget the old hand-rolled
236
+ * `AbortController` enforced.
150
237
  */
151
238
  import { createHash } from "node:crypto";
239
+ import { SpanKind, TraceFlags } from "@opentelemetry/api";
240
+ import { ExportResultCode } from "@opentelemetry/core";
241
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
242
+ import { JsonTraceSerializer } from "@opentelemetry/otlp-transformer";
243
+ import { resourceFromAttributes } from "@opentelemetry/resources";
244
+ import { applyOtelEnvSupplement } from "./data_types.js";
245
+ import { resolveOtelMetrics } from "./otel_metrics.js";
152
246
  // ── tunables (see BACKPRESSURE above) ───────────────────────────────────────
153
247
  const MAX_QUEUED_SPANS = 2048;
154
248
  const BATCH_SPANS = 64;
@@ -157,7 +251,6 @@ const FLUSH_INTERVAL_MS = 2_000;
157
251
  const SEND_TIMEOUT_MS = 2_000;
158
252
  /** Span events buffered per phase while it runs; a runaway phase cannot grow unbounded. */
159
253
  const MAX_EVENTS_PER_SPAN = 64;
160
- const SPAN_KIND_INTERNAL = 1;
161
254
  const STATUS_UNSET = 0;
162
255
  const STATUS_OK = 1;
163
256
  const STATUS_ERROR = 2;
@@ -250,19 +343,33 @@ export function endpointLabel(endpoint) {
250
343
  return "(unparseable endpoint)";
251
344
  }
252
345
  }
253
- /**
254
- * ISO-8601 -> uint64 nanoseconds AS A STRING (see WIRE FORMAT). Unparseable,
255
- * missing, or pre-epoch input falls back to `fallbackMs`, because a span with
256
- * a nonsense timestamp is rejected wholesale by most backends while a span
257
- * with an approximate one is still useful.
258
- */
259
- export function nanosFromIso(iso, fallbackMs = Date.now()) {
346
+ /** ISO -> epoch milliseconds, with a fallback for unparseable/missing/pre-epoch input. Shared by `nanosFromIso` and the internal `HrTime` builder below. */
347
+ function resolveMs(iso, fallbackMs) {
260
348
  const parsed = iso ? Date.parse(iso) : NaN;
261
- const ms = Number.isFinite(parsed) && parsed >= 0 ? parsed : fallbackMs;
349
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallbackMs;
350
+ }
351
+ /** ISO-8601 -> uint64 nanoseconds AS A STRING. Unparseable, missing, or pre-epoch input falls back to `fallbackMs`. Kept as its own public, string-returning helper — pinned by tests since before the SDK swap. */
352
+ export function nanosFromIso(iso, fallbackMs = Date.now()) {
353
+ const ms = resolveMs(iso, fallbackMs);
262
354
  // String concat, not BigInt math: ms is an integer, and appending six zeros
263
355
  // is exact where `ms * 1e6` would drift into float territory.
264
356
  return `${Math.floor(ms)}000000`;
265
357
  }
358
+ /** Same fallback logic as `nanosFromIso`, as the `[seconds, nanoseconds]` tuple `ReadableSpan.startTime`/`endTime` actually want. */
359
+ function hrTimeFromIso(iso, fallbackMs = Date.now()) {
360
+ const ms = resolveMs(iso, fallbackMs);
361
+ return [Math.floor(ms / 1000), Math.floor(ms % 1000) * 1_000_000];
362
+ }
363
+ /** Best-effort, informational only — never serialized to the wire (OTLP has no "duration" field; start/end carry it). */
364
+ function hrDuration(start, end) {
365
+ let sec = end[0] - start[0];
366
+ let nano = end[1] - start[1];
367
+ if (nano < 0) {
368
+ sec -= 1;
369
+ nano += 1_000_000_000;
370
+ }
371
+ return sec < 0 ? [0, 0] : [sec, nano];
372
+ }
266
373
  /**
267
374
  * The safe half of a `tool_call` event's name. `record.name` for a tool call is
268
375
  * a human label built FROM THE TOOL'S ARGUMENTS ("bash: cat src/secret.ts",
@@ -300,10 +407,6 @@ export function redact(message, secrets) {
300
407
  function numOrNull(value) {
301
408
  return typeof value === "number" && Number.isFinite(value) ? value : null;
302
409
  }
303
- const str = (key, value) => ({ key, value: { stringValue: value } });
304
- const int = (key, value) => ({ key, value: { intValue: String(Math.trunc(value)) } });
305
- const dbl = (key, value) => ({ key, value: { doubleValue: value } });
306
- const bool = (key, value) => ({ key, value: { boolValue: value } });
307
410
  /** UsageBreakdown's token fields -> attribute suffixes. Numbers only, by construction. */
308
411
  const TOKEN_FIELDS = [
309
412
  ["input_tokens", "spf.tokens.input"],
@@ -320,6 +423,35 @@ const COST_FIELDS = [
320
423
  ["cache_write_cost", "spf.cost.cache_write"],
321
424
  ["total_cost", "spf.cost.total"],
322
425
  ];
426
+ /**
427
+ * `spf.lora_adapter` — the served LoRA adapter name for this agent's model,
428
+ * when one can be determined. Checked in order, first match wins:
429
+ * 1. `agent.lora_adapter` (explicit config) — set once per agent, the
430
+ * unambiguous source of truth an operator can always fall back to.
431
+ * 2. `provider/base:adapter` — an explicit adapter suffix after the LAST
432
+ * ":" in the model id (e.g. `vllm/nemotron-base:my-lora` -> `my-lora`).
433
+ * 3. `provider/adapter-name` — no ":" in the model id, but the id itself
434
+ * contains "-lora-" (case-insensitive) — this org's own vLLM/Switchyard
435
+ * served-model naming convention (`k8s/manifests/switchyard/
436
+ * configmap-routes.yaml`: "id MUST equal the name= half of the matching
437
+ * --lora-modules entry", e.g. `nemotron-lora-placeholder`) — the WHOLE
438
+ * model id (minus the `provider/` prefix) IS the adapter name in this
439
+ * convention, since vLLM resolves LoRA adapters by served-model name,
440
+ * not by a base-model-plus-suffix split.
441
+ * No match on any of the three -> `null`, and the attribute is omitted
442
+ * entirely — never a blind copy of a non-LoRA model id.
443
+ */
444
+ export function loraAdapterFor(agent) {
445
+ if (agent.lora_adapter)
446
+ return clip(agent.lora_adapter);
447
+ const model = agent.model ?? "";
448
+ const slash = model.indexOf("/");
449
+ const modelId = slash === -1 ? model : model.slice(slash + 1);
450
+ const colon = modelId.lastIndexOf(":");
451
+ if (colon !== -1 && colon < modelId.length - 1)
452
+ return clip(modelId.slice(colon + 1));
453
+ return /-lora-/i.test(modelId) ? clip(modelId) : null;
454
+ }
323
455
  export class OtelExporter {
324
456
  cfg;
325
457
  adwId;
@@ -327,10 +459,14 @@ export class OtelExporter {
327
459
  serviceName;
328
460
  url;
329
461
  log;
462
+ metrics;
330
463
  traceId;
331
464
  /** "" unless an inbound traceparent parented this run — see INBOUND TRACEPARENT. */
332
465
  rootParentSpanId;
333
466
  rootSpanId;
467
+ resource;
468
+ scope = { name: "spf", version: "1" };
469
+ spanExporter;
334
470
  queue = [];
335
471
  dropped = 0;
336
472
  droppedEvents = 0;
@@ -349,7 +485,7 @@ export class OtelExporter {
349
485
  * nothing will ever drain.
350
486
  */
351
487
  emittedPhases = new Set();
352
- /** `<phase_id><agent>` -> the open agent call, for closing it and parenting tool spans. */
488
+ /** `<phase_id> <agent>` -> the open agent call, for closing it and parenting tool spans. */
353
489
  openAgents = new Map();
354
490
  /** How many times an agent has been called in a phase, so a retry gets its own span id. */
355
491
  agentCalls = new Map();
@@ -364,10 +500,25 @@ export class OtelExporter {
364
500
  this.serviceName = init.cfg.service_name || "spf";
365
501
  this.url = resolveTracesUrl(init.cfg.endpoint);
366
502
  this.log = init.log ?? ((m) => console.error(m));
503
+ this.metrics = init.metrics ?? null;
367
504
  const inbound = inboundTraceparent(init.env ?? process.env);
368
505
  this.traceId = inbound ? inbound.traceId : traceIdFor(init.adwId);
369
506
  this.rootParentSpanId = inbound ? inbound.spanId : "";
370
507
  this.rootSpanId = spanIdFor(`run:${init.adwId}`);
508
+ this.resource = resourceFromAttributes({
509
+ "service.name": this.serviceName,
510
+ "spf.adw_id": this.adwId,
511
+ "spf.chain": this.chainName,
512
+ });
513
+ this.spanExporter = new OTLPTraceExporter({
514
+ url: this.url,
515
+ headers: init.cfg.headers,
516
+ timeoutMillis: SEND_TIMEOUT_MS,
517
+ // The Node HTTP agent defaults `keepAlive: true`, which would hold a
518
+ // socket open past this exporter's own bounded drain() — see the
519
+ // module header's WIRE TRANSPORT note.
520
+ keepAlive: false,
521
+ });
371
522
  }
372
523
  // ── fan-out seams (called from tracer.ts's write methods) ────────────────
373
524
  /** `tracer.sessionStart` — only the run's clock; the engineer name is not allowlisted. */
@@ -416,11 +567,11 @@ export class OtelExporter {
416
567
  case "handoff":
417
568
  case "error":
418
569
  this.bufferSpanEvent(record.phase_id, {
419
- timeUnixNano: nanosFromIso(record.started_at ?? tsIso),
570
+ time: hrTimeFromIso(record.started_at ?? tsIso),
420
571
  name: record.type,
421
572
  // `record.name` is code- or config-declared (a phase name, a gate
422
573
  // name, "paths_touched"), never agent output — unlike payload.
423
- attributes: [str("spf.event.type", record.type), str("spf.event.name", clip(record.name))],
574
+ attributes: { "spf.event.type": record.type, "spf.event.name": clip(record.name) },
424
575
  });
425
576
  return;
426
577
  default:
@@ -439,29 +590,35 @@ export class OtelExporter {
439
590
  return;
440
591
  const spanId = spanIdFor(phase.phase_id);
441
592
  this.emittedPhases.add(phase.phase_id);
442
- const attributes = [
443
- str("spf.adw_id", this.adwId),
444
- str("spf.chain", this.chainName),
445
- str("spf.phase.name", clip(phase.params.name)),
446
- str("spf.phase.kind", clip(phase.params.kind)),
447
- str("spf.phase.owner", clip(phase.params.owner)),
448
- str("spf.phase.status", clip(phase.status)),
449
- int("spf.phase.seq", phase.seq),
450
- int("spf.phase.attempt", phase.attempt),
451
- ];
452
- this.enqueue({
453
- traceId: this.traceId,
593
+ const attributes = {
594
+ "spf.adw_id": this.adwId,
595
+ "spf.chain": this.chainName,
596
+ "spf.phase.name": clip(phase.params.name),
597
+ "spf.phase.kind": clip(phase.params.kind),
598
+ "spf.phase.owner": clip(phase.params.owner),
599
+ "spf.phase.status": clip(phase.status),
600
+ "spf.phase.seq": Math.trunc(phase.seq),
601
+ "spf.phase.attempt": Math.trunc(phase.attempt),
602
+ };
603
+ const start = hrTimeFromIso(phase.started_at, this.runStartedAtMs);
604
+ const end = hrTimeFromIso(phase.ended_at);
605
+ this.enqueue(this.makeSpan({
454
606
  spanId,
455
607
  parentSpanId: this.rootSpanId,
456
608
  name: `phase ${phase.params.name}`,
457
- kind: SPAN_KIND_INTERNAL,
458
- startTimeUnixNano: nanosFromIso(phase.started_at, this.runStartedAtMs),
459
- endTimeUnixNano: nanosFromIso(phase.ended_at),
609
+ start,
610
+ end,
460
611
  attributes,
461
612
  // `phase.error` is deliberately absent: agent- and repo-derived text.
462
613
  // The ERROR status is the whole signal a backend gets.
463
- status: { code: phase.status === "success" ? STATUS_OK : STATUS_ERROR },
614
+ statusCode: phase.status === "success" ? STATUS_OK : STATUS_ERROR,
464
615
  events: this.takeBufferedEvents(phase.phase_id),
616
+ }));
617
+ const durationSeconds = (end[0] + end[1] / 1e9) - (start[0] + start[1] / 1e9);
618
+ this.metrics?.recordPhaseDuration(Math.max(0, durationSeconds), {
619
+ kind: phase.params.kind,
620
+ owner: phase.params.owner,
621
+ status: phase.status,
465
622
  });
466
623
  }
467
624
  /**
@@ -471,15 +628,16 @@ export class OtelExporter {
471
628
  */
472
629
  recordGate(phase, gate, report, attempt) {
473
630
  this.bufferSpanEvent(phase.phase_id, {
474
- timeUnixNano: nanosFromIso(null),
631
+ time: hrTimeFromIso(null),
475
632
  name: report.passed ? "gate_pass" : "gate_fail",
476
- attributes: [
477
- str("spf.gate.name", clip(gate)),
478
- bool("spf.gate.passed", report.passed),
479
- int("spf.gate.violation_count", report.violations.length),
480
- int("spf.gate.attempt", attempt),
481
- ],
633
+ attributes: {
634
+ "spf.gate.name": clip(gate),
635
+ "spf.gate.passed": report.passed,
636
+ "spf.gate.violation_count": Math.trunc(report.violations.length),
637
+ "spf.gate.attempt": Math.trunc(attempt),
638
+ },
482
639
  });
640
+ this.metrics?.recordGateResult(clip(gate), report.passed);
483
641
  }
484
642
  /**
485
643
  * `tracer.agentSessionRow` — the typed source for an agent's model and
@@ -490,7 +648,11 @@ export class OtelExporter {
490
648
  * measure).
491
649
  */
492
650
  recordAgentSession(agent) {
493
- this.agentMeta.set(agent.name, { model: agent.model, codingAgent: agent.coding_agent });
651
+ this.agentMeta.set(agent.name, {
652
+ model: agent.model,
653
+ codingAgent: agent.coding_agent,
654
+ loraAdapter: loraAdapterFor(agent),
655
+ });
494
656
  }
495
657
  /**
496
658
  * `tracer.sessionFinish` — emits the root run span exactly once. Called
@@ -500,6 +662,20 @@ export class OtelExporter {
500
662
  recordSessionFinish(ok) {
501
663
  this.emitRootSpan(ok ? "success" : "fail", ok ? STATUS_OK : STATUS_ERROR);
502
664
  }
665
+ // ── outbound propagation seam (new in v2 — see the header) ───────────────
666
+ /**
667
+ * The currently-open agent call's trace context — `null` when otel has no
668
+ * agent call open for this phase+agent pair right now (agent_start hasn't
669
+ * fired, or already closed). `agents.ts`'s `send()` reads this into
670
+ * `AgentRequest.otel`; a `null` here just means that field stays unset, a
671
+ * plain no-op for every backend that doesn't propagate it.
672
+ */
673
+ agentCallTraceContext(phaseId, agentName) {
674
+ const open = this.openAgents.get(this.agentKey(phaseId, agentName));
675
+ if (!open)
676
+ return null;
677
+ return { traceparent: `00-${this.traceId}-${open.spanId}-01`, spanId: open.spanId };
678
+ }
503
679
  // ── queue + batching ────────────────────────────────────────────────────
504
680
  /** Queued spans and spans/events dropped so far. For tests and diagnostics. */
505
681
  stats() {
@@ -507,12 +683,15 @@ export class OtelExporter {
507
683
  }
508
684
  /**
509
685
  * The exact JSON body the next flush would POST, without sending or
510
- * draining. This is the seam `src/test/otel.test.ts` uses to prove the
511
- * allowlist holds the assertion is on the literal bytes, so any future
512
- * attribute that leaks a payload fails a test rather than a review.
686
+ * draining via the SAME `JsonTraceSerializer` the real exporter uses
687
+ * internally, so this is not a second, possibly-diverging encoding path.
688
+ * This is the seam `src/test/otel.test.ts` uses to prove the allowlist
689
+ * holds — the assertion is on the literal bytes, so any future attribute
690
+ * that leaks a payload fails a test rather than a review.
513
691
  */
514
692
  pendingJson() {
515
- return JSON.stringify(this.payloadFor(this.queue, false));
693
+ const bytes = JsonTraceSerializer.serializeRequest(this.queue);
694
+ return bytes ? Buffer.from(bytes).toString("utf-8") : "{}";
516
695
  }
517
696
  enqueue(span) {
518
697
  if (this.queue.length >= MAX_QUEUED_SPANS) {
@@ -546,7 +725,11 @@ export class OtelExporter {
546
725
  /**
547
726
  * Send whatever is queued. Never throws, never rejects: a failed export is a
548
727
  * single redacted log line and a swallowed error, because the alternative is
549
- * an observability feature that can fail a run.
728
+ * an observability feature that can fail a run. `OTLPTraceExporter.export()`
729
+ * itself already never throws and always calls its callback exactly once
730
+ * (verified against `@opentelemetry/otlp-exporter-base`'s
731
+ * `OTLPExportDelegate.export()` source) — the try/catch here is belt-and-
732
+ * braces for that contract, not a load-bearing guard.
550
733
  */
551
734
  async flush(isFinal = false) {
552
735
  if (this.timer) {
@@ -558,30 +741,26 @@ export class OtelExporter {
558
741
  return;
559
742
  const spans = this.queue;
560
743
  this.queue = [];
561
- const body = JSON.stringify(this.payloadFor(spans, isFinal));
562
744
  if (isFinal && this.dropped > 0 && !this.warnedDrops) {
563
745
  this.warnedDrops = true;
564
746
  this.log(`spf: otel export dropped ${this.dropped} span(s) — the queue bound (${MAX_QUEUED_SPANS}) was hit`);
747
+ this.metrics?.recordDroppedSpans(this.dropped);
748
+ if (this.droppedEvents > 0)
749
+ this.metrics?.recordDroppedSpanEvents(this.droppedEvents);
565
750
  }
566
- const controller = new AbortController();
567
- const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
568
- timer.unref?.();
569
751
  try {
570
- const response = await fetch(this.url, {
571
- method: "POST",
572
- headers: { "content-type": "application/json", ...(this.cfg.headers ?? {}) },
573
- body,
574
- signal: controller.signal,
752
+ await new Promise((resolve) => {
753
+ this.spanExporter.export(spans, (result) => {
754
+ if (result.code !== ExportResultCode.SUCCESS) {
755
+ this.logFailureOnce(result.error?.message ?? String(result.error ?? "export failed"));
756
+ }
757
+ resolve();
758
+ });
575
759
  });
576
- if (!response.ok)
577
- this.logFailureOnce(`HTTP ${response.status}`);
578
760
  }
579
761
  catch (error) {
580
762
  this.logFailureOnce(error?.message ?? String(error));
581
763
  }
582
- finally {
583
- clearTimeout(timer);
584
- }
585
764
  }
586
765
  /**
587
766
  * Drain: flush, then await anything already in flight, all under one hard
@@ -620,29 +799,50 @@ export class OtelExporter {
620
799
  }
621
800
  }
622
801
  // ── internals ───────────────────────────────────────────────────────────
802
+ makeSpan(opts) {
803
+ const spanContext = { traceId: this.traceId, spanId: opts.spanId, traceFlags: TraceFlags.SAMPLED };
804
+ const parentSpanContext = opts.parentSpanId
805
+ ? { traceId: this.traceId, spanId: opts.parentSpanId, traceFlags: TraceFlags.SAMPLED, isRemote: opts.parentIsRemote ?? false }
806
+ : undefined;
807
+ return {
808
+ name: opts.name,
809
+ kind: SpanKind.INTERNAL,
810
+ spanContext: () => spanContext,
811
+ parentSpanContext,
812
+ startTime: opts.start,
813
+ endTime: opts.end,
814
+ status: { code: opts.statusCode },
815
+ attributes: opts.attributes,
816
+ links: [],
817
+ events: opts.events,
818
+ duration: hrDuration(opts.start, opts.end),
819
+ ended: true,
820
+ resource: this.resource,
821
+ instrumentationScope: this.scope,
822
+ droppedAttributesCount: 0,
823
+ droppedEventsCount: 0,
824
+ droppedLinksCount: 0,
825
+ };
826
+ }
623
827
  emitRootSpan(status, code) {
624
828
  if (this.rootEmitted)
625
829
  return;
626
830
  this.rootEmitted = true;
627
- this.enqueue({
628
- traceId: this.traceId,
831
+ const now = hrTimeFromIso(null);
832
+ this.enqueue(this.makeSpan({
629
833
  spanId: this.rootSpanId,
630
834
  parentSpanId: this.rootParentSpanId,
835
+ parentIsRemote: true, // the only possible parent here is an INBOUND traceparent — always a remote context
631
836
  name: `spf run ${this.chainName}`,
632
- kind: SPAN_KIND_INTERNAL,
633
- startTimeUnixNano: nanosFromIso(null, this.runStartedAtMs),
634
- endTimeUnixNano: nanosFromIso(null),
635
- attributes: [
636
- str("spf.adw_id", this.adwId),
637
- str("spf.chain", this.chainName),
638
- str("spf.run.status", status),
639
- ],
640
- status: { code },
837
+ start: hrTimeFromIso(null, this.runStartedAtMs),
838
+ end: now,
839
+ attributes: { "spf.adw_id": this.adwId, "spf.chain": this.chainName, "spf.run.status": status },
840
+ statusCode: code,
641
841
  events: this.takeBufferedEvents(""),
642
- });
842
+ }));
643
843
  }
644
844
  agentKey(phaseId, agentName) {
645
- return `${phaseId}${agentName}`;
845
+ return `${phaseId} ${agentName}`;
646
846
  }
647
847
  openAgentCall(phaseId, agentName, tsIso) {
648
848
  const key = this.agentKey(phaseId, agentName);
@@ -650,7 +850,7 @@ export class OtelExporter {
650
850
  this.agentCalls.set(key, n);
651
851
  this.openAgents.set(key, {
652
852
  spanId: spanIdFor(`agent:${phaseId}:${agentName}:${n}`),
653
- startNano: nanosFromIso(tsIso),
853
+ startTime: hrTimeFromIso(tsIso),
654
854
  });
655
855
  }
656
856
  closeAgentCall(record, tsIso) {
@@ -658,15 +858,18 @@ export class OtelExporter {
658
858
  const open = this.openAgents.get(key);
659
859
  this.openAgents.delete(key);
660
860
  const meta = this.agentMeta.get(record.name);
661
- const attributes = [
662
- str("spf.adw_id", this.adwId),
663
- str("spf.agent.name", clip(record.name)),
664
- ];
861
+ const attributes = {
862
+ "spf.adw_id": this.adwId,
863
+ "spf.agent.name": clip(record.name),
864
+ };
665
865
  if (meta) {
666
- attributes.push(str("spf.agent.model", clip(meta.model)), str("spf.agent.coding_agent", clip(meta.codingAgent)));
866
+ attributes["spf.agent.model"] = clip(meta.model);
867
+ attributes["spf.agent.coding_agent"] = clip(meta.codingAgent);
667
868
  // gen_ai.* is the OTel semantic convention a GenAI-aware backend groups
668
869
  // by; the spf.* twins stay because they are what SPF's own queries use.
669
- attributes.push(str("gen_ai.request.model", clip(meta.model)));
870
+ attributes["gen_ai.request.model"] = clip(meta.model);
871
+ if (meta.loraAdapter)
872
+ attributes["spf.lora_adapter"] = meta.loraAdapter;
670
873
  }
671
874
  // NUMBERS ONLY out of payload — see the ATTRIBUTE ALLOWLIST note on
672
875
  // `numOrNull`. A string under any of these keys is dropped, not exported.
@@ -675,37 +878,61 @@ export class OtelExporter {
675
878
  for (const [field, key2] of TOKEN_FIELDS) {
676
879
  const value = numOrNull(usageObj[field]);
677
880
  if (value !== null)
678
- attributes.push(int(key2, value));
881
+ attributes[key2] = Math.trunc(value);
679
882
  }
680
883
  for (const [field, key2] of COST_FIELDS) {
681
884
  const value = numOrNull(usageObj[field]);
682
885
  if (value !== null)
683
- attributes.push(dbl(key2, value));
886
+ attributes[key2] = value;
684
887
  }
685
888
  const totalTokens = numOrNull(record.tokens);
686
889
  if (totalTokens !== null)
687
- attributes.push(int("spf.tokens.total", totalTokens));
890
+ attributes["spf.tokens.total"] = Math.trunc(totalTokens);
688
891
  const inputTokens = numOrNull(usageObj["input_tokens"]);
689
892
  if (inputTokens !== null)
690
- attributes.push(int("gen_ai.usage.input_tokens", inputTokens));
893
+ attributes["gen_ai.usage.input_tokens"] = Math.trunc(inputTokens);
691
894
  const outputTokens = numOrNull(usageObj["output_tokens"]);
692
895
  if (outputTokens !== null)
693
- attributes.push(int("gen_ai.usage.output_tokens", outputTokens));
896
+ attributes["gen_ai.usage.output_tokens"] = Math.trunc(outputTokens);
897
+ // Semantic-convention twin of spf.tokens.cache_read — the vLLM/OpenAI-
898
+ // compatible `usage.prompt_tokens_details.cached_tokens` shape, already
899
+ // normalized into `cache_read_tokens` upstream (pi-ai's openai-completions
900
+ // adapter -> UsageBreakdown.add_turn -> this event's payload.usage) by
901
+ // the time it reaches this module; nothing new to read here beyond one
902
+ // more attribute name for the same already-present number.
903
+ const cacheReadTokens = numOrNull(usageObj["cache_read_tokens"]);
904
+ if (cacheReadTokens !== null)
905
+ attributes["gen_ai.usage.cache_read.input_tokens"] = Math.trunc(cacheReadTokens);
694
906
  const cost = numOrNull(record.payload?.["cost"]);
695
907
  if (cost !== null)
696
- attributes.push(dbl("spf.cost.total", cost));
697
- this.enqueue({
698
- traceId: this.traceId,
908
+ attributes["spf.cost.total"] = cost;
909
+ const start = open?.startTime ?? hrTimeFromIso(tsIso);
910
+ const end = hrTimeFromIso(tsIso);
911
+ this.enqueue(this.makeSpan({
699
912
  spanId: open?.spanId ?? spanIdFor(`agent:${record.phase_id}:${record.name}:orphan`),
700
913
  parentSpanId: record.phase_id ? spanIdFor(record.phase_id) : this.rootSpanId,
701
914
  name: `agent ${record.name}`,
702
- kind: SPAN_KIND_INTERNAL,
703
- startTimeUnixNano: open?.startNano ?? nanosFromIso(tsIso),
704
- endTimeUnixNano: nanosFromIso(tsIso),
915
+ start,
916
+ end,
705
917
  attributes,
706
- status: { code: STATUS_UNSET }, // the phase span carries the verdict
918
+ statusCode: STATUS_UNSET, // the phase span carries the verdict
707
919
  events: [],
708
- });
920
+ }));
921
+ if (meta) {
922
+ this.metrics?.recordAgentCall({ agent: record.name, model: meta.model, codingAgent: meta.codingAgent });
923
+ for (const [field, kind] of [
924
+ ["input_tokens", "input"],
925
+ ["output_tokens", "output"],
926
+ ["cache_read_tokens", "cache_read"],
927
+ ["cache_write_tokens", "cache_write"],
928
+ ]) {
929
+ const value = numOrNull(usageObj[field]);
930
+ if (value !== null && value !== 0)
931
+ this.metrics?.recordTokens(kind, value, { agent: record.name, model: meta.model });
932
+ }
933
+ if (cost !== null && cost !== 0)
934
+ this.metrics?.recordCost(cost, { agent: record.name, model: meta.model });
935
+ }
709
936
  }
710
937
  /**
711
938
  * Tool spans carry REAL elapsed time (the tracker records started_at/ended_at
@@ -719,18 +946,16 @@ export class OtelExporter {
719
946
  const agentName = record.payload?.["agent"];
720
947
  const open = typeof agentName === "string" ? this.openAgents.get(this.agentKey(record.phase_id, agentName)) : undefined;
721
948
  const parent = open?.spanId ?? (record.phase_id ? spanIdFor(record.phase_id) : this.rootSpanId);
722
- this.enqueue({
723
- traceId: this.traceId,
949
+ this.enqueue(this.makeSpan({
724
950
  spanId: spanIdFor(`tool:${record.phase_id}:${eventId}`),
725
951
  parentSpanId: parent,
726
952
  name: toolSpanName(record.name),
727
- kind: SPAN_KIND_INTERNAL,
728
- startTimeUnixNano: nanosFromIso(record.started_at ?? tsIso),
729
- endTimeUnixNano: nanosFromIso(record.ended_at ?? tsIso),
730
- attributes: [str("spf.adw_id", this.adwId), str("spf.event.type", record.type)],
731
- status: { code: STATUS_UNSET },
953
+ start: hrTimeFromIso(record.started_at ?? tsIso),
954
+ end: hrTimeFromIso(record.ended_at ?? tsIso),
955
+ attributes: { "spf.adw_id": this.adwId, "spf.event.type": record.type },
956
+ statusCode: STATUS_UNSET,
732
957
  events: [],
733
- });
958
+ }));
734
959
  }
735
960
  bufferSpanEvent(phaseId, event) {
736
961
  const key = phaseId && !this.emittedPhases.has(phaseId) ? phaseId : "";
@@ -747,27 +972,6 @@ export class OtelExporter {
747
972
  this.bufferedEvents.delete(phaseId);
748
973
  return events;
749
974
  }
750
- payloadFor(spans, isFinal) {
751
- const attributes = [
752
- str("service.name", this.serviceName),
753
- str("spf.adw_id", this.adwId),
754
- str("spf.chain", this.chainName),
755
- ];
756
- // The drop counter rides the FINAL flush's resource, so the gap is visible
757
- // in the backend and not only in a log line nobody kept.
758
- if (isFinal && this.dropped > 0)
759
- attributes.push(int("spf.otel.dropped_spans", this.dropped));
760
- if (isFinal && this.droppedEvents > 0)
761
- attributes.push(int("spf.otel.dropped_span_events", this.droppedEvents));
762
- return {
763
- resourceSpans: [
764
- {
765
- resource: { attributes },
766
- scopeSpans: [{ scope: { name: "spf", version: "1" }, spans }],
767
- },
768
- ],
769
- };
770
- }
771
975
  logFailureOnce(reason) {
772
976
  if (this.loggedFailure)
773
977
  return;
@@ -791,6 +995,12 @@ const LIVE = new Map();
791
995
  * the default for every repo that has not configured an endpoint, and no
792
996
  * environment variable can change that (see EXPLICIT CONFIG ONLY).
793
997
  *
998
+ * Also resolves (once per process — see `otel_metrics.ts`'s own singleton
999
+ * guard) the shared, PROCESS-scoped `OtelMetrics` handle and holds a
1000
+ * reference on the exporter, so `recordPhase`/`recordGate`/`closeAgentCall`
1001
+ * can fan out to it without any OTHER call site (`tracer.ts`, `agents.ts`)
1002
+ * needing to know metrics exist at all.
1003
+ *
794
1004
  * Registered under `opts.adwId` — the RESOLVED id (`session.ensure`'s own
795
1005
  * `id`, never a caller's possibly-null `ctx.adw_id`) — which is exactly the
796
1006
  * key `releaseOtelExporter` below looks it up by. A second registration
@@ -800,15 +1010,20 @@ const LIVE = new Map();
800
1010
  * timer, just unreachable from `flushAll()` from that point on.
801
1011
  */
802
1012
  export function resolveOtelExporter(cfg, opts) {
803
- const otel = cfg.observability.otel;
804
- if (!otel || !otel.endpoint)
1013
+ const rawOtel = cfg.observability.otel;
1014
+ if (!rawOtel || !rawOtel.endpoint)
805
1015
  return null;
1016
+ // `allow_env`'s narrow supplement — see `data_types.ts`'s
1017
+ // `applyOtelEnvSupplement`: a no-op unless the block above is ALREADY
1018
+ // active (it is, we just checked `endpoint`) AND `allow_env: true`.
1019
+ const otel = applyOtelEnvSupplement(rawOtel, opts.env ?? process.env);
806
1020
  const exporter = new OtelExporter({
807
1021
  cfg: otel,
808
1022
  adwId: opts.adwId,
809
1023
  chainName: opts.chainName,
810
1024
  log: opts.log,
811
1025
  env: opts.env,
1026
+ metrics: resolveOtelMetrics(cfg),
812
1027
  });
813
1028
  LIVE.set(opts.adwId, exporter);
814
1029
  return exporter;