@rangojs/router 0.0.0-experimental.126 → 0.0.0-experimental.128

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.
Files changed (44) hide show
  1. package/dist/bin/rango.js +5 -1
  2. package/dist/vite/index.js +55 -40
  3. package/package.json +23 -19
  4. package/skills/observability/SKILL.md +39 -4
  5. package/skills/prerender/SKILL.md +30 -11
  6. package/skills/router-setup/SKILL.md +23 -3
  7. package/src/build/route-types/codegen.ts +12 -1
  8. package/src/cache/cache-scope.ts +20 -0
  9. package/src/cloudflare/index.ts +11 -0
  10. package/src/cloudflare/tracing.ts +112 -0
  11. package/src/index.rsc.ts +19 -2
  12. package/src/index.ts +16 -1
  13. package/src/route-definition/dsl-helpers.ts +19 -0
  14. package/src/router/instrument.ts +440 -0
  15. package/src/router/loader-resolution.ts +15 -10
  16. package/src/router/match-middleware/cache-lookup.ts +9 -14
  17. package/src/router/match-middleware/cache-store.ts +12 -0
  18. package/src/router/middleware.ts +23 -2
  19. package/src/router/prerender-match.ts +5 -2
  20. package/src/router/router-context.ts +2 -1
  21. package/src/router/router-interfaces.ts +8 -0
  22. package/src/router/router-options.ts +58 -4
  23. package/src/router/segment-resolution/fresh.ts +15 -18
  24. package/src/router/segment-resolution/helpers.ts +6 -0
  25. package/src/router/segment-resolution/loader-cache.ts +5 -0
  26. package/src/router/segment-resolution/revalidation.ts +9 -18
  27. package/src/router/segment-wrappers.ts +3 -2
  28. package/src/router/telemetry-otel.ts +161 -179
  29. package/src/router/tracing.ts +203 -0
  30. package/src/router.ts +9 -0
  31. package/src/rsc/handler.ts +140 -134
  32. package/src/rsc/loader-fetch.ts +7 -1
  33. package/src/rsc/progressive-enhancement.ts +9 -2
  34. package/src/rsc/rsc-rendering.ts +38 -14
  35. package/src/rsc/server-action.ts +28 -7
  36. package/src/segment-system.tsx +4 -1
  37. package/src/server/request-context.ts +23 -5
  38. package/src/vite/discovery/prerender-collection.ts +26 -37
  39. package/src/vite/discovery/state.ts +6 -0
  40. package/src/vite/plugin-types.ts +25 -0
  41. package/src/vite/plugins/expose-ids/router-transform.ts +10 -0
  42. package/src/vite/rango.ts +1 -0
  43. package/src/vite/router-discovery.ts +9 -3
  44. package/src/vite/utils/prerender-utils.ts +36 -0
@@ -1,20 +1,48 @@
1
1
  /**
2
- * OpenTelemetry Adapter for Router Telemetry
2
+ * OpenTelemetry adapters for the router.
3
3
  *
4
- * Maps internal TelemetrySink events to OTel spans. The core router
5
- * remains OTel-agnostic — this adapter bridges the gap by accepting
6
- * a standard OTel Tracer and producing spans/events from it.
4
+ * Two adapters, two surfaces matching the split in instrument.ts:
5
+ *
6
+ * - createOTelTracing(tracer): the OTel adapter for the `tracing` SLOT — the
7
+ * canonical phase-span layer. It bridges observePhase's callback boundary
8
+ * onto OTel's callback-bound `startActiveSpan`, so the router's phase spans
9
+ * (rango.request/middleware/loader/render/ssr) nest by async context and the
10
+ * loader's own OTel spans (db/fetch) land under rango.loader. This is the
11
+ * OTel equivalent of createCloudflareTracing — pass it to
12
+ * `createRouter({ tracing })`.
13
+ *
14
+ * - createOTelSink(tracer): the OTel adapter for the `telemetry` SLOT — a
15
+ * TelemetrySink for the EVENT-shaped facts (handler errors, cache decisions,
16
+ * revalidation decisions, timeouts, origin rejections). It emits one instant
17
+ * OTel span per fact. It deliberately does NOT emit request/loader phase
18
+ * spans: those are owned by the tracing slot (createOTelTracing) so the two
19
+ * layers don't produce duplicate rango.request / rango.loader spans.
20
+ *
21
+ * The core router stays OTel-agnostic — these adapters accept a standard OTel
22
+ * Tracer (structurally typed, no import needed) and bridge the gap.
7
23
  *
8
24
  * Usage:
9
25
  * import { trace } from "@opentelemetry/api";
10
- * import { createOTelSink } from "@rangojs/router";
26
+ * import { createRouter, createOTelTracing, createOTelSink } from "@rangojs/router";
11
27
  *
28
+ * const tracer = trace.getTracer("my-app");
12
29
  * const router = createRouter({
13
- * telemetry: createOTelSink(trace.getTracer("my-app")),
30
+ * tracing: createOTelTracing(tracer), // phase spans (callback-bound)
31
+ * telemetry: createOTelSink(tracer), // discrete-fact instant spans
14
32
  * });
33
+ *
34
+ * Faithful nesting requires an OTel async context manager
35
+ * (AsyncLocalStorageContextManager) configured in your OTel setup — standard for
36
+ * any startActiveSpan-based instrumentation.
15
37
  */
16
38
 
17
39
  import type { TelemetrySink, TelemetryEvent } from "./telemetry.js";
40
+ import { runThenSettle } from "./tracing.js";
41
+ import type {
42
+ RouterTracingConfig,
43
+ SpanRunner,
44
+ TracePhaseToggles,
45
+ } from "./tracing.js";
18
46
 
19
47
  // ---------------------------------------------------------------------------
20
48
  // Minimal OTel-compatible types (structurally typed, no import needed)
@@ -22,21 +50,21 @@ import type { TelemetrySink, TelemetryEvent } from "./telemetry.js";
22
50
 
23
51
  /**
24
52
  * Minimal Span interface compatible with @opentelemetry/api Span.
25
- * Only the methods used by the adapter are declared.
53
+ * Only the methods used by the adapters are declared.
26
54
  */
27
55
  export interface OTelSpan {
28
56
  setAttribute(key: string, value: string | number | boolean): OTelSpan | void;
29
- addEvent(
30
- name: string,
31
- attributes?: Record<string, string | number | boolean>,
32
- ): OTelSpan | void;
33
57
  setStatus(status: { code: number; message?: string }): OTelSpan | void;
34
58
  recordException(exception: Error): void;
35
59
  end(): void;
36
60
  }
37
61
 
38
62
  /**
39
- * Minimal Tracer interface compatible with @opentelemetry/api Tracer.
63
+ * Minimal Tracer interface for the EVENT sink (createOTelSink): only `startSpan`
64
+ * is used (one instant span per discrete fact). Kept narrow so a custom,
65
+ * event-only tracer that does not implement `startActiveSpan` still satisfies it.
66
+ * The real `@opentelemetry/api` Tracer has both methods, so it satisfies this and
67
+ * `OTelActiveSpanTracer` below.
40
68
  */
41
69
  export interface OTelTracer {
42
70
  startSpan(
@@ -47,182 +75,110 @@ export interface OTelTracer {
47
75
  ): OTelSpan;
48
76
  }
49
77
 
78
+ /**
79
+ * Minimal Tracer interface for the PHASE-SPAN adapter (createOTelTracing): only
80
+ * `startActiveSpan` is used (callback-bound, so the span is active for the work
81
+ * and child spans nest). Declared separately from `OTelTracer` so each factory
82
+ * requires exactly the method it calls.
83
+ */
84
+ export interface OTelActiveSpanTracer {
85
+ startActiveSpan<T>(name: string, fn: (span: OTelSpan) => T): T;
86
+ }
87
+
50
88
  // OTel SpanStatusCode constants (mirrors @opentelemetry/api values)
51
- const STATUS_OK = 1;
52
89
  const STATUS_ERROR = 2;
53
90
 
54
91
  // ---------------------------------------------------------------------------
55
- // Span correlation helpers
92
+ // Tracing adapter: phase spans via startActiveSpan (the `tracing` slot)
56
93
  // ---------------------------------------------------------------------------
57
94
 
58
- // Build correlation keys using requestId.
59
- // getRequestId() always returns a value (generated internally when no
60
- // header is present), so concurrent requests to the same path each get
61
- // their own correlation key and never mis-correlate.
62
-
63
- function requestKey(event: {
64
- requestId?: string;
65
- pathname: string;
66
- transaction: string;
67
- }): string {
68
- return `${event.requestId ?? ""}:${event.pathname}:${event.transaction}`;
69
- }
70
-
71
- function loaderKey(event: {
72
- requestId?: string;
73
- segmentId: string;
74
- loaderName: string;
75
- pathname: string;
76
- }): string {
77
- return `${event.requestId ?? ""}:${event.segmentId}:${event.loaderName}:${event.pathname}`;
95
+ /** Options for createOTelTracing. */
96
+ export interface OTelTracingOptions {
97
+ /** Master switch. Defaults to true. */
98
+ enabled?: boolean;
99
+ /** Per-phase span toggles. Omitted phases default to enabled. */
100
+ spans?: TracePhaseToggles;
78
101
  }
79
102
 
80
- function pushSpan(
81
- map: Map<string, OTelSpan[]>,
82
- key: string,
83
- span: OTelSpan,
84
- ): void {
85
- let stack = map.get(key);
86
- if (!stack) {
87
- stack = [];
88
- map.set(key, stack);
89
- }
90
- stack.push(span);
91
- }
103
+ /**
104
+ * Create the tracing config that maps the router's phases onto OTel spans via
105
+ * `tracer.startActiveSpan`, which runs the work inside the span's active context
106
+ * (so child spans nest) and returns the work's value unchanged. The span ends
107
+ * when that value — or, for async work, the returned promise — settles, matching
108
+ * observePhase's contract. When the work throws or rejects, the exception is
109
+ * recorded and the span status is set to ERROR before it ends, so a failed phase
110
+ * stays visible in the trace (the old createOTelSink error path is preserved here
111
+ * now that phase spans live in this adapter). Pass the result to
112
+ * `createRouter({ tracing })`.
113
+ *
114
+ * @see createCloudflareTracing (`@rangojs/router/cloudflare`) for the same slot
115
+ * using Cloudflare Workers native custom spans.
116
+ */
117
+ export function createOTelTracing(
118
+ tracer: OTelActiveSpanTracer,
119
+ options: OTelTracingOptions = {},
120
+ ): RouterTracingConfig {
121
+ const runner: SpanRunner = <T>(name: string, fn: (span: OTelSpan) => T): T =>
122
+ tracer.startActiveSpan(
123
+ name,
124
+ (span): T =>
125
+ runThenSettle(
126
+ () => fn(span),
127
+ (error) => {
128
+ // On failure record the exception + ERROR status before ending, so a
129
+ // failed phase stays visible in the trace.
130
+ if (error !== undefined) {
131
+ if (error instanceof Error) {
132
+ span.recordException(error);
133
+ span.setStatus({ code: STATUS_ERROR, message: error.message });
134
+ } else {
135
+ span.setStatus({ code: STATUS_ERROR });
136
+ }
137
+ }
138
+ span.end();
139
+ },
140
+ ),
141
+ );
92
142
 
93
- function popSpan(
94
- map: Map<string, OTelSpan[]>,
95
- key: string,
96
- ): OTelSpan | undefined {
97
- const stack = map.get(key);
98
- if (!stack || stack.length === 0) return undefined;
99
- const span = stack.pop()!;
100
- if (stack.length === 0) map.delete(key);
101
- return span;
143
+ return {
144
+ runner,
145
+ enabled: options.enabled ?? true,
146
+ spans: options.spans,
147
+ };
102
148
  }
103
149
 
104
150
  // ---------------------------------------------------------------------------
105
- // Adapter factory
151
+ // Telemetry sink: discrete-fact instant spans (the `telemetry` slot)
106
152
  // ---------------------------------------------------------------------------
107
153
 
108
154
  /**
109
- * Create a TelemetrySink that maps router lifecycle events to OTel spans.
155
+ * Create a TelemetrySink that maps the router's discrete-fact events to instant
156
+ * OTel spans. One span per fact; no duration spans.
157
+ *
158
+ * Fact mapping:
159
+ * - handler.error -> "rango.handler.error" (error)
160
+ * - cache.decision -> "rango.cache.decision"
161
+ * - revalidation.decision -> "rango.revalidation.decision"
162
+ * - request.timeout -> "rango.request.timeout" (error)
163
+ * - request.origin-rejected -> "rango.request.origin-rejected" (error)
110
164
  *
111
- * Span mapping:
112
- * - request.start / request.end / request.error → "rango.request" span
113
- * - loader.start / loader.end / loader.error → "rango.loader" span
114
- * - handler.error → "rango.handler.error" instant span
115
- * - cache.decision → "rango.cache.decision" instant span
116
- * - revalidation.decision → "rango.revalidation.decision" instant span
165
+ * Request and loader PHASE spans are intentionally NOT emitted here — they are
166
+ * owned by the tracing slot (createOTelTracing) so the two layers cannot produce
167
+ * duplicate rango.request / rango.loader spans. request.start/end and
168
+ * loader.start/end events are no-ops for this sink.
117
169
  *
118
170
  * Attributes use the `rango.*` namespace for router-specific data and
119
171
  * `http.method` / `http.route` for HTTP semantics.
120
172
  */
121
173
  export function createOTelSink(tracer: OTelTracer): TelemetrySink {
122
- const requestSpans = new Map<string, OTelSpan[]>();
123
- const loaderSpans = new Map<string, OTelSpan[]>();
174
+ const instant = (
175
+ name: string,
176
+ attributes: Record<string, string | number | boolean>,
177
+ ): OTelSpan => tracer.startSpan(name, { attributes });
124
178
 
125
179
  return {
126
180
  emit(event: TelemetryEvent): void {
127
181
  switch (event.type) {
128
- case "request.start": {
129
- const span = tracer.startSpan("rango.request", {
130
- attributes: {
131
- "http.method": event.method,
132
- "http.route": event.pathname,
133
- "rango.transaction": event.transaction,
134
- "rango.is_partial": event.isPartial,
135
- },
136
- });
137
- pushSpan(requestSpans, requestKey(event), span);
138
- break;
139
- }
140
-
141
- case "request.end": {
142
- const span = popSpan(requestSpans, requestKey(event));
143
- if (span) {
144
- span.setAttribute("rango.duration_ms", event.durationMs);
145
- span.setAttribute("rango.segment_count", event.segmentCount);
146
- span.setAttribute("rango.cache.hit", event.cacheHit);
147
- span.setStatus({ code: STATUS_OK });
148
- span.end();
149
- }
150
- break;
151
- }
152
-
153
- case "request.error": {
154
- const span = popSpan(requestSpans, requestKey(event));
155
- if (span) {
156
- span.setAttribute("rango.duration_ms", event.durationMs);
157
- span.setAttribute("rango.phase", event.phase);
158
- span.recordException(event.error);
159
- span.setStatus({
160
- code: STATUS_ERROR,
161
- message: event.error.message,
162
- });
163
- span.end();
164
- }
165
- break;
166
- }
167
-
168
- case "loader.start": {
169
- const span = tracer.startSpan("rango.loader", {
170
- attributes: {
171
- "rango.segment_id": event.segmentId,
172
- "rango.loader_name": event.loaderName,
173
- "http.route": event.pathname,
174
- },
175
- });
176
- pushSpan(loaderSpans, loaderKey(event), span);
177
- break;
178
- }
179
-
180
- case "loader.end": {
181
- const key = loaderKey(event);
182
- const span = popSpan(loaderSpans, key);
183
- if (span) {
184
- span.setAttribute("rango.duration_ms", event.durationMs);
185
- span.setAttribute("rango.loader.ok", event.ok);
186
- span.setStatus({ code: event.ok ? STATUS_OK : STATUS_ERROR });
187
- span.end();
188
- }
189
- break;
190
- }
191
-
192
- case "loader.error": {
193
- const key = loaderKey(event);
194
- const span = popSpan(loaderSpans, key);
195
- if (span) {
196
- span.setAttribute(
197
- "rango.handled_by_boundary",
198
- event.handledByBoundary,
199
- );
200
- span.recordException(event.error);
201
- span.setStatus({
202
- code: STATUS_ERROR,
203
- message: event.error.message,
204
- });
205
- span.end();
206
- } else {
207
- // No matching start — create a standalone error span
208
- const errorSpan = tracer.startSpan("rango.loader", {
209
- attributes: {
210
- "rango.segment_id": event.segmentId,
211
- "rango.loader_name": event.loaderName,
212
- "http.route": event.pathname,
213
- "rango.handled_by_boundary": event.handledByBoundary,
214
- },
215
- });
216
- errorSpan.recordException(event.error);
217
- errorSpan.setStatus({
218
- code: STATUS_ERROR,
219
- message: event.error.message,
220
- });
221
- errorSpan.end();
222
- }
223
- break;
224
- }
225
-
226
182
  case "handler.error": {
227
183
  const attrs: Record<string, string | number | boolean> = {
228
184
  "rango.handled_by_boundary": event.handledByBoundary,
@@ -232,13 +188,10 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
232
188
  attrs["rango.segment_type"] = event.segmentType;
233
189
  if (event.pathname) attrs["http.route"] = event.pathname;
234
190
  if (event.routeKey) attrs["rango.route_key"] = event.routeKey;
235
- if (event.params) {
191
+ if (event.params)
236
192
  attrs["rango.params"] = JSON.stringify(event.params);
237
- }
238
193
 
239
- const span = tracer.startSpan("rango.handler.error", {
240
- attributes: attrs,
241
- });
194
+ const span = instant("rango.handler.error", attrs);
242
195
  span.recordException(event.error);
243
196
  span.setStatus({ code: STATUS_ERROR, message: event.error.message });
244
197
  span.end();
@@ -253,26 +206,55 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
253
206
  "rango.cache.should_revalidate": event.shouldRevalidate,
254
207
  };
255
208
  if (event.source) attrs["rango.cache.source"] = event.source;
209
+ instant("rango.cache.decision", attrs).end();
210
+ break;
211
+ }
256
212
 
257
- const span = tracer.startSpan("rango.cache.decision", {
258
- attributes: attrs,
213
+ case "revalidation.decision": {
214
+ instant("rango.revalidation.decision", {
215
+ "rango.segment_id": event.segmentId,
216
+ "http.route": event.pathname,
217
+ "rango.route_key": event.routeKey,
218
+ "rango.revalidate": event.shouldRevalidate,
219
+ }).end();
220
+ break;
221
+ }
222
+
223
+ case "request.timeout": {
224
+ const attrs: Record<string, string | number | boolean> = {
225
+ "rango.phase": event.phase,
226
+ "http.route": event.pathname,
227
+ "rango.duration_ms": event.durationMs,
228
+ "rango.timeout.custom_handler": event.customHandler,
229
+ };
230
+ if (event.routeKey) attrs["rango.route_key"] = event.routeKey;
231
+ if (event.actionId) attrs["rango.action_id"] = event.actionId;
232
+ const span = instant("rango.request.timeout", attrs);
233
+ span.setStatus({
234
+ code: STATUS_ERROR,
235
+ message: `timeout: ${event.phase}`,
259
236
  });
260
237
  span.end();
261
238
  break;
262
239
  }
263
240
 
264
- case "revalidation.decision": {
265
- const span = tracer.startSpan("rango.revalidation.decision", {
266
- attributes: {
267
- "rango.segment_id": event.segmentId,
268
- "http.route": event.pathname,
269
- "rango.route_key": event.routeKey,
270
- "rango.revalidate": event.shouldRevalidate,
271
- },
272
- });
241
+ case "request.origin-rejected": {
242
+ const attrs: Record<string, string | number | boolean> = {
243
+ "http.method": event.method,
244
+ "http.route": event.pathname,
245
+ "rango.phase": event.phase,
246
+ };
247
+ if (event.origin) attrs["rango.origin"] = event.origin;
248
+ if (event.host) attrs["http.host"] = event.host;
249
+ const span = instant("rango.request.origin-rejected", attrs);
250
+ span.setStatus({ code: STATUS_ERROR, message: "origin rejected" });
273
251
  span.end();
274
252
  break;
275
253
  }
254
+
255
+ // request.start/end/error and loader.start/end/error are phase events;
256
+ // their spans are owned by the tracing slot (createOTelTracing), so this
257
+ // sink ignores them to avoid duplicate rango.request / rango.loader spans.
276
258
  }
277
259
  },
278
260
  };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Span tracing hook (platform-agnostic).
3
+ *
4
+ * The core router emits its existing performance phases (request, middleware, action,
5
+ * loaders, render, ssr) as spans by calling traceSpan() at a small set of
6
+ * execution boundaries. When no tracing is configured the call is a direct
7
+ * pass-through: fn is invoked with a no-op span, with no wrapper and no
8
+ * allocation, so a non-traced request behaves exactly as before.
9
+ *
10
+ * A platform integration supplies a SpanRunner that wraps fn in a real span.
11
+ * Two runners ship: the Cloudflare one (createCloudflareTracing in
12
+ * src/cloudflare/tracing.ts), which bridges onto executionContext.tracing.
13
+ * enterSpan, and the OTel one (createOTelTracing in router/telemetry-otel.ts),
14
+ * which bridges onto tracer.startActiveSpan. Both wrap the actual work — not a
15
+ * post-hoc event — so spans nest by async context and the platform's automatic
16
+ * spans (KV/D1/fetch) nest under the right phase.
17
+ *
18
+ * traceSpan() below is the low-level wrap primitive. It is INTERNAL: the only
19
+ * caller is observePhase() (instrument.ts), the single phase-instrumentation
20
+ * API, which co-emits the span AND the debugPerformance perf metric from one
21
+ * wrap site (or just the span, for metric:false phases) so the two surfaces
22
+ * can't drift. Every router phase routes through observePhase via the PHASES
23
+ * registry; do not call traceSpan directly from new code.
24
+ *
25
+ * Phase coverage (all via observePhase): rango.request (span-only; handler:total
26
+ * metered directly), rango.middleware (span-only incl. intercept middleware;
27
+ * pre/post metered directly), rango.action (action:<id>; server-action
28
+ * execution, JS + no-JS/PE), rango.loader (loader:<id>; single metering site at
29
+ * useLoader, plus the fetchable path), rango.render (render:total:<route>; normal AND
30
+ * action-revalidation renders), rango.ssr (ssr:render-html).
31
+ *
32
+ * Streaming-phase span lifetime: a span ends when its callback's value (or
33
+ * promise) settles. The streaming phases (request, middleware, render, ssr) are
34
+ * wrapped by observeRequestPhase / observeStreamingPhase (instrument.ts), whose
35
+ * callback hands the constructed Response/stream to the caller immediately
36
+ * (streaming preserved) but then awaits a request-scoped drain barrier, so the
37
+ * SPAN ends when the response BODY finishes draining, not at stream construction.
38
+ * That keeps span durations covering the real streamed work and the trace tree
39
+ * valid: a loader/Suspense child that resolves while the body streams ends before
40
+ * its now-drain-bound parent. The co-emitted perf METRIC (render:total, …) is
41
+ * still recorded at construction — it ships in the Server-Timing header, flushed
42
+ * before the body drains — so a streaming span legitimately reads longer than its
43
+ * same-named metric by the time the body spent streaming after construction.
44
+ *
45
+ * Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
46
+ * platform-specific bridge lives at the edge behind the SpanRunner contract.
47
+ */
48
+
49
+ /**
50
+ * Minimal span handle passed to traced work. Structurally compatible with both
51
+ * Cloudflare's `Span` and OTel's `Span` (only setAttribute is used here).
52
+ */
53
+ export interface TraceSpan {
54
+ setAttribute(key: string, value: string | number | boolean): void;
55
+ }
56
+
57
+ /**
58
+ * Wraps a unit of work in a span. A runner MUST invoke fn exactly once, pass it
59
+ * a span, return fn's result unchanged, and propagate thrown errors / rejected
60
+ * promises unchanged. When fn returns a promise the span ends once it settles.
61
+ */
62
+ export type SpanRunner = <T>(name: string, fn: (span: TraceSpan) => T) => T;
63
+
64
+ /** The router phases that can be wrapped in a span. */
65
+ export type TracePhase =
66
+ | "request"
67
+ | "middleware"
68
+ | "action"
69
+ | "loader"
70
+ | "render"
71
+ | "ssr";
72
+
73
+ /** Per-phase span toggles. Omitted phases default to enabled. */
74
+ export interface TracePhaseToggles {
75
+ request?: boolean;
76
+ middleware?: boolean;
77
+ action?: boolean;
78
+ loader?: boolean;
79
+ render?: boolean;
80
+ ssr?: boolean;
81
+ }
82
+
83
+ /**
84
+ * Value passed to `createRouter({ tracing })`. Produced by a platform factory
85
+ * such as `createCloudflareTracing()`.
86
+ */
87
+ export interface RouterTracingConfig {
88
+ /** Platform span runner. */
89
+ runner: SpanRunner;
90
+ /** Master switch. Defaults to true when a config object is provided. */
91
+ enabled?: boolean;
92
+ /** Per-phase span toggles. */
93
+ spans?: TracePhaseToggles;
94
+ }
95
+
96
+ /**
97
+ * Resolved tracing state stored on the router/request context. `undefined`
98
+ * means tracing is fully disabled and every traceSpan() call is a pass-through.
99
+ */
100
+ export interface ResolvedTracing {
101
+ runner: SpanRunner;
102
+ phases: Record<TracePhase, boolean>;
103
+ }
104
+
105
+ /** Shared no-op span. setAttribute is a no-op so disabled call sites stay free. */
106
+ export const NOOP_TRACE_SPAN: TraceSpan = {
107
+ setAttribute() {},
108
+ };
109
+
110
+ const ALL_PHASES_ON: Record<TracePhase, boolean> = {
111
+ request: true,
112
+ middleware: true,
113
+ action: true,
114
+ loader: true,
115
+ render: true,
116
+ ssr: true,
117
+ };
118
+
119
+ /**
120
+ * Resolve a user-supplied tracing config into the fast internal form, or
121
+ * `undefined` when tracing is off (no config, `enabled: false`, or no runner).
122
+ */
123
+ export function resolveTracing(
124
+ config: RouterTracingConfig | undefined,
125
+ ): ResolvedTracing | undefined {
126
+ if (
127
+ !config ||
128
+ config.enabled === false ||
129
+ typeof config.runner !== "function"
130
+ ) {
131
+ return undefined;
132
+ }
133
+ const spans = config.spans;
134
+ return {
135
+ runner: config.runner,
136
+ phases: spans
137
+ ? {
138
+ request: spans.request ?? true,
139
+ middleware: spans.middleware ?? true,
140
+ action: spans.action ?? true,
141
+ loader: spans.loader ?? true,
142
+ render: spans.render ?? true,
143
+ ssr: spans.ssr ?? true,
144
+ }
145
+ : ALL_PHASES_ON,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Wrap `fn` in a span for `phase`. When tracing is off (or the phase is
151
+ * disabled) fn runs directly with a no-op span — identical to the untraced
152
+ * path. Otherwise the platform runner wraps fn so the span covers the real
153
+ * work and nests by async context.
154
+ */
155
+ export function traceSpan<T>(
156
+ tracing: ResolvedTracing | undefined,
157
+ phase: TracePhase,
158
+ name: string,
159
+ fn: (span: TraceSpan) => T,
160
+ ): T {
161
+ if (tracing === undefined || tracing.phases[phase] === false) {
162
+ return fn(NOOP_TRACE_SPAN);
163
+ }
164
+ return tracing.runner(name, fn);
165
+ }
166
+
167
+ /**
168
+ * Run `fn` once and invoke `onSettle` exactly once when it terminates — on a
169
+ * synchronous return, a synchronous throw, an async resolution, or an async
170
+ * rejection. `onSettle` receives the error (or `undefined` on success). fn's
171
+ * value is returned and errors propagate unchanged.
172
+ *
173
+ * Centralizes the run-once-then-settle control flow shared by the two span
174
+ * surfaces: observePhase records the perf metric on settle, and the OTel runner
175
+ * ends (or error-marks) the span on settle. The Cloudflare runner delegates
176
+ * settling to enterSpan, so it does not use this.
177
+ */
178
+ export function runThenSettle<T>(
179
+ fn: () => T,
180
+ onSettle: (error: unknown) => void,
181
+ ): T {
182
+ let out: T;
183
+ try {
184
+ out = fn();
185
+ } catch (error) {
186
+ onSettle(error);
187
+ throw error;
188
+ }
189
+ if (out instanceof Promise) {
190
+ return out.then(
191
+ (value) => {
192
+ onSettle(undefined);
193
+ return value;
194
+ },
195
+ (error) => {
196
+ onSettle(error);
197
+ throw error;
198
+ },
199
+ ) as unknown as T;
200
+ }
201
+ onSettle(undefined);
202
+ return out;
203
+ }
package/src/router.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  traverseBack,
74
74
  } from "./router/pattern-matching.js";
75
75
  import { resolveSink, safeEmit, getRequestId } from "./router/telemetry.js";
76
+ import { resolveTracing } from "./router/tracing.js";
76
77
  import { evaluateRevalidation } from "./router/revalidation.js";
77
78
  import {
78
79
  type RouterContext,
@@ -152,6 +153,7 @@ export function createRouter<TEnv = any>(
152
153
  warmup: warmupOption,
153
154
  allowDebugManifest: allowDebugManifestOption = false,
154
155
  telemetry: telemetrySink,
156
+ tracing: tracingOption,
155
157
  ssr: ssrOption,
156
158
  timeout: timeoutShorthand,
157
159
  timeouts: timeoutsOption,
@@ -178,6 +180,10 @@ export function createRouter<TEnv = any>(
178
180
  // Resolve telemetry sink (no-op when not configured)
179
181
  const telemetry = resolveSink(telemetrySink);
180
182
 
183
+ // Resolve span tracing (undefined when not configured; every traceSpan() call
184
+ // is then a direct pass-through with zero behavior change).
185
+ const resolvedTracing = resolveTracing(tracingOption);
186
+
181
187
  // Resolve cache profiles: merge user config with the guaranteed default
182
188
  // profile. This resolved map is threaded onto each request context; the
183
189
  // "use cache: <profile>" runtime path reads it request-scoped.
@@ -968,6 +974,9 @@ export function createRouter<TEnv = any>(
968
974
  // Expose router-wide performance debugging for request-level metrics setup
969
975
  debugPerformance,
970
976
 
977
+ // Expose resolved span tracing for the handler (Cloudflare custom spans)
978
+ tracing: resolvedTracing,
979
+
971
980
  // Expose debug manifest flag for handler
972
981
  allowDebugManifest: allowDebugManifestOption,
973
982