@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.
@@ -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,28 @@
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
- import type { AgentConfig, EventRecord, GateReport, OTelConfig, Phase, SFConfig } from "./data_types.ts";
238
+ import { type AgentConfig, type EventRecord, type GateReport, type OTelConfig, type Phase, type SFConfig } from "./data_types.ts";
239
+ import { type OtelMetrics } from "./otel_metrics.ts";
152
240
  /** trace-id = first 32 hex of sha256(adw_id). Bespoke convention — see the header. */
153
241
  export declare function traceIdFor(adwId: string): string;
154
242
  /** span-id = first 16 hex of sha256(key), where key is a phase_id or a synthetic child key. */
@@ -186,12 +274,7 @@ export declare function resolveTracesUrl(endpoint: string): string;
186
274
  * both are places a credential is routinely smuggled into a URL.
187
275
  */
188
276
  export declare function endpointLabel(endpoint: string): string;
189
- /**
190
- * ISO-8601 -> uint64 nanoseconds AS A STRING (see WIRE FORMAT). Unparseable,
191
- * missing, or pre-epoch input falls back to `fallbackMs`, because a span with
192
- * a nonsense timestamp is rejected wholesale by most backends while a span
193
- * with an approximate one is still useful.
194
- */
277
+ /** 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. */
195
278
  export declare function nanosFromIso(iso: string | null | undefined, fallbackMs?: number): string;
196
279
  /**
197
280
  * The safe half of a `tool_call` event's name. `record.name` for a tool call is
@@ -208,6 +291,28 @@ export declare function toolSpanName(eventName: string | undefined | null): stri
208
291
  * `observability.otel` ever reaches a log.
209
292
  */
210
293
  export declare function redact(message: string, secrets: Array<string | undefined>): string;
294
+ /**
295
+ * `spf.lora_adapter` — the served LoRA adapter name for this agent's model,
296
+ * when one can be determined. Checked in order, first match wins:
297
+ * 1. `agent.lora_adapter` (explicit config) — set once per agent, the
298
+ * unambiguous source of truth an operator can always fall back to.
299
+ * 2. `provider/base:adapter` — an explicit adapter suffix after the LAST
300
+ * ":" in the model id (e.g. `vllm/nemotron-base:my-lora` -> `my-lora`).
301
+ * 3. `provider/adapter-name` — no ":" in the model id, but the id itself
302
+ * contains "-lora-" (case-insensitive) — this org's own vLLM/Switchyard
303
+ * served-model naming convention (`k8s/manifests/switchyard/
304
+ * configmap-routes.yaml`: "id MUST equal the name= half of the matching
305
+ * --lora-modules entry", e.g. `nemotron-lora-placeholder`) — the WHOLE
306
+ * model id (minus the `provider/` prefix) IS the adapter name in this
307
+ * convention, since vLLM resolves LoRA adapters by served-model name,
308
+ * not by a base-model-plus-suffix split.
309
+ * No match on any of the three -> `null`, and the attribute is omitted
310
+ * entirely — never a blind copy of a non-LoRA model id.
311
+ */
312
+ export declare function loraAdapterFor(agent: {
313
+ model: string;
314
+ lora_adapter?: string | null;
315
+ }): string | null;
211
316
  export interface OtelExporterInit {
212
317
  /** The validated `observability.otel` block. Its presence IS the activation switch. */
213
318
  cfg: OTelConfig;
@@ -218,6 +323,8 @@ export interface OtelExporterInit {
218
323
  log?: (message: string) => void;
219
324
  /** Injectable for tests; defaults to `process.env`. Only ever read for `traceparent`. */
220
325
  env?: NodeJS.ProcessEnv;
326
+ /** The process-scoped metrics handle (see `otel_metrics.ts`), or `null` when metrics are off/unconfigured. Injectable for tests. */
327
+ metrics?: OtelMetrics | null;
221
328
  }
222
329
  export declare class OtelExporter {
223
330
  private readonly cfg;
@@ -226,10 +333,14 @@ export declare class OtelExporter {
226
333
  private readonly serviceName;
227
334
  private readonly url;
228
335
  private readonly log;
336
+ private readonly metrics;
229
337
  private readonly traceId;
230
338
  /** "" unless an inbound traceparent parented this run — see INBOUND TRACEPARENT. */
231
339
  private readonly rootParentSpanId;
232
340
  private readonly rootSpanId;
341
+ private readonly resource;
342
+ private readonly scope;
343
+ private readonly spanExporter;
233
344
  private queue;
234
345
  private dropped;
235
346
  private droppedEvents;
@@ -248,7 +359,7 @@ export declare class OtelExporter {
248
359
  * nothing will ever drain.
249
360
  */
250
361
  private emittedPhases;
251
- /** `<phase_id><agent>` -> the open agent call, for closing it and parenting tool spans. */
362
+ /** `<phase_id> <agent>` -> the open agent call, for closing it and parenting tool spans. */
252
363
  private openAgents;
253
364
  /** How many times an agent has been called in a phase, so a retry gets its own span id. */
254
365
  private agentCalls;
@@ -308,6 +419,17 @@ export declare class OtelExporter {
308
419
  * the guard is why that is harmless.
309
420
  */
310
421
  recordSessionFinish(ok: boolean): void;
422
+ /**
423
+ * The currently-open agent call's trace context — `null` when otel has no
424
+ * agent call open for this phase+agent pair right now (agent_start hasn't
425
+ * fired, or already closed). `agents.ts`'s `send()` reads this into
426
+ * `AgentRequest.otel`; a `null` here just means that field stays unset, a
427
+ * plain no-op for every backend that doesn't propagate it.
428
+ */
429
+ agentCallTraceContext(phaseId: string, agentName: string): {
430
+ traceparent: string;
431
+ spanId: string;
432
+ } | null;
311
433
  /** Queued spans and spans/events dropped so far. For tests and diagnostics. */
312
434
  stats(): {
313
435
  queued: number;
@@ -316,9 +438,11 @@ export declare class OtelExporter {
316
438
  };
317
439
  /**
318
440
  * The exact JSON body the next flush would POST, without sending or
319
- * draining. This is the seam `src/test/otel.test.ts` uses to prove the
320
- * allowlist holds the assertion is on the literal bytes, so any future
321
- * attribute that leaks a payload fails a test rather than a review.
441
+ * draining via the SAME `JsonTraceSerializer` the real exporter uses
442
+ * internally, so this is not a second, possibly-diverging encoding path.
443
+ * This is the seam `src/test/otel.test.ts` uses to prove the allowlist
444
+ * holds — the assertion is on the literal bytes, so any future attribute
445
+ * that leaks a payload fails a test rather than a review.
322
446
  */
323
447
  pendingJson(): string;
324
448
  private enqueue;
@@ -327,7 +451,11 @@ export declare class OtelExporter {
327
451
  /**
328
452
  * Send whatever is queued. Never throws, never rejects: a failed export is a
329
453
  * single redacted log line and a swallowed error, because the alternative is
330
- * an observability feature that can fail a run.
454
+ * an observability feature that can fail a run. `OTLPTraceExporter.export()`
455
+ * itself already never throws and always calls its callback exactly once
456
+ * (verified against `@opentelemetry/otlp-exporter-base`'s
457
+ * `OTLPExportDelegate.export()` source) — the try/catch here is belt-and-
458
+ * braces for that contract, not a load-bearing guard.
331
459
  */
332
460
  flush(isFinal?: boolean): Promise<void>;
333
461
  /**
@@ -336,6 +464,7 @@ export declare class OtelExporter {
336
464
  * `session.ts`'s signal handler (with a tighter budget there).
337
465
  */
338
466
  drain(budgetMs?: number): Promise<void>;
467
+ private makeSpan;
339
468
  private emitRootSpan;
340
469
  private agentKey;
341
470
  private openAgentCall;
@@ -351,7 +480,6 @@ export declare class OtelExporter {
351
480
  private emitToolSpan;
352
481
  private bufferSpanEvent;
353
482
  private takeBufferedEvents;
354
- private payloadFor;
355
483
  private logFailureOnce;
356
484
  }
357
485
  /**
@@ -361,6 +489,12 @@ export declare class OtelExporter {
361
489
  * the default for every repo that has not configured an endpoint, and no
362
490
  * environment variable can change that (see EXPLICIT CONFIG ONLY).
363
491
  *
492
+ * Also resolves (once per process — see `otel_metrics.ts`'s own singleton
493
+ * guard) the shared, PROCESS-scoped `OtelMetrics` handle and holds a
494
+ * reference on the exporter, so `recordPhase`/`recordGate`/`closeAgentCall`
495
+ * can fan out to it without any OTHER call site (`tracer.ts`, `agents.ts`)
496
+ * needing to know metrics exist at all.
497
+ *
364
498
  * Registered under `opts.adwId` — the RESOLVED id (`session.ensure`'s own
365
499
  * `id`, never a caller's possibly-null `ctx.adw_id`) — which is exactly the
366
500
  * key `releaseOtelExporter` below looks it up by. A second registration