@gr8ful/spf 0.17.0 → 0.18.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/assets/skill/references/config.md +6 -3
- package/assets/skill/references/observability.md +115 -1
- package/dist/cli/index.js +6 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +39 -1
- package/dist/core/agent_flue.js +26 -0
- package/dist/core/agent_opencode.d.ts +105 -3
- package/dist/core/agent_opencode.js +169 -17
- package/dist/core/agents.d.ts +6 -0
- package/dist/core/agents.js +22 -0
- package/dist/core/data_types.d.ts +60 -0
- package/dist/core/data_types.js +69 -0
- package/dist/core/otel.d.ts +182 -48
- package/dist/core/otel.js +373 -158
- package/dist/core/otel_metrics.d.ts +127 -0
- package/dist/core/otel_metrics.js +221 -0
- package/dist/core/otel_propagation.d.ts +159 -0
- package/dist/core/otel_propagation.js +225 -0
- package/package.json +14 -1
package/dist/core/otel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenTelemetry span export (
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
24
|
-
* from a CI image or a coworker's dotfiles must not
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 +
|
|
34
|
-
* violation COUNT, token counts (UsageBreakdown fields) + costs,
|
|
35
|
-
* (implied by span start/end), and event TYPE. Everything else is
|
|
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
|
-
*
|
|
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`
|
|
43
|
-
* `recordAgentSession` (config data), NOT from the
|
|
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:
|
|
83
|
-
* span at phase start would require mutating an already-sent
|
|
84
|
-
* has no notion of.
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
143
|
-
*
|
|
144
|
-
* `
|
|
145
|
-
*
|
|
146
|
-
* `
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
this.
|
|
453
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
+
time: hrTimeFromIso(null),
|
|
475
632
|
name: report.passed ? "gate_pass" : "gate_fail",
|
|
476
|
-
attributes:
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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, {
|
|
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
|
|
511
|
-
*
|
|
512
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
861
|
+
const attributes = {
|
|
862
|
+
"spf.adw_id": this.adwId,
|
|
863
|
+
"spf.agent.name": clip(record.name),
|
|
864
|
+
};
|
|
665
865
|
if (meta) {
|
|
666
|
-
attributes
|
|
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
|
|
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.
|
|
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
|
|
886
|
+
attributes[key2] = value;
|
|
684
887
|
}
|
|
685
888
|
const totalTokens = numOrNull(record.tokens);
|
|
686
889
|
if (totalTokens !== null)
|
|
687
|
-
attributes
|
|
890
|
+
attributes["spf.tokens.total"] = Math.trunc(totalTokens);
|
|
688
891
|
const inputTokens = numOrNull(usageObj["input_tokens"]);
|
|
689
892
|
if (inputTokens !== null)
|
|
690
|
-
attributes
|
|
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
|
|
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
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
endTimeUnixNano: nanosFromIso(tsIso),
|
|
915
|
+
start,
|
|
916
|
+
end,
|
|
705
917
|
attributes,
|
|
706
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
804
|
-
if (!
|
|
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;
|