@gr8ful/spf 0.16.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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Outbound trace-context propagation for `coding_agent: flue` — the
3
+ * "best-effort" half of the two propagation paths this repo's OTel spike
4
+ * documented (`claude_code` gets `agent_cc.ts`'s single `spawn()` choke
5
+ * point instead; see that module).
6
+ *
7
+ * `@flue/opentelemetry`'s own docs (fetched at
8
+ * https://flueframework.com/docs/ecosystem/tooling/opentelemetry/, cited in
9
+ * this repo's phase0 spike) are explicit that `dispatch()` "does not
10
+ * propagate trace context currently" and that "custom header propagation to
11
+ * model providers is not documented" — so the literal ask ("propagate via
12
+ * @flue/opentelemetry") is not fully satisfiable by that package alone. What
13
+ * IS real and verifiable: `@opentelemetry/instrumentation-http` and
14
+ * `-undici` create a real client span (with a real, non-noop SpanContext)
15
+ * around every outbound `http`/`https`/`fetch`(undici) call made from this
16
+ * process, and the OTel API's global propagator is what those
17
+ * instrumentations use to inject `traceparent` (and, here, `x-request-id`)
18
+ * into that call's headers — REGARDLESS of which provider SDK issued it.
19
+ * This reaches every provider whose Node SDK issues requests through
20
+ * Node's own `http`/`https` modules or `undici` (verified: `fetch()`,
21
+ * `https.request()`). It does NOT reach a provider transport that bypasses
22
+ * both (unverified in this repo for the Anthropic/Google/Mistral SDKs'
23
+ * internal transports specifically — flagged, not assumed, per the spike).
24
+ *
25
+ * WHY A REAL GLOBAL TracerProvider IS REQUIRED, NOT OPTIONAL: the
26
+ * W3CTraceContextPropagator's `inject()` silently skips writing a
27
+ * `traceparent` header when the active SpanContext is INVALID
28
+ * (`isSpanContextValid()` false) — which is exactly what every span is when
29
+ * no global TracerProvider has ever been registered (the API's default is a
30
+ * no-op tracer). So this module registers a real (if minimal)
31
+ * `BasicTracerProvider` — with its own `BatchSpanProcessor` ->
32
+ * `OTLPTraceExporter` aimed at the SAME collector `observability.otel.
33
+ * endpoint` names — alongside the propagator and the two instrumentations.
34
+ * This also happens to be exactly what `@flue/opentelemetry`'s own docs ask
35
+ * for ("Configure the SDK first, then register one instrumentation
36
+ * instance") — Flue's own spans (`invoke_agent`, `chat <model>`,
37
+ * `execute_tool`) now have somewhere real to go, which they did not before.
38
+ *
39
+ * FLUE SPANS JOIN SPF's DETERMINISTIC TRACE (v3; issue #80). The naive
40
+ * approach — extracting SPF's agent-call traceparent into the active
41
+ * context around `dispatch()` — was tried and REJECTED by design review:
42
+ * flue's node runtime executes submissions in ONE process-lifetime claim
43
+ * loop (`builtin-providers ... claimLoop()`, started by the first
44
+ * dispatch's `finally`), whose async context is captured once at loop
45
+ * creation. A dispatch-time context wrap therefore joins only the FIRST
46
+ * flue agent in a process and silently MIS-ATTRIBUTES every later agent's
47
+ * spans into the first agent's trace — worse than a separate trace.
48
+ *
49
+ * The mechanism below instead uses the instrumentation's own
50
+ * `resolveRootContext(event, ctx)` option (typed in
51
+ * `@flue/opentelemetry`'s public d.mts; verified in its dist: consulted
52
+ * per span exactly when a span has neither an explicit parent nor an
53
+ * active-context SpanContext — i.e. per span, per submission, no matter
54
+ * what context the claim loop was captured in). SPF keeps a small
55
+ * instance-id -> traceparent map (`registerFlueSessionTrace`, populated by
56
+ * `agent_flue.ts`'s `run()` around each agent call), and the resolver
57
+ * matches on `ctx.id` — flue's documented "stable agent instance id during
58
+ * agent processing", which is the id SPF mints and hands to
59
+ * `init(SfAgent, { id })`. Extraction goes through the globally
60
+ * registered propagator against ROOT_CONTEXT, so no leaked loop context
61
+ * can stick. Consequences, all intended:
62
+ * - Flue's spans inherit SPF's sha256 trace id, parented under the
63
+ * right agent-call span PER SESSION — correct under multiple agents
64
+ * per process, concurrent agents, and claim-loop restarts alike. Span
65
+ * ids are SDK-random; only the trace id is shared.
66
+ * - The http/undici client spans' injected `traceparent` carries the
67
+ * deterministic id too, so Switchyard/vLLM hops land as descendants of
68
+ * SPF's trace — parity with `claude_code`'s `ANTHROPIC_CUSTOM_HEADERS`
69
+ * path. `x-request-id` stays the this-span id for request-keyed
70
+ * correlation, unchanged.
71
+ * - Unmapped sessions (never registered, restarted process with a
72
+ * durable backlog, post-`unregister` straggler bookkeeping spans)
73
+ * resolve to an unparented root — flue's spans root a separate SDK
74
+ * trace exactly as v1 did, correlatable by `x-request-id`/`spf.adw_id`/
75
+ * time window. Degraded join, never an error and never MIS-attributed.
76
+ * - flue's internal `executionContext.traceCarrier` (typed but not on
77
+ * the public `AgentDispatchRequest` surface) stays unused — noted here
78
+ * as flue's own escape hatch, not something SPF reaches into.
79
+ *
80
+ * REGISTRATION TIMING. `installFluePropagation()` is called from
81
+ * `agent_flue.ts`'s `run()`, before `ensureRuntime()`/dispatch — i.e. before
82
+ * the actual outbound call, which is the only ordering that matters for
83
+ * `instrumentation-undici` (subscribes to `undici`'s own `diagnostics_channel`
84
+ * events; any registration before the request fires is picked up regardless
85
+ * of when `undici`/`fetch` was first imported) and, in practice, for
86
+ * `instrumentation-http` too (Node's `http`/`https` modules are shared,
87
+ * monkey-patchable singletons; a later `require("http")` elsewhere in the
88
+ * process still resolves to the SAME, already-patched module object). This
89
+ * is a real, load-bearing difference from a truly cold, "before ANY other
90
+ * import" registration (which would require moving this into `cli/bin.ts`,
91
+ * ahead of that file's own deliberately-static-import-free module graph) —
92
+ * documented as the honest scope of what's verified, not claimed as more.
93
+ *
94
+ * NO-OP WHEN UNCONFIGURED. Gated on the exact same `observability.otel.
95
+ * endpoint` presence check as `otel.ts` and `otel_metrics.ts` — no ambient
96
+ * `OTEL_*` env var activates any of this on its own. Idempotent: registers
97
+ * exactly once per process, on whichever call (across however many `flue`
98
+ * agent dispatches this process makes) happens to arrive first.
99
+ */
100
+ import { defaultTextMapGetter, propagation, ROOT_CONTEXT, trace as traceApi, context as contextApi, isSpanContextValid } from "@opentelemetry/api";
101
+ import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks";
102
+ import { CompositePropagator, W3CTraceContextPropagator } from "@opentelemetry/core";
103
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
104
+ import { registerInstrumentations } from "@opentelemetry/instrumentation";
105
+ import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
106
+ import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
107
+ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
108
+ import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry";
109
+ import { instrument } from "@flue/runtime";
110
+ import { resolveTracesUrl } from "./otel.js";
111
+ /** The one custom propagation field this repo adds beyond the standard W3C `traceparent`: the current span's own id, for a collector/log pipeline that correlates by request rather than by trace. */
112
+ export const X_REQUEST_ID_HEADER = "x-request-id";
113
+ /**
114
+ * Injects `x-request-id` from whatever span is active at the point of the
115
+ * outbound call — the same id that would appear as that span's `spanId` on
116
+ * the (separate — see the module header) Flue-side trace. One-directional:
117
+ * `extract()` is a pass-through, since nothing on the INBOUND side of an
118
+ * outbound provider call needs to read this back.
119
+ */
120
+ export class XRequestIdPropagator {
121
+ inject(ctx, carrier, setter) {
122
+ const spanContext = traceApi.getSpanContext(ctx);
123
+ if (!spanContext || !isSpanContextValid(spanContext))
124
+ return;
125
+ setter.set(carrier, X_REQUEST_ID_HEADER, spanContext.spanId);
126
+ }
127
+ extract(ctx) {
128
+ return ctx;
129
+ }
130
+ fields() {
131
+ return [X_REQUEST_ID_HEADER];
132
+ }
133
+ }
134
+ /**
135
+ * Instance-id -> agent-call traceparent registrations backing
136
+ * `resolveFlueRootContext` — see the module header for the full mechanism
137
+ * and WHY this is a map consulted per span rather than a dispatch-time
138
+ * context wrap (flue's single claim loop makes the latter mis-attribute
139
+ * every agent after the first). One entry per in-flight `agent_flue.run()`
140
+ * call.
141
+ */
142
+ const flueSessionTraces = new Map();
143
+ /**
144
+ * Registers `traceparent` (SPF's deterministic agent-call span, as a W3C
145
+ * carrier string) as the trace root for flue spans belonging to `sessionId`
146
+ * — flue's instance id, minted by SPF and handed to `init(SfAgent, { id })`.
147
+ * Overwrites a prior registration for the same id (a same-phase retry is
148
+ * the same logical call; the current call wins).
149
+ */
150
+ export function registerFlueSessionTrace(sessionId, traceparent) {
151
+ flueSessionTraces.set(sessionId, traceparent);
152
+ }
153
+ /**
154
+ * Idempotent — called from `run()`'s `finally`. Post-settlement bookkeeping
155
+ * spans flue mints after this point simply resolve to an unparented root
156
+ * (separate trace), which is preferable to leaking a registration whose id
157
+ * a REUSED session id could collide with on a later phase.
158
+ */
159
+ export function unregisterFlueSessionTrace(sessionId) {
160
+ flueSessionTraces.delete(sessionId);
161
+ }
162
+ /**
163
+ * The `resolveRootContext` implementation handed to
164
+ * `createOpenTelemetryInstrumentation` — consulted per root-span creation
165
+ * (see the module header). Matches on `ctx.id` (flue's documented stable
166
+ * agent instance id during processing) and returns SPF's agent-call span
167
+ * as an extracted REMOTE parent, pulled from ROOT_CONTEXT so no ambient
168
+ * claim-loop context can leak in. Returns `undefined` (flue mints an
169
+ * unparented root span of its own) for an unmapped session id, a malformed
170
+ * traceparent, an absent ctx — and, with no global propagator installed,
171
+ * for everything. Exported for tests.
172
+ */
173
+ export function resolveFlueRootContext(_event, ctx) {
174
+ const traceparent = ctx?.id ? flueSessionTraces.get(ctx.id) : undefined;
175
+ if (!traceparent)
176
+ return undefined;
177
+ const extracted = propagation.extract(ROOT_CONTEXT, { traceparent }, defaultTextMapGetter);
178
+ const spanContext = traceApi.getSpanContext(extracted);
179
+ return spanContext && isSpanContextValid(spanContext) ? extracted : undefined;
180
+ }
181
+ let installed = false;
182
+ let flueInstrumented = false;
183
+ /**
184
+ * Idempotent: the first call in this process wins; every later call
185
+ * (another `flue` agent dispatch, possibly with a different `cfg`) is a
186
+ * silent no-op, matching the "process-scoped, created exactly once" rule
187
+ * `otel_metrics.ts` documents for the same reason (`spf watch`'s daemon
188
+ * loop). Never throws — a failure to install best-effort propagation must
189
+ * never fail an agent dispatch.
190
+ */
191
+ export function installFluePropagation(cfg, log = (m) => console.error(m)) {
192
+ if (!cfg || !cfg.endpoint)
193
+ return;
194
+ try {
195
+ if (!installed) {
196
+ const provider = new BasicTracerProvider({
197
+ spanProcessors: [
198
+ new BatchSpanProcessor(new OTLPTraceExporter({ url: resolveTracesUrl(cfg.endpoint), headers: cfg.headers, keepAlive: false })),
199
+ ],
200
+ });
201
+ traceApi.setGlobalTracerProvider(provider);
202
+ contextApi.setGlobalContextManager(new AsyncHooksContextManager().enable());
203
+ propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new XRequestIdPropagator()] }));
204
+ registerInstrumentations({ instrumentations: [new HttpInstrumentation(), new UndiciInstrumentation()] });
205
+ // Set AFTER the fallible registrations above: on a construction-time
206
+ // throw, the next call must be free to retry — latching `installed`
207
+ // first would permanently disable the process with one stderr line,
208
+ // and flue's instrumentation (created at most once below) would
209
+ // capture the no-op tracer as its provider.
210
+ installed = true;
211
+ }
212
+ if (!flueInstrumented) {
213
+ flueInstrumented = true;
214
+ instrument(createOpenTelemetryInstrumentation({ content: false, resolveRootContext: resolveFlueRootContext }));
215
+ }
216
+ }
217
+ catch (error) {
218
+ log(`spf: otel flue propagation setup failed (${error?.message ?? String(error)}) — provider calls will not carry a traceparent; runs are unaffected`);
219
+ }
220
+ }
221
+ /** Tests only: forget global installation state. Does NOT undo `setGlobalTracerProvider`/`registerInstrumentations` (the OTel API has no supported "un-register" — tests that need isolation run in a fresh process). */
222
+ export function resetFluePropagationForTest() {
223
+ installed = false;
224
+ flueInstrumented = false;
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,9 +42,22 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@earendil-works/pi-ai": "0.83.0",
45
+ "@flue/opentelemetry": "2.0.4",
45
46
  "@flue/runtime": "2.0.3",
46
47
  "@hono/node-server": "^2.1.1",
47
48
  "@inkjs/ui": "^2.0.0",
49
+ "@opentelemetry/api": "1.9.1",
50
+ "@opentelemetry/context-async-hooks": "2.11.0",
51
+ "@opentelemetry/core": "2.11.0",
52
+ "@opentelemetry/exporter-metrics-otlp-http": "0.222.0",
53
+ "@opentelemetry/exporter-trace-otlp-http": "0.222.0",
54
+ "@opentelemetry/instrumentation": "0.222.0",
55
+ "@opentelemetry/instrumentation-http": "0.222.0",
56
+ "@opentelemetry/instrumentation-undici": "0.32.0",
57
+ "@opentelemetry/resources": "2.11.0",
58
+ "@opentelemetry/sdk-metrics": "2.11.0",
59
+ "@opentelemetry/sdk-trace-base": "2.11.0",
60
+ "@opentelemetry/semantic-conventions": "1.43.0",
48
61
  "@valibot/to-json-schema": "^1.7.1",
49
62
  "hono": "^4.13.3",
50
63
  "ink": "^7.1.1",