@gr8ful/spf 0.4.0 → 0.5.1
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/README.md +122 -4
- package/assets/defaults/spf.config.yaml +6 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +69 -4
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +5 -0
- package/dist/chains/context.d.ts +30 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +70 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +215 -20
- package/dist/chains/steps.js +429 -61
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +319 -11
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +78 -1
- package/dist/cli/commands/list.js +42 -5
- package/dist/cli/commands/run.js +25 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +158 -10
- package/dist/cli/index.js +60 -3
- package/dist/cli/interview.js +65 -10
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +104 -4
- package/dist/core/data_types.js +99 -2
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.js +11 -3
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +11 -0
- package/dist/core/watch.js +17 -2
- package/dist/test/chains.test.js +8 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +54 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +127 -0
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +68 -0
- package/dist/test/refine.test.js +64 -1
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +124 -1
- package/package.json +5 -5
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry span export (v1): a config-gated, lossy, fire-and-forget
|
|
3
|
+
* PROJECTION of the trace SQLite already holds. Read this header before
|
|
4
|
+
* changing anything here — every paragraph is a constraint that survived an
|
|
5
|
+
* adversarial review, not a preference.
|
|
6
|
+
*
|
|
7
|
+
* WHAT THIS IS NOT. It is not a second source of truth and it is not in any
|
|
8
|
+
* control path. `tracer.ts` (files + SQLite, synchronous) remains THE record;
|
|
9
|
+
* this module is a tail-call fan-out off its write methods. Export can never
|
|
10
|
+
* affect a phase, a gate, or a run outcome — "agent proposes, code disposes"
|
|
11
|
+
* is untouched because export cannot dispose of anything. Nothing in here may
|
|
12
|
+
* ever throw into a caller, block a caller, or be awaited by a caller other
|
|
13
|
+
* than the two shutdown paths named under LIFECYCLE below.
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* EXPLICIT CONFIG ONLY. Activation requires `observability.otel.endpoint` in
|
|
22
|
+
* 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.)
|
|
28
|
+
*
|
|
29
|
+
* ATTRIBUTE ALLOWLIST — exfiltration is the top risk here, because
|
|
30
|
+
* `EventRecord.payload` carries the repository's own source code (tool args,
|
|
31
|
+
* result snippets, diffs, prompts, envelope contents, the operator's request
|
|
32
|
+
* 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:
|
|
37
|
+
* - This module reads `EventRecord.payload` for FINITE NUMBERS ONLY (see
|
|
38
|
+
* `numOrNull`) and only under known UsageBreakdown/cost keys. A string can
|
|
39
|
+
* never reach an attribute through the payload path. Do not add a
|
|
40
|
+
* `stringValue` read from `payload` — that single line is the whole
|
|
41
|
+
* exfiltration bug.
|
|
42
|
+
* - Agent model/coding_agent come from the typed `AgentConfig` handed to
|
|
43
|
+
* `recordAgentSession` (config data), NOT from the `agent_start` payload.
|
|
44
|
+
* - Tool spans are named from `record.name`'s prefix up to the first ":"
|
|
45
|
+
* (see `toolSpanName`). The full `record.name` is a HUMAN LABEL built from
|
|
46
|
+
* real tool arguments (`agent_flue.ts`'s `labelFor` -> "bash: cat
|
|
47
|
+
* src/secret.ts") and must never be exported verbatim.
|
|
48
|
+
* - `Phase.error` is NOT exported. It is an agent- and repo-derived string.
|
|
49
|
+
* A failed phase span carries status ERROR with no message.
|
|
50
|
+
* - `tracer.sessionRequest`, `tracer.envelopeRow`, `tracer.processStart/End`
|
|
51
|
+
* have deliberately NO fan-out: the operator's request text, envelope
|
|
52
|
+
* contents, and pids are all outside the allowlist. Do not add one.
|
|
53
|
+
*
|
|
54
|
+
* SPAN MODEL. One run (adw_id) = one trace. Root span = the run. Each phase =
|
|
55
|
+
* a child span of the root, using `runner.ts`'s real `started_at`/`ended_at`.
|
|
56
|
+
* Each agent call (`agent_start`..`agent_end`, with its UsageBreakdown) = a
|
|
57
|
+
* CHILD span of its phase: a phase-only tree cannot answer "which agent call
|
|
58
|
+
* burned the tokens", which is the question this feature exists for.
|
|
59
|
+
* `tool_call` events (they carry real timing) = child spans of the open agent
|
|
60
|
+
* call where attributable, else of the phase. `handoff`/`error` become a span
|
|
61
|
+
* EVENT on the phase span, with allowlisted attributes only. `gate_pass`/
|
|
62
|
+
* `gate_fail` do NOT (see `recordGate` below, which carries the structured
|
|
63
|
+
* verdict instead). `log` is dropped outright — a console line is redundant
|
|
64
|
+
* with the phase span itself and would only crowd out `handoff`/`error` in
|
|
65
|
+
* the per-phase event cap (see BACKPRESSURE and `MAX_EVENTS_PER_SPAN`). Any
|
|
66
|
+
* event type not named above is dropped, fail-closed, by `recordEvent`.
|
|
67
|
+
*
|
|
68
|
+
* IDS ARE A BESPOKE CONVENTION, documented so nobody mistakes it for the OTel
|
|
69
|
+
* SDK's random-id behavior: trace-id = first 32 hex of sha256(adw_id),
|
|
70
|
+
* span-id = first 16 hex of sha256(a stable key — `phase_id` for a phase,
|
|
71
|
+
* `agent:<phase_id>:<agent>:<n>` for an agent call, `tool:<phase_id>:<event_id>`
|
|
72
|
+
* for a tool call). Determinism means a re-export of the same run lands on the
|
|
73
|
+
* 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.
|
|
75
|
+
* `EventRecord.parent_id` is structurally ALWAYS EMPTY today (SPF's phases are
|
|
76
|
+
* flat siblings; nothing writes nesting), so there is no recorded hierarchy to
|
|
77
|
+
* mine — the parenting above is reconstructed from phase_id + agent-call
|
|
78
|
+
* bracketing, and that is the only reason it needs reconstructing at all.
|
|
79
|
+
*
|
|
80
|
+
* PHASE SPANS ARE EMITTED AT PHASE END ONLY. A hung or killed phase is
|
|
81
|
+
* 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.
|
|
85
|
+
*
|
|
86
|
+
* INBOUND TRACEPARENT. When a valid W3C `traceparent` is present in the
|
|
87
|
+
* environment, its trace-id becomes this run's trace-id and the run's root
|
|
88
|
+
* span is parented under its span-id, so an SPF run joins the CI trace that
|
|
89
|
+
* launched it instead of hanging as an orphan root. Reading `traceparent` is
|
|
90
|
+
* NOT ambient activation: with no `observability.otel` config, nothing is
|
|
91
|
+
* constructed and nothing is sent, traceparent or not. Garbage is rejected
|
|
92
|
+
* silently (see `parseTraceparent`) — a malformed variable must degrade to
|
|
93
|
+
* "own root", never to an error.
|
|
94
|
+
*
|
|
95
|
+
* LIFECYCLE (copied from `notify/notifier.ts`'s discipline). A module-level
|
|
96
|
+
* LIVE registry holds every exporter this process created; `flushAll()` is
|
|
97
|
+
* awaited in `src/cli/index.ts`'s existing `finally` block next to
|
|
98
|
+
* `notify.flushAll()`, AND `session.ts`'s signal handler runs a bounded,
|
|
99
|
+
* timeout-capped drain before its `process.exit(128+n)` (notify does NOT do
|
|
100
|
+
* that second one today — its in-flight webhooks are dropped on SIGTERM; only
|
|
101
|
+
* the otel path is fixed here, on purpose, to keep this change to one seam).
|
|
102
|
+
* Send failures log ONE line for the life of the exporter, with the endpoint
|
|
103
|
+
* and every header VALUE redacted, and are then swallowed.
|
|
104
|
+
*
|
|
105
|
+
* BACKPRESSURE. `tracer.event()` fires per tool call on a hot path, so raw
|
|
106
|
+
* promise-per-span fire-and-forget is a memory bug, not a style choice.
|
|
107
|
+
* Spans go into a BOUNDED queue (`MAX_QUEUED_SPANS`, drop-OLDEST) and leave in
|
|
108
|
+
* batches (`BATCH_SPANS`, or `FLUSH_INTERVAL_MS`, whichever comes first) via an
|
|
109
|
+
* UNREF'D timer that can never hold the process open. Dropped spans are
|
|
110
|
+
* counted, reported once as a warn line, and exported as a resource attribute
|
|
111
|
+
* on the final flush so the gap is visible in the backend too. The size
|
|
112
|
+
* trigger schedules a timer rather than flushing inline, which also means a
|
|
113
|
+
* synchronous burst of thousands of events exercises the bound (see the queue
|
|
114
|
+
* test) instead of interleaving sends.
|
|
115
|
+
*
|
|
116
|
+
* WIRE FORMAT is hand-rolled OTLP/HTTP with a JSON body — no new npm
|
|
117
|
+
* dependency for an optional, lossy projection. The shape that matters:
|
|
118
|
+
* `{resourceSpans:[{resource:{attributes:[KeyValue]},scopeSpans:[{scope,spans:[Span]}]}]}`,
|
|
119
|
+
* every attribute value wrapped in an AnyValue (`{stringValue}`/`{intValue}`/
|
|
120
|
+
* `{doubleValue}`/`{boolValue}`), trace/span ids as lowercase hex strings, and
|
|
121
|
+
* every uint64 nanosecond timestamp AS A STRING (a JSON number would lose
|
|
122
|
+
* precision past 2^53 and backends reject it). `src/test/otel.test.ts` pins
|
|
123
|
+
* this shape against an in-process receiver.
|
|
124
|
+
*/
|
|
125
|
+
import { createHash } from "node:crypto";
|
|
126
|
+
// ── tunables (see BACKPRESSURE above) ───────────────────────────────────────
|
|
127
|
+
const MAX_QUEUED_SPANS = 2048;
|
|
128
|
+
const BATCH_SPANS = 64;
|
|
129
|
+
const FLUSH_INTERVAL_MS = 2_000;
|
|
130
|
+
/** Per-request cap, and the default drain budget for `flushAll()`. */
|
|
131
|
+
const SEND_TIMEOUT_MS = 2_000;
|
|
132
|
+
/** Span events buffered per phase while it runs; a runaway phase cannot grow unbounded. */
|
|
133
|
+
const MAX_EVENTS_PER_SPAN = 64;
|
|
134
|
+
const SPAN_KIND_INTERNAL = 1;
|
|
135
|
+
const STATUS_UNSET = 0;
|
|
136
|
+
const STATUS_OK = 1;
|
|
137
|
+
const STATUS_ERROR = 2;
|
|
138
|
+
// ── pure helpers (exported so `src/test/otel.test.ts` can pin them) ─────────
|
|
139
|
+
const HEX32 = /^[0-9a-f]{32}$/;
|
|
140
|
+
const HEX16 = /^[0-9a-f]{16}$/;
|
|
141
|
+
function sha256Hex(input) {
|
|
142
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* An all-zero id is invalid in W3C/OTLP ("no trace"/"no span"), so a hash that
|
|
146
|
+
* somehow lands there is nudged off it. Practically unreachable; cheaper than
|
|
147
|
+
* reasoning about whether it is.
|
|
148
|
+
*/
|
|
149
|
+
function nonZero(hex) {
|
|
150
|
+
return /^0+$/.test(hex) ? hex.slice(0, -1) + "1" : hex;
|
|
151
|
+
}
|
|
152
|
+
/** trace-id = first 32 hex of sha256(adw_id). Bespoke convention — see the header. */
|
|
153
|
+
export function traceIdFor(adwId) {
|
|
154
|
+
return nonZero(sha256Hex(adwId).slice(0, 32));
|
|
155
|
+
}
|
|
156
|
+
/** span-id = first 16 hex of sha256(key), where key is a phase_id or a synthetic child key. */
|
|
157
|
+
export function spanIdFor(key) {
|
|
158
|
+
return nonZero(sha256Hex(key).slice(0, 16));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Strict W3C `traceparent` parse: `00-<32 hex>-<16 hex>-<2 hex>`, lowercase,
|
|
162
|
+
* exact lengths, neither id all-zero. Anything else — a wrong version, an
|
|
163
|
+
* uppercase digest, a truncated id, an empty string, unset — returns null and
|
|
164
|
+
* the run keeps its own root. Never throws, never logs: a malformed CI
|
|
165
|
+
* variable is not this module's problem to report.
|
|
166
|
+
*/
|
|
167
|
+
export function parseTraceparent(value) {
|
|
168
|
+
if (!value)
|
|
169
|
+
return null;
|
|
170
|
+
const parts = value.trim().split("-");
|
|
171
|
+
if (parts.length !== 4)
|
|
172
|
+
return null;
|
|
173
|
+
const [version, traceId, spanId, flags] = parts;
|
|
174
|
+
// Only version 00 is defined. A future version MAY be parseable field-wise,
|
|
175
|
+
// but guessing at an unknown format is how you propagate a wrong parent.
|
|
176
|
+
if (version !== "00")
|
|
177
|
+
return null;
|
|
178
|
+
if (!HEX32.test(traceId) || !HEX16.test(spanId))
|
|
179
|
+
return null;
|
|
180
|
+
if (!/^[0-9a-f]{2}$/.test(flags))
|
|
181
|
+
return null;
|
|
182
|
+
if (/^0+$/.test(traceId) || /^0+$/.test(spanId))
|
|
183
|
+
return null;
|
|
184
|
+
return { traceId, spanId, sampled: (parseInt(flags, 16) & 0x01) === 0x01 };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The two env vars CI systems actually set. Reading them is not activation —
|
|
188
|
+
* see INBOUND TRACEPARENT in the header.
|
|
189
|
+
*/
|
|
190
|
+
export function inboundTraceparent(env = process.env) {
|
|
191
|
+
return parseTraceparent(env["TRACEPARENT"]) ?? parseTraceparent(env["OTEL_TRACEPARENT"]);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The configured endpoint is used AS GIVEN when it already names a path — the
|
|
195
|
+
* operator's URL is not ours to rewrite. A bare origin (`http://host:4318`,
|
|
196
|
+
* or a trailing "/") gets the standard OTLP/HTTP traces path appended, because
|
|
197
|
+
* that is the one guess with a single right answer. Returns the input
|
|
198
|
+
* unchanged if it does not parse as a URL; the config schema rejects those
|
|
199
|
+
* first, so this is only belt-and-braces for direct callers.
|
|
200
|
+
*/
|
|
201
|
+
export function resolveTracesUrl(endpoint) {
|
|
202
|
+
let url;
|
|
203
|
+
try {
|
|
204
|
+
url = new URL(endpoint);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return endpoint;
|
|
208
|
+
}
|
|
209
|
+
if (url.pathname === "" || url.pathname === "/")
|
|
210
|
+
url.pathname = "/v1/traces";
|
|
211
|
+
return url.toString();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A printable form of the endpoint for `spf doctor`: origin + path only.
|
|
215
|
+
* Userinfo (`https://user:token@host/...`) and the query string are dropped —
|
|
216
|
+
* both are places a credential is routinely smuggled into a URL.
|
|
217
|
+
*/
|
|
218
|
+
export function endpointLabel(endpoint) {
|
|
219
|
+
try {
|
|
220
|
+
const url = new URL(endpoint);
|
|
221
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return "(unparseable endpoint)";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* ISO-8601 -> uint64 nanoseconds AS A STRING (see WIRE FORMAT). Unparseable,
|
|
229
|
+
* missing, or pre-epoch input falls back to `fallbackMs`, because a span with
|
|
230
|
+
* a nonsense timestamp is rejected wholesale by most backends while a span
|
|
231
|
+
* with an approximate one is still useful.
|
|
232
|
+
*/
|
|
233
|
+
export function nanosFromIso(iso, fallbackMs = Date.now()) {
|
|
234
|
+
const parsed = iso ? Date.parse(iso) : NaN;
|
|
235
|
+
const ms = Number.isFinite(parsed) && parsed >= 0 ? parsed : fallbackMs;
|
|
236
|
+
// String concat, not BigInt math: ms is an integer, and appending six zeros
|
|
237
|
+
// is exact where `ms * 1e6` would drift into float territory.
|
|
238
|
+
return `${Math.floor(ms)}000000`;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The safe half of a `tool_call` event's name. `record.name` for a tool call is
|
|
242
|
+
* a human label built FROM THE TOOL'S ARGUMENTS ("bash: cat src/secret.ts",
|
|
243
|
+
* "read: /etc/hosts"); only the part before the first ":" is the tool's own
|
|
244
|
+
* identity. Also clipped and character-restricted so a hand-rolled event name
|
|
245
|
+
* can't smuggle a payload through as a span name.
|
|
246
|
+
*/
|
|
247
|
+
export function toolSpanName(eventName) {
|
|
248
|
+
const head = String(eventName ?? "").split(":")[0].trim();
|
|
249
|
+
const safe = head.replace(/[^A-Za-z0-9_.\-/]/g, "").slice(0, 40);
|
|
250
|
+
return safe || "tool_call";
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Redact every secret from a log line. The endpoint and each header VALUE are
|
|
254
|
+
* passed in; `fetch` failures routinely embed the URL they attempted, and a
|
|
255
|
+
* proxy error can echo a header. One-line rule: nothing configured under
|
|
256
|
+
* `observability.otel` ever reaches a log.
|
|
257
|
+
*/
|
|
258
|
+
export function redact(message, secrets) {
|
|
259
|
+
let out = message;
|
|
260
|
+
for (const secret of secrets) {
|
|
261
|
+
if (!secret || secret.length < 4)
|
|
262
|
+
continue;
|
|
263
|
+
out = out.split(secret).join("[redacted]");
|
|
264
|
+
}
|
|
265
|
+
// Belt and braces for a URL this exporter never saw (a redirect target, a
|
|
266
|
+
// proxy's own address) appearing in someone else's error text.
|
|
267
|
+
return out.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi, "[redacted-url]");
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The ONLY door from `EventRecord.payload` into an attribute: finite numbers,
|
|
271
|
+
* nothing else. A string — which is what every exfiltration risk in a payload
|
|
272
|
+
* actually is — returns null and is dropped. Do not relax this.
|
|
273
|
+
*/
|
|
274
|
+
function numOrNull(value) {
|
|
275
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
276
|
+
}
|
|
277
|
+
const str = (key, value) => ({ key, value: { stringValue: value } });
|
|
278
|
+
const int = (key, value) => ({ key, value: { intValue: String(Math.trunc(value)) } });
|
|
279
|
+
const dbl = (key, value) => ({ key, value: { doubleValue: value } });
|
|
280
|
+
const bool = (key, value) => ({ key, value: { boolValue: value } });
|
|
281
|
+
/** UsageBreakdown's token fields -> attribute suffixes. Numbers only, by construction. */
|
|
282
|
+
const TOKEN_FIELDS = [
|
|
283
|
+
["input_tokens", "spf.tokens.input"],
|
|
284
|
+
["output_tokens", "spf.tokens.output"],
|
|
285
|
+
["cache_read_tokens", "spf.tokens.cache_read"],
|
|
286
|
+
["cache_write_tokens", "spf.tokens.cache_write"],
|
|
287
|
+
["reasoning_tokens", "spf.tokens.reasoning"],
|
|
288
|
+
["total_tokens", "spf.tokens.total"],
|
|
289
|
+
];
|
|
290
|
+
const COST_FIELDS = [
|
|
291
|
+
["input_cost", "spf.cost.input"],
|
|
292
|
+
["output_cost", "spf.cost.output"],
|
|
293
|
+
["cache_read_cost", "spf.cost.cache_read"],
|
|
294
|
+
["cache_write_cost", "spf.cost.cache_write"],
|
|
295
|
+
["total_cost", "spf.cost.total"],
|
|
296
|
+
];
|
|
297
|
+
export class OtelExporter {
|
|
298
|
+
cfg;
|
|
299
|
+
adwId;
|
|
300
|
+
chainName;
|
|
301
|
+
serviceName;
|
|
302
|
+
url;
|
|
303
|
+
log;
|
|
304
|
+
traceId;
|
|
305
|
+
/** "" unless an inbound traceparent parented this run — see INBOUND TRACEPARENT. */
|
|
306
|
+
rootParentSpanId;
|
|
307
|
+
rootSpanId;
|
|
308
|
+
queue = [];
|
|
309
|
+
dropped = 0;
|
|
310
|
+
droppedEvents = 0;
|
|
311
|
+
warnedDrops = false;
|
|
312
|
+
loggedFailure = false;
|
|
313
|
+
timer = null;
|
|
314
|
+
timerDelay = Number.POSITIVE_INFINITY;
|
|
315
|
+
pending = new Set();
|
|
316
|
+
/** Span events buffered until their phase span exists. Key "" = the root run span. */
|
|
317
|
+
bufferedEvents = new Map();
|
|
318
|
+
/**
|
|
319
|
+
* Phases whose span has already gone out. Events DO arrive after a phase
|
|
320
|
+
* span is emitted — `run.finish()`'s `not_accepted` error names the last
|
|
321
|
+
* phase — and a span already on the wire cannot grow an event, so those are
|
|
322
|
+
* re-homed onto the root run span instead of accumulating in a buffer that
|
|
323
|
+
* nothing will ever drain.
|
|
324
|
+
*/
|
|
325
|
+
emittedPhases = new Set();
|
|
326
|
+
/** `<phase_id><agent>` -> the open agent call, for closing it and parenting tool spans. */
|
|
327
|
+
openAgents = new Map();
|
|
328
|
+
/** How many times an agent has been called in a phase, so a retry gets its own span id. */
|
|
329
|
+
agentCalls = new Map();
|
|
330
|
+
/** agent name -> config metadata, from `recordAgentSession` (typed config, never a payload). */
|
|
331
|
+
agentMeta = new Map();
|
|
332
|
+
runStartedAtMs = Date.now();
|
|
333
|
+
rootEmitted = false;
|
|
334
|
+
constructor(init) {
|
|
335
|
+
this.cfg = init.cfg;
|
|
336
|
+
this.adwId = init.adwId;
|
|
337
|
+
this.chainName = init.chainName;
|
|
338
|
+
this.serviceName = init.cfg.service_name || "spf";
|
|
339
|
+
this.url = resolveTracesUrl(init.cfg.endpoint);
|
|
340
|
+
this.log = init.log ?? ((m) => console.error(m));
|
|
341
|
+
const inbound = inboundTraceparent(init.env ?? process.env);
|
|
342
|
+
this.traceId = inbound ? inbound.traceId : traceIdFor(init.adwId);
|
|
343
|
+
this.rootParentSpanId = inbound ? inbound.spanId : "";
|
|
344
|
+
this.rootSpanId = spanIdFor(`run:${init.adwId}`);
|
|
345
|
+
}
|
|
346
|
+
// ── fan-out seams (called from tracer.ts's write methods) ────────────────
|
|
347
|
+
/** `tracer.sessionStart` — only the run's clock; the engineer name is not allowlisted. */
|
|
348
|
+
recordSessionStart(startedAtIso) {
|
|
349
|
+
const parsed = startedAtIso ? Date.parse(startedAtIso) : NaN;
|
|
350
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
351
|
+
this.runStartedAtMs = parsed;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `tracer.event` — the one hot seam. Dispatch, in full:
|
|
355
|
+
* phase_start/phase_end -> ignored (the phase span's own boundaries say it)
|
|
356
|
+
* agent_start -> open an agent call
|
|
357
|
+
* agent_end -> emit the agent-call child span, with usage
|
|
358
|
+
* tool_call -> emit a tool child span (real timing, safe name)
|
|
359
|
+
* gate_pass/gate_fail -> ignored HERE; `recordGate` carries the structured
|
|
360
|
+
* GateReport for the same gate, and doubling it
|
|
361
|
+
* would put two span events on every gate
|
|
362
|
+
* handoff/error -> a span event buffered onto the phase span
|
|
363
|
+
* log -> dropped: a console line adds nothing beyond
|
|
364
|
+
* `spf.event.type`/`name`, and buffering it would
|
|
365
|
+
* crowd `handoff`/`error` out of the per-phase cap
|
|
366
|
+
* (see MAX_EVENTS_PER_SPAN)
|
|
367
|
+
* anything else -> dropped. The dispatch is fail-closed on purpose:
|
|
368
|
+
* a future EventRecord.type whose `name` is
|
|
369
|
+
* derived from agent output must not fall through
|
|
370
|
+
* to export by default.
|
|
371
|
+
*/
|
|
372
|
+
recordEvent(record, eventId, tsIso) {
|
|
373
|
+
switch (record.type) {
|
|
374
|
+
case "phase_start":
|
|
375
|
+
case "phase_end":
|
|
376
|
+
case "gate_pass":
|
|
377
|
+
case "gate_fail":
|
|
378
|
+
return;
|
|
379
|
+
case "agent_start":
|
|
380
|
+
this.openAgentCall(record.phase_id, record.name, tsIso);
|
|
381
|
+
return;
|
|
382
|
+
case "agent_end":
|
|
383
|
+
this.closeAgentCall(record, tsIso);
|
|
384
|
+
return;
|
|
385
|
+
case "tool_call":
|
|
386
|
+
this.emitToolSpan(record, eventId, tsIso);
|
|
387
|
+
return;
|
|
388
|
+
case "log":
|
|
389
|
+
return;
|
|
390
|
+
case "handoff":
|
|
391
|
+
case "error":
|
|
392
|
+
this.bufferSpanEvent(record.phase_id, {
|
|
393
|
+
timeUnixNano: nanosFromIso(record.started_at ?? tsIso),
|
|
394
|
+
name: record.type,
|
|
395
|
+
// `record.name` is code- or config-declared (a phase name, a gate
|
|
396
|
+
// name, "paths_touched"), never agent output — unlike payload.
|
|
397
|
+
attributes: [str("spf.event.type", record.type), str("spf.event.name", clip(record.name))],
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
default:
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* `tracer.phaseUpsert` — the phase's END is the emit point (see PHASE SPANS
|
|
406
|
+
* ARE EMITTED AT PHASE END ONLY). The start-of-phase upsert has no
|
|
407
|
+
* `ended_at` and is skipped, which also makes this idempotent-ish: a
|
|
408
|
+
* re-upsert of the same finished phase re-emits a span with the SAME id, so
|
|
409
|
+
* a backend overwrites rather than duplicates.
|
|
410
|
+
*/
|
|
411
|
+
recordPhase(phase) {
|
|
412
|
+
if (!phase.ended_at)
|
|
413
|
+
return;
|
|
414
|
+
const spanId = spanIdFor(phase.phase_id);
|
|
415
|
+
this.emittedPhases.add(phase.phase_id);
|
|
416
|
+
const attributes = [
|
|
417
|
+
str("spf.adw_id", this.adwId),
|
|
418
|
+
str("spf.chain", this.chainName),
|
|
419
|
+
str("spf.phase.name", clip(phase.params.name)),
|
|
420
|
+
str("spf.phase.kind", clip(phase.params.kind)),
|
|
421
|
+
str("spf.phase.owner", clip(phase.params.owner)),
|
|
422
|
+
str("spf.phase.status", clip(phase.status)),
|
|
423
|
+
int("spf.phase.seq", phase.seq),
|
|
424
|
+
int("spf.phase.attempt", phase.attempt),
|
|
425
|
+
];
|
|
426
|
+
this.enqueue({
|
|
427
|
+
traceId: this.traceId,
|
|
428
|
+
spanId,
|
|
429
|
+
parentSpanId: this.rootSpanId,
|
|
430
|
+
name: `phase ${phase.params.name}`,
|
|
431
|
+
kind: SPAN_KIND_INTERNAL,
|
|
432
|
+
startTimeUnixNano: nanosFromIso(phase.started_at, this.runStartedAtMs),
|
|
433
|
+
endTimeUnixNano: nanosFromIso(phase.ended_at),
|
|
434
|
+
attributes,
|
|
435
|
+
// `phase.error` is deliberately absent: agent- and repo-derived text.
|
|
436
|
+
// The ERROR status is the whole signal a backend gets.
|
|
437
|
+
status: { code: phase.status === "success" ? STATUS_OK : STATUS_ERROR },
|
|
438
|
+
events: this.takeBufferedEvents(phase.phase_id),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* `tracer.gateRow` — the verdict and its SIZE, never its content. A
|
|
443
|
+
* violation string quotes the agent's own claim and the repo's files; only
|
|
444
|
+
* the count crosses the wire.
|
|
445
|
+
*/
|
|
446
|
+
recordGate(phase, gate, report, attempt) {
|
|
447
|
+
this.bufferSpanEvent(phase.phase_id, {
|
|
448
|
+
timeUnixNano: nanosFromIso(null),
|
|
449
|
+
name: report.passed ? "gate_pass" : "gate_fail",
|
|
450
|
+
attributes: [
|
|
451
|
+
str("spf.gate.name", clip(gate)),
|
|
452
|
+
bool("spf.gate.passed", report.passed),
|
|
453
|
+
int("spf.gate.violation_count", report.violations.length),
|
|
454
|
+
int("spf.gate.attempt", attempt),
|
|
455
|
+
],
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* `tracer.agentSessionRow` — the typed source for an agent's model and
|
|
460
|
+
* backend. Load-bearing, not decoration: it is written BEFORE the
|
|
461
|
+
* `agent_end` event (see `agents.ts`), which is what lets the agent-call
|
|
462
|
+
* span carry model/coding_agent without ever reading the `agent_start`
|
|
463
|
+
* payload. `session_id` is NOT exported (it is a coding-agent handle, not a
|
|
464
|
+
* measure).
|
|
465
|
+
*/
|
|
466
|
+
recordAgentSession(agent) {
|
|
467
|
+
this.agentMeta.set(agent.name, { model: agent.model, codingAgent: agent.coding_agent });
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* `tracer.sessionFinish` — emits the root run span exactly once. Called
|
|
471
|
+
* twice on some paths (a failing phase finalizes, then `finish()` does), and
|
|
472
|
+
* the guard is why that is harmless.
|
|
473
|
+
*/
|
|
474
|
+
recordSessionFinish(ok) {
|
|
475
|
+
this.emitRootSpan(ok ? "success" : "fail", ok ? STATUS_OK : STATUS_ERROR);
|
|
476
|
+
}
|
|
477
|
+
// ── queue + batching ────────────────────────────────────────────────────
|
|
478
|
+
/** Queued spans and spans/events dropped so far. For tests and diagnostics. */
|
|
479
|
+
stats() {
|
|
480
|
+
return { queued: this.queue.length, dropped: this.dropped, droppedEvents: this.droppedEvents };
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* The exact JSON body the next flush would POST, without sending or
|
|
484
|
+
* draining. This is the seam `src/test/otel.test.ts` uses to prove the
|
|
485
|
+
* allowlist holds — the assertion is on the literal bytes, so any future
|
|
486
|
+
* attribute that leaks a payload fails a test rather than a review.
|
|
487
|
+
*/
|
|
488
|
+
pendingJson() {
|
|
489
|
+
return JSON.stringify(this.payloadFor(this.queue, false));
|
|
490
|
+
}
|
|
491
|
+
enqueue(span) {
|
|
492
|
+
if (this.queue.length >= MAX_QUEUED_SPANS) {
|
|
493
|
+
this.queue.shift(); // drop OLDEST: the newest spans are the ones still explaining the run
|
|
494
|
+
this.dropped += 1;
|
|
495
|
+
}
|
|
496
|
+
this.queue.push(span);
|
|
497
|
+
// Size trigger SCHEDULES; it never sends inline. A synchronous burst of
|
|
498
|
+
// events therefore fills the queue (exercising the bound) instead of
|
|
499
|
+
// interleaving thousands of sends into the middle of a phase.
|
|
500
|
+
this.schedule(this.queue.length >= BATCH_SPANS ? 0 : FLUSH_INTERVAL_MS);
|
|
501
|
+
}
|
|
502
|
+
schedule(delayMs) {
|
|
503
|
+
if (this.timer && this.timerDelay <= delayMs)
|
|
504
|
+
return;
|
|
505
|
+
if (this.timer)
|
|
506
|
+
clearTimeout(this.timer);
|
|
507
|
+
this.timerDelay = delayMs;
|
|
508
|
+
this.timer = setTimeout(() => {
|
|
509
|
+
this.timer = null;
|
|
510
|
+
this.timerDelay = Number.POSITIVE_INFINITY;
|
|
511
|
+
this.track(this.flush());
|
|
512
|
+
}, delayMs);
|
|
513
|
+
// UNREF'D: the exporter must never be the reason a `spf` process lingers.
|
|
514
|
+
this.timer.unref?.();
|
|
515
|
+
}
|
|
516
|
+
track(promise) {
|
|
517
|
+
this.pending.add(promise);
|
|
518
|
+
void promise.finally(() => this.pending.delete(promise));
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Send whatever is queued. Never throws, never rejects: a failed export is a
|
|
522
|
+
* single redacted log line and a swallowed error, because the alternative is
|
|
523
|
+
* an observability feature that can fail a run.
|
|
524
|
+
*/
|
|
525
|
+
async flush(isFinal = false) {
|
|
526
|
+
if (this.timer) {
|
|
527
|
+
clearTimeout(this.timer);
|
|
528
|
+
this.timer = null;
|
|
529
|
+
this.timerDelay = Number.POSITIVE_INFINITY;
|
|
530
|
+
}
|
|
531
|
+
if (this.queue.length === 0)
|
|
532
|
+
return;
|
|
533
|
+
const spans = this.queue;
|
|
534
|
+
this.queue = [];
|
|
535
|
+
const body = JSON.stringify(this.payloadFor(spans, isFinal));
|
|
536
|
+
if (isFinal && this.dropped > 0 && !this.warnedDrops) {
|
|
537
|
+
this.warnedDrops = true;
|
|
538
|
+
this.log(`spf: otel export dropped ${this.dropped} span(s) — the queue bound (${MAX_QUEUED_SPANS}) was hit`);
|
|
539
|
+
}
|
|
540
|
+
const controller = new AbortController();
|
|
541
|
+
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
|
542
|
+
timer.unref?.();
|
|
543
|
+
try {
|
|
544
|
+
const response = await fetch(this.url, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers: { "content-type": "application/json", ...(this.cfg.headers ?? {}) },
|
|
547
|
+
body,
|
|
548
|
+
signal: controller.signal,
|
|
549
|
+
});
|
|
550
|
+
if (!response.ok)
|
|
551
|
+
this.logFailureOnce(`HTTP ${response.status}`);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
this.logFailureOnce(error?.message ?? String(error));
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Drain: flush, then await anything already in flight, all under one hard
|
|
562
|
+
* budget. Never throws. Called from the CLI's `finally` and from
|
|
563
|
+
* `session.ts`'s signal handler (with a tighter budget there).
|
|
564
|
+
*/
|
|
565
|
+
async drain(budgetMs = SEND_TIMEOUT_MS) {
|
|
566
|
+
// A hard crash never reached sessionFinish — emit the root span anyway so
|
|
567
|
+
// its children are not orphans, marked so the gap is legible.
|
|
568
|
+
if (!this.rootEmitted)
|
|
569
|
+
this.emitRootSpan("incomplete", STATUS_UNSET);
|
|
570
|
+
const work = (async () => {
|
|
571
|
+
this.track(this.flush(true));
|
|
572
|
+
await Promise.all([...this.pending]);
|
|
573
|
+
})();
|
|
574
|
+
// Give `work` a terminal handler before racing it against `budget`: if
|
|
575
|
+
// `budget` wins first and `work` rejects afterward, an unattached
|
|
576
|
+
// rejection here would be unhandled (Node >=15 terminates the process)
|
|
577
|
+
// on exactly the shutdown path this function exists to protect.
|
|
578
|
+
void work.catch(() => { });
|
|
579
|
+
let deadline = null;
|
|
580
|
+
const budget = new Promise((resolve) => {
|
|
581
|
+
deadline = setTimeout(resolve, budgetMs);
|
|
582
|
+
deadline.unref?.();
|
|
583
|
+
});
|
|
584
|
+
try {
|
|
585
|
+
await Promise.race([work, budget]);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// unreachable in practice — flush() already swallows — but a drain that
|
|
589
|
+
// can throw would break the shutdown path it exists to protect.
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
if (deadline)
|
|
593
|
+
clearTimeout(deadline);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
597
|
+
emitRootSpan(status, code) {
|
|
598
|
+
if (this.rootEmitted)
|
|
599
|
+
return;
|
|
600
|
+
this.rootEmitted = true;
|
|
601
|
+
this.enqueue({
|
|
602
|
+
traceId: this.traceId,
|
|
603
|
+
spanId: this.rootSpanId,
|
|
604
|
+
parentSpanId: this.rootParentSpanId,
|
|
605
|
+
name: `spf run ${this.chainName}`,
|
|
606
|
+
kind: SPAN_KIND_INTERNAL,
|
|
607
|
+
startTimeUnixNano: nanosFromIso(null, this.runStartedAtMs),
|
|
608
|
+
endTimeUnixNano: nanosFromIso(null),
|
|
609
|
+
attributes: [
|
|
610
|
+
str("spf.adw_id", this.adwId),
|
|
611
|
+
str("spf.chain", this.chainName),
|
|
612
|
+
str("spf.run.status", status),
|
|
613
|
+
],
|
|
614
|
+
status: { code },
|
|
615
|
+
events: this.takeBufferedEvents(""),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
agentKey(phaseId, agentName) {
|
|
619
|
+
return `${phaseId}${agentName}`;
|
|
620
|
+
}
|
|
621
|
+
openAgentCall(phaseId, agentName, tsIso) {
|
|
622
|
+
const key = this.agentKey(phaseId, agentName);
|
|
623
|
+
const n = (this.agentCalls.get(key) ?? 0) + 1;
|
|
624
|
+
this.agentCalls.set(key, n);
|
|
625
|
+
this.openAgents.set(key, {
|
|
626
|
+
spanId: spanIdFor(`agent:${phaseId}:${agentName}:${n}`),
|
|
627
|
+
startNano: nanosFromIso(tsIso),
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
closeAgentCall(record, tsIso) {
|
|
631
|
+
const key = this.agentKey(record.phase_id, record.name);
|
|
632
|
+
const open = this.openAgents.get(key);
|
|
633
|
+
this.openAgents.delete(key);
|
|
634
|
+
const meta = this.agentMeta.get(record.name);
|
|
635
|
+
const attributes = [
|
|
636
|
+
str("spf.adw_id", this.adwId),
|
|
637
|
+
str("spf.agent.name", clip(record.name)),
|
|
638
|
+
];
|
|
639
|
+
if (meta) {
|
|
640
|
+
attributes.push(str("spf.agent.model", clip(meta.model)), str("spf.agent.coding_agent", clip(meta.codingAgent)));
|
|
641
|
+
// gen_ai.* is the OTel semantic convention a GenAI-aware backend groups
|
|
642
|
+
// by; the spf.* twins stay because they are what SPF's own queries use.
|
|
643
|
+
attributes.push(str("gen_ai.request.model", clip(meta.model)));
|
|
644
|
+
}
|
|
645
|
+
// NUMBERS ONLY out of payload — see the ATTRIBUTE ALLOWLIST note on
|
|
646
|
+
// `numOrNull`. A string under any of these keys is dropped, not exported.
|
|
647
|
+
const usage = record.payload?.["usage"];
|
|
648
|
+
const usageObj = usage && typeof usage === "object" ? usage : {};
|
|
649
|
+
for (const [field, key2] of TOKEN_FIELDS) {
|
|
650
|
+
const value = numOrNull(usageObj[field]);
|
|
651
|
+
if (value !== null)
|
|
652
|
+
attributes.push(int(key2, value));
|
|
653
|
+
}
|
|
654
|
+
for (const [field, key2] of COST_FIELDS) {
|
|
655
|
+
const value = numOrNull(usageObj[field]);
|
|
656
|
+
if (value !== null)
|
|
657
|
+
attributes.push(dbl(key2, value));
|
|
658
|
+
}
|
|
659
|
+
const totalTokens = numOrNull(record.tokens);
|
|
660
|
+
if (totalTokens !== null)
|
|
661
|
+
attributes.push(int("spf.tokens.total", totalTokens));
|
|
662
|
+
const inputTokens = numOrNull(usageObj["input_tokens"]);
|
|
663
|
+
if (inputTokens !== null)
|
|
664
|
+
attributes.push(int("gen_ai.usage.input_tokens", inputTokens));
|
|
665
|
+
const outputTokens = numOrNull(usageObj["output_tokens"]);
|
|
666
|
+
if (outputTokens !== null)
|
|
667
|
+
attributes.push(int("gen_ai.usage.output_tokens", outputTokens));
|
|
668
|
+
const cost = numOrNull(record.payload?.["cost"]);
|
|
669
|
+
if (cost !== null)
|
|
670
|
+
attributes.push(dbl("spf.cost.total", cost));
|
|
671
|
+
this.enqueue({
|
|
672
|
+
traceId: this.traceId,
|
|
673
|
+
spanId: open?.spanId ?? spanIdFor(`agent:${record.phase_id}:${record.name}:orphan`),
|
|
674
|
+
parentSpanId: record.phase_id ? spanIdFor(record.phase_id) : this.rootSpanId,
|
|
675
|
+
name: `agent ${record.name}`,
|
|
676
|
+
kind: SPAN_KIND_INTERNAL,
|
|
677
|
+
startTimeUnixNano: open?.startNano ?? nanosFromIso(tsIso),
|
|
678
|
+
endTimeUnixNano: nanosFromIso(tsIso),
|
|
679
|
+
attributes,
|
|
680
|
+
status: { code: STATUS_UNSET }, // the phase span carries the verdict
|
|
681
|
+
events: [],
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Tool spans carry REAL elapsed time (the tracker records started_at/ended_at
|
|
686
|
+
* per call), which is the whole reason they are spans and not span events.
|
|
687
|
+
* Parented under the open agent call when one is attributable — the agent
|
|
688
|
+
* name comes from `payload.agent`, and it is used ONLY as a map lookup key,
|
|
689
|
+
* never written to an attribute, so an unexpected value yields "no parent
|
|
690
|
+
* found" rather than an exported string.
|
|
691
|
+
*/
|
|
692
|
+
emitToolSpan(record, eventId, tsIso) {
|
|
693
|
+
const agentName = record.payload?.["agent"];
|
|
694
|
+
const open = typeof agentName === "string" ? this.openAgents.get(this.agentKey(record.phase_id, agentName)) : undefined;
|
|
695
|
+
const parent = open?.spanId ?? (record.phase_id ? spanIdFor(record.phase_id) : this.rootSpanId);
|
|
696
|
+
this.enqueue({
|
|
697
|
+
traceId: this.traceId,
|
|
698
|
+
spanId: spanIdFor(`tool:${record.phase_id}:${eventId}`),
|
|
699
|
+
parentSpanId: parent,
|
|
700
|
+
name: toolSpanName(record.name),
|
|
701
|
+
kind: SPAN_KIND_INTERNAL,
|
|
702
|
+
startTimeUnixNano: nanosFromIso(record.started_at ?? tsIso),
|
|
703
|
+
endTimeUnixNano: nanosFromIso(record.ended_at ?? tsIso),
|
|
704
|
+
attributes: [str("spf.adw_id", this.adwId), str("spf.event.type", record.type)],
|
|
705
|
+
status: { code: STATUS_UNSET },
|
|
706
|
+
events: [],
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
bufferSpanEvent(phaseId, event) {
|
|
710
|
+
const key = phaseId && !this.emittedPhases.has(phaseId) ? phaseId : "";
|
|
711
|
+
const list = this.bufferedEvents.get(key) ?? [];
|
|
712
|
+
if (list.length >= MAX_EVENTS_PER_SPAN) {
|
|
713
|
+
this.droppedEvents += 1;
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
list.push(event);
|
|
717
|
+
this.bufferedEvents.set(key, list);
|
|
718
|
+
}
|
|
719
|
+
takeBufferedEvents(phaseId) {
|
|
720
|
+
const events = this.bufferedEvents.get(phaseId) ?? [];
|
|
721
|
+
this.bufferedEvents.delete(phaseId);
|
|
722
|
+
return events;
|
|
723
|
+
}
|
|
724
|
+
payloadFor(spans, isFinal) {
|
|
725
|
+
const attributes = [
|
|
726
|
+
str("service.name", this.serviceName),
|
|
727
|
+
str("spf.adw_id", this.adwId),
|
|
728
|
+
str("spf.chain", this.chainName),
|
|
729
|
+
];
|
|
730
|
+
// The drop counter rides the FINAL flush's resource, so the gap is visible
|
|
731
|
+
// in the backend and not only in a log line nobody kept.
|
|
732
|
+
if (isFinal && this.dropped > 0)
|
|
733
|
+
attributes.push(int("spf.otel.dropped_spans", this.dropped));
|
|
734
|
+
if (isFinal && this.droppedEvents > 0)
|
|
735
|
+
attributes.push(int("spf.otel.dropped_span_events", this.droppedEvents));
|
|
736
|
+
return {
|
|
737
|
+
resourceSpans: [
|
|
738
|
+
{
|
|
739
|
+
resource: { attributes },
|
|
740
|
+
scopeSpans: [{ scope: { name: "spf", version: "1" }, spans }],
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
logFailureOnce(reason) {
|
|
746
|
+
if (this.loggedFailure)
|
|
747
|
+
return;
|
|
748
|
+
this.loggedFailure = true;
|
|
749
|
+
const secrets = [this.cfg.endpoint, this.url, ...Object.values(this.cfg.headers ?? {})];
|
|
750
|
+
this.log(`spf: otel export failed (${redact(reason, secrets)}) — spans for this run are lost; the run is unaffected`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/** Attribute strings are clipped: an attribute is a label, not a document. */
|
|
754
|
+
function clip(value, limit = 200) {
|
|
755
|
+
const text = String(value ?? "");
|
|
756
|
+
return text.length <= limit ? text : text.slice(0, limit);
|
|
757
|
+
}
|
|
758
|
+
// ── module-level lifecycle (mirrors notify/notifier.ts's LIVE + flushAll) ───
|
|
759
|
+
const LIVE = [];
|
|
760
|
+
/**
|
|
761
|
+
* Build an exporter from `cfg.observability.otel`, or `null` when it is
|
|
762
|
+
* absent — the same optional-dependency shape as `resolveNotifier`, so every
|
|
763
|
+
* call site is `otel?.record...()` and never a conditional branch. `null` is
|
|
764
|
+
* the default for every repo that has not configured an endpoint, and no
|
|
765
|
+
* environment variable can change that (see EXPLICIT CONFIG ONLY).
|
|
766
|
+
*/
|
|
767
|
+
export function resolveOtelExporter(cfg, opts) {
|
|
768
|
+
const otel = cfg.observability.otel;
|
|
769
|
+
if (!otel || !otel.endpoint)
|
|
770
|
+
return null;
|
|
771
|
+
const exporter = new OtelExporter({
|
|
772
|
+
cfg: otel,
|
|
773
|
+
adwId: opts.adwId,
|
|
774
|
+
chainName: opts.chainName,
|
|
775
|
+
log: opts.log,
|
|
776
|
+
env: opts.env,
|
|
777
|
+
});
|
|
778
|
+
LIVE.push(exporter);
|
|
779
|
+
return exporter;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Drain every exporter this process created, under one budget. A no-op when
|
|
783
|
+
* otel is unconfigured. Called from `src/cli/index.ts`'s `finally` (next to
|
|
784
|
+
* `notify.flushAll()`) and, with a tighter budget, from `session.ts`'s signal
|
|
785
|
+
* handler. Never throws.
|
|
786
|
+
*/
|
|
787
|
+
export async function flushAll(budgetMs) {
|
|
788
|
+
await Promise.all(LIVE.map((exporter) => exporter.drain(budgetMs)));
|
|
789
|
+
}
|
|
790
|
+
/** Tests only: forget every registered exporter so cases cannot leak into each other. */
|
|
791
|
+
export function resetLiveForTest() {
|
|
792
|
+
LIVE.length = 0;
|
|
793
|
+
}
|