@rangojs/router 0.0.0-experimental.127 → 0.0.0-experimental.129

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.
@@ -2133,7 +2133,7 @@ import { resolve } from "node:path";
2133
2133
  // package.json
2134
2134
  var package_default = {
2135
2135
  name: "@rangojs/router",
2136
- version: "0.0.0-experimental.127",
2136
+ version: "0.0.0-experimental.129",
2137
2137
  description: "Django-inspired RSC router with composable URL patterns",
2138
2138
  keywords: [
2139
2139
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.127",
3
+ "version": "0.0.0-experimental.129",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -95,6 +95,31 @@ const router = createRouter({
95
95
  });
96
96
  ```
97
97
 
98
+ On **Cloudflare Workers**, use `createCloudflareTracing` for the `tracing` slot
99
+ instead — it emits the same phases as native Cloudflare custom spans (in the
100
+ Workers trace waterfall, next to the automatic KV/D1/fetch spans), with no
101
+ `@opentelemetry/api` dependency:
102
+
103
+ ```typescript
104
+ import { createRouter } from "@rangojs/router";
105
+ import { createCloudflareTracing } from "@rangojs/router/cloudflare";
106
+
107
+ const router = createRouter({
108
+ document: Document,
109
+ urls: urlpatterns,
110
+ tracing: createCloudflareTracing(), // all phases on by default
111
+ // tracing: createCloudflareTracing({ spans: { ssr: false } }), // toggle phases
112
+ });
113
+ ```
114
+
115
+ Both factories return a `RouterTracingConfig` for the same `tracing` slot;
116
+ `telemetry` stays independent (events only, no phase spans). Phase spans:
117
+ `rango.request`, `rango.middleware`, `rango.action`, `rango.loader`,
118
+ `rango.render`, `rango.ssr` — the same phases the `debugPerformance` timeline
119
+ shows, co-emitted from one site. Off-platform (no Cloudflare tracing destination
120
+ / no OTel SDK) every span call is a transparent pass-through, so the request
121
+ behaves as if tracing were off.
122
+
98
123
  Custom sinks implement `emit(event)`:
99
124
 
100
125
  ```typescript
@@ -112,7 +137,8 @@ const router = createRouter({
112
137
  ```
113
138
 
114
139
  Events include `request.start/end/error`, `loader.start/end/error`,
115
- `handler.error`, `cache.decision`, and `revalidation.decision`.
140
+ `handler.error`, `cache.decision`, `revalidation.decision`, `request.timeout`,
141
+ and `request.origin-rejected`.
116
142
 
117
143
  ## Debugging revalidation and stale data
118
144
 
@@ -488,6 +488,18 @@ const router = createRouter({
488
488
  });
489
489
  ```
490
490
 
491
+ ```typescript
492
+ // On Cloudflare Workers, swap the tracing factory for native custom spans
493
+ // (no @opentelemetry/api dependency); the telemetry slot is unchanged.
494
+ import { createCloudflareTracing } from "@rangojs/router/cloudflare";
495
+
496
+ const router = createRouter({
497
+ document: Document,
498
+ urls: urlpatterns,
499
+ tracing: createCloudflareTracing(), // { spans: { ssr: false } } to toggle phases
500
+ });
501
+ ```
502
+
491
503
  ```typescript
492
504
  // Custom sink
493
505
  const router = createRouter({
@@ -2,9 +2,9 @@
2
2
  * Cloudflare custom-spans integration.
3
3
  *
4
4
  * Bridges the router's performance phases (request, middleware, action,
5
- * loaders, render, ssr) onto Cloudflare Workers custom spans so they show up in the
6
- * trace waterfall and OpenTelemetry exports next to the platform's automatic
7
- * spans (KV reads, D1 queries, fetch calls), with correct parent-child nesting.
5
+ * loaders, handler, render, ssr) onto Cloudflare Workers custom spans so they show
6
+ * up in the trace waterfall and OpenTelemetry exports next to the platform's
7
+ * automatic spans (KV reads, D1 queries, fetch calls), with correct nesting.
8
8
  *
9
9
  * Usage (Cloudflare preset only):
10
10
  *
@@ -25,14 +25,17 @@
25
25
  * request behaves exactly as if tracing were off. Whether spans are actually
26
26
  * recorded is governed by the `observability`/tracing block in wrangler config.
27
27
  *
28
- * Span duration note: a phase span ends when its callback's returned value (or
29
- * promise) settles, which for the streaming phases (request/render/ssr) is when
30
- * the Response or HTML/RSC stream is *constructed*, not when the body finishes
31
- * draining. Loader/Suspense work that settles while the body streams therefore
32
- * extends past the parent span's end, and platform spans emitted during that
33
- * drain (deferred SSR, waitUntil-scheduled cache writes) are siblings of the
34
- * automatic request span rather than children of rango.*. Phase spans bound
35
- * setup-to-stream-handoff; they are not a full request-duration measure.
28
+ * Span duration note: enterSpan ends a span when its callback's returned value
29
+ * (or promise) settles. The streaming phases (request/render/ssr/middleware) are
30
+ * wrapped (in instrument.ts) so their callback awaits the response body's drain
31
+ * before settling the constructed Response is handed to the client immediately,
32
+ * but the callback (hence the SPAN) stays open until the body finishes draining.
33
+ * So these spans cover the full streamed request and loader/Suspense children
34
+ * that resolve mid-stream nest under a still-open parent. This uses only the
35
+ * typed enterSpan API; no startActiveSpan/end. The perf METRICS (render:total,
36
+ * the middleware own-time, handler:total) stay construction-bound — they ship in
37
+ * the Server-Timing header, flushed before drain — so a span reads at least as
38
+ * long as its same-named metric, the difference being post-construction streaming.
36
39
  */
37
40
 
38
41
  import { _getRequestContext } from "../server/request-context.js";
@@ -92,8 +95,8 @@ const cloudflareSpanRunner: SpanRunner = (name, fn) => {
92
95
  /**
93
96
  * Create the tracing config for a Cloudflare router. Pass the result to
94
97
  * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
95
- * action, loaders, render, and ssr phases; pass `spans` to turn individual
96
- * phases off.
98
+ * action, loaders, handler, render, and ssr phases; pass `spans` to turn
99
+ * individual phases off.
97
100
  *
98
101
  * @see createOTelTracing (`@rangojs/router`) for the same slot on any platform
99
102
  * with an OpenTelemetry SDK.
@@ -35,13 +35,16 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
35
35
  import { getRouterContext } from "./router-context.js";
36
36
  import { resolveSink, safeEmit, type TelemetryEvent } from "./telemetry.js";
37
37
  import { appendMetric } from "./metrics.js";
38
+ import { type MetricsStore } from "../server/context.js";
38
39
  import {
39
40
  NOOP_TRACE_SPAN,
40
41
  traceSpan,
41
42
  runThenSettle,
42
43
  type TracePhase,
43
44
  type TraceSpan,
45
+ type ResolvedTracing,
44
46
  } from "./tracing.js";
47
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
45
48
 
46
49
  /**
47
50
  * Perf-metric boundary for a phase, or `false` for span-only. `false` means the
@@ -63,6 +66,25 @@ export interface PhaseSpec {
63
66
  spanName: string;
64
67
  /** Span attributes set automatically when the span opens. */
65
68
  attributes?: Record<string, string | number | boolean>;
69
+ /**
70
+ * Span attributes resolved AFTER the wrapped work runs (so they can read state
71
+ * that only exists once the work is underway, e.g. the matched route name).
72
+ * Applied for streaming phases once fn has constructed its value. Return
73
+ * undefined to add nothing.
74
+ */
75
+ lazyAttributes?: () => Record<string, string | number | boolean> | undefined;
76
+ }
77
+
78
+ /**
79
+ * The matched route name for the current request, or undefined when there is no
80
+ * named route (unmatched / auto-generated). Shared by the render phase's metric
81
+ * label and its rango.route span attribute so the two can't disagree.
82
+ */
83
+ function currentRouteName(): string | undefined {
84
+ const routeName = _getRequestContext()?._routeName;
85
+ return routeName && !isAutoGeneratedRouteName(routeName)
86
+ ? routeName
87
+ : undefined;
66
88
  }
67
89
 
68
90
  /**
@@ -112,6 +134,18 @@ export const PHASES = {
112
134
  attributes: { "rango.loader_id": id },
113
135
  }),
114
136
 
137
+ /** One segment route/layout handler execution (the component/handler that
138
+ * produces a segment). Span only — the perf metric (handler:<id>) is owned by
139
+ * the legacy track() at the same call site, so observePhase here adds the
140
+ * rango.handler span without double-recording. `id` is the segment id, carried
141
+ * as the rango.segment_id attribute to match the handler:<id> perf row. */
142
+ handler: (id: string): PhaseSpec => ({
143
+ metric: false,
144
+ tracePhase: "handler",
145
+ spanName: "rango.handler",
146
+ attributes: { "rango.segment_id": id },
147
+ }),
148
+
115
149
  /** Whole render phase: match + serialize + SSR. The metric label is resolved
116
150
  * lazily at record time (after match has set the route name) so the perf
117
151
  * timeline shows WHICH route rendered: `render:total:<routeName>`, falling back
@@ -119,14 +153,20 @@ export const PHASES = {
119
153
  render: {
120
154
  metric: {
121
155
  label: () => {
122
- const routeName = _getRequestContext()?._routeName;
123
- return routeName && !isAutoGeneratedRouteName(routeName)
124
- ? `render:total:${routeName}`
125
- : "render:total";
156
+ const routeName = currentRouteName();
157
+ return routeName ? `render:total:${routeName}` : "render:total";
126
158
  },
127
159
  },
128
160
  tracePhase: "render",
129
161
  spanName: "rango.render",
162
+ // Tag the render span with the matched route so the Cloudflare/OTel waterfall
163
+ // shows WHICH route rendered (rango.render + rango.route=index), resolved
164
+ // after match has run. Kept an attribute (not baked into the span name) so the
165
+ // span name stays low-cardinality and aggregatable across routes.
166
+ lazyAttributes: () => {
167
+ const routeName = currentRouteName();
168
+ return routeName ? { "rango.route": routeName } : undefined;
169
+ },
130
170
  } as PhaseSpec,
131
171
 
132
172
  /** SSR HTML render from the RSC stream. Colon-delimited like the other ssr:*
@@ -138,6 +178,29 @@ export const PHASES = {
138
178
  } as PhaseSpec,
139
179
  } as const;
140
180
 
181
+ /** Apply a phase spec's static attributes to a span (the no-op span ignores them). */
182
+ function applyAttributes(
183
+ span: TraceSpan,
184
+ attributes: Record<string, string | number | boolean>,
185
+ ): void {
186
+ for (const key in attributes) span.setAttribute(key, attributes[key]);
187
+ }
188
+
189
+ /**
190
+ * Record a phase's perf metric for the interval [start, now]. The label may be
191
+ * lazy (resolved here, e.g. render:total needs the route name that match sets
192
+ * partway through the wrapped work).
193
+ */
194
+ function recordPhaseMetric(
195
+ store: MetricsStore,
196
+ metric: Exclude<PhaseMetric, false>,
197
+ start: number,
198
+ ): void {
199
+ const label =
200
+ typeof metric.label === "function" ? metric.label() : metric.label;
201
+ appendMetric(store, label, start, performance.now() - start, metric.depth);
202
+ }
203
+
141
204
  /**
142
205
  * Instrument one unit of work: open its span AND (unless `metric: false`) record
143
206
  * its perf metric, from a single wrap site. fn is invoked exactly once with the
@@ -145,6 +208,12 @@ export const PHASES = {
145
208
  * unchanged and thrown errors / rejected promises propagate unchanged. When fn
146
209
  * returns a promise both the metric duration and the span end when it settles.
147
210
  *
211
+ * This is the boundary for NON-streaming phases (action, loader): both the span
212
+ * and the metric settle when their own work completes. Streaming phases (request,
213
+ * middleware, render, ssr) use observeRequestPhase / observeStreamingPhase, where
214
+ * the SPAN is held open until body-drain (valid tree) while the perf metric is
215
+ * still recorded at construction (Server-Timing parity).
216
+ *
148
217
  * Reads the metrics store + tracing off the RequestContext ALS, which is active
149
218
  * for the WHOLE request — contrast observeEvent, which reads the RouterContext
150
219
  * ALS (entered later, during match).
@@ -166,7 +235,7 @@ export function observePhase<T>(
166
235
  const wrapped: (span: TraceSpan) => T =
167
236
  attributes && tracing
168
237
  ? (span) => {
169
- for (const key in attributes) span.setAttribute(key, attributes[key]);
238
+ applyAttributes(span, attributes);
170
239
  return fn(span);
171
240
  }
172
241
  : fn;
@@ -182,13 +251,196 @@ export function observePhase<T>(
182
251
  // failed loader/render still shows its timing in the perf report (parity with
183
252
  // the old track().finally() path it replaced).
184
253
  const start = performance.now();
185
- return runThenSettle(runSpan, () => {
186
- // The label may be lazy (resolved at record time, e.g. render:total needs the
187
- // route name that match sets partway through the wrapped work).
188
- const label =
189
- typeof metric.label === "function" ? metric.label() : metric.label;
190
- appendMetric(store, label, start, performance.now() - start, metric.depth);
254
+ return runThenSettle(runSpan, () => recordPhaseMetric(store, metric, start));
255
+ }
256
+
257
+ /**
258
+ * Re-stream `response`'s body through a pass-through that fires `onDrain` exactly
259
+ * once when the body finishes — on natural end, a stream error, or a client
260
+ * cancel (so a span can never leak on an aborted response). A bodyless response
261
+ * fires immediately. Only used while instrumentation is active, so the per-chunk
262
+ * relay cost never touches an untraced request.
263
+ */
264
+ function instrumentResponseDrain(
265
+ response: Response,
266
+ onDrain: () => void,
267
+ ): Response {
268
+ // WS-upgrade responses (status 101 / workerd `webSocket` property) must never
269
+ // be reconstructed: `new Response(body, { status: 101 })` throws and a copy
270
+ // drops the non-standard webSocket handoff (the invariant every other Response
271
+ // reconstruction site honors). A bodyless response has nothing to drain.
272
+ const source = response.body;
273
+ if (!source || isWebSocketUpgradeResponse(response)) {
274
+ onDrain();
275
+ return response;
276
+ }
277
+ let fired = false;
278
+ const fire = (): void => {
279
+ if (fired) return;
280
+ fired = true;
281
+ onDrain();
282
+ };
283
+ const reader = source.getReader();
284
+ const wrapped = new ReadableStream<Uint8Array>({
285
+ async pull(controller) {
286
+ try {
287
+ const { done, value } = await reader.read();
288
+ if (done) {
289
+ controller.close();
290
+ fire();
291
+ return;
292
+ }
293
+ controller.enqueue(value);
294
+ } catch (error) {
295
+ controller.error(error);
296
+ fire();
297
+ }
298
+ },
299
+ cancel(reason) {
300
+ fire();
301
+ return reader.cancel(reason);
302
+ },
191
303
  });
304
+ return new Response(wrapped, response);
305
+ }
306
+
307
+ /**
308
+ * Shared engine for the streaming phases (request, middleware, render, ssr). It
309
+ * opens the span, runs fn, records the phase's perf metric at CONSTRUCTION (so it
310
+ * still reaches the Server-Timing header / [RSC Perf] table, both built before
311
+ * the body drains), hands the constructed value to the caller via a side channel
312
+ * (streaming preserved), then holds the span open until `drain` resolves. The
313
+ * SPAN therefore ends at body-drain — keeping the trace tree valid (a loader
314
+ * child that resolves mid-stream ends before its parent) — while the perf metric
315
+ * stays the construction work-time. `onDeliver` lets the request phase instrument
316
+ * the final body before handing it back; `onError` lets it release the barrier on
317
+ * failure. Fire-and-forget: the value reaches the caller via the returned
318
+ * promise, so the span promise's rejection is swallowed (already surfaced there).
319
+ */
320
+ function runDrainBoundPhase<R>(
321
+ spec: PhaseSpec,
322
+ fn: (span: TraceSpan) => R | Promise<R>,
323
+ tracing: ResolvedTracing | undefined,
324
+ store: MetricsStore | undefined,
325
+ drain: Promise<void>,
326
+ onDeliver: (value: R) => R,
327
+ onError?: () => void,
328
+ ): Promise<R> {
329
+ let deliver!: (value: R) => void;
330
+ let reject!: (error: unknown) => void;
331
+ const delivered = new Promise<R>((res, rej) => {
332
+ deliver = res;
333
+ reject = rej;
334
+ });
335
+
336
+ const start = performance.now();
337
+ const attributes = spec.attributes;
338
+ const metric = spec.metric;
339
+ const record = (): void => {
340
+ if (store && metric !== false) recordPhaseMetric(store, metric, start);
341
+ };
342
+ const spanCallback = async (span: TraceSpan): Promise<void> => {
343
+ if (attributes && tracing) applyAttributes(span, attributes);
344
+ let value: R;
345
+ try {
346
+ value = await fn(span);
347
+ } catch (error) {
348
+ record(); // a failed phase still shows its (construction) timing
349
+ onError?.();
350
+ reject(error);
351
+ throw error; // settle the span with the error, at construction
352
+ }
353
+ // Late attributes (e.g. rango.route) — resolved now that the work has run,
354
+ // so they can read state like the matched route name that match sets midway.
355
+ const lazy =
356
+ tracing && spec.lazyAttributes ? spec.lazyAttributes() : undefined;
357
+ if (lazy) applyAttributes(span, lazy);
358
+ record(); // construction-bound metric, before the response/header is built
359
+ deliver(onDeliver(value));
360
+ await drain; // hold the span open until the response body drains
361
+ };
362
+
363
+ traceSpan(tracing, spec.tracePhase, spec.spanName, spanCallback).catch(
364
+ () => {},
365
+ );
366
+ return delivered;
367
+ }
368
+
369
+ /**
370
+ * The request phase (rango.request, metric:false). Owns the drain barrier: it
371
+ * runs fn to construct the final Response, instruments that Response's body so
372
+ * the barrier resolves at drain, hands the Response to the caller immediately
373
+ * (streaming preserved), and holds the span open until the body drains. Every
374
+ * streaming inner phase awaits the same barrier (via observeStreamingPhase), so
375
+ * the request/middleware/render/ssr chain ends at body-drain together and the
376
+ * trace tree is valid (no child span outlives its parent). The perf metrics
377
+ * (render:total, …) are recorded at construction so they still reach the
378
+ * Server-Timing header; only the SPANS are drain-bound. ctx.waitUntil holds the
379
+ * worker alive until drain so the span end runs. Pass-through when no surface is
380
+ * active.
381
+ */
382
+ export function observeRequestPhase(
383
+ spec: PhaseSpec,
384
+ fn: (span: TraceSpan) => Promise<Response>,
385
+ ): Promise<Response> {
386
+ const reqCtx = _getRequestContext();
387
+ const store = reqCtx?._metricsStore;
388
+ const tracing = reqCtx?._tracing;
389
+
390
+ if ((!store && !tracing) || !reqCtx) return fn(NOOP_TRACE_SPAN);
391
+
392
+ let resolveDrain!: () => void;
393
+ const finalDrain = new Promise<void>((resolve) => {
394
+ resolveDrain = resolve;
395
+ });
396
+ reqCtx._finalDrain = finalDrain;
397
+
398
+ // Keep the worker alive until the body drains, so the drain-bound span end
399
+ // (and the inner phases' settle) runs before the runtime can reclaim it.
400
+ const ec = reqCtx.executionContext;
401
+ if (typeof ec?.waitUntil === "function") ec.waitUntil(finalDrain);
402
+
403
+ return runDrainBoundPhase<Response>(
404
+ spec,
405
+ fn,
406
+ tracing,
407
+ store,
408
+ finalDrain,
409
+ (response) => instrumentResponseDrain(response, resolveDrain),
410
+ resolveDrain, // release the barrier if fn fails before constructing a body
411
+ );
412
+ }
413
+
414
+ /**
415
+ * A streaming inner phase (rango.middleware / render / ssr). Its SPAN settles
416
+ * when the request's final response body drains (the barrier owned by
417
+ * observeRequestPhase), not when fn returns the constructed stream — so
418
+ * loader/Suspense children that resolve mid-stream nest under a still-open
419
+ * parent. fn's result is delivered at construction (streaming preserved) and the
420
+ * perf metric is recorded at construction (Server-Timing parity). Falls back to
421
+ * observePhase (construction-bound span) when there is no barrier — a
422
+ * non-streaming request, or instrumentation off.
423
+ */
424
+ export function observeStreamingPhase<R>(
425
+ spec: PhaseSpec,
426
+ fn: (span: TraceSpan) => R | Promise<R>,
427
+ ): Promise<R> {
428
+ const reqCtx = _getRequestContext();
429
+ const store = reqCtx?._metricsStore;
430
+ const tracing = reqCtx?._tracing;
431
+ const finalDrain = reqCtx?._finalDrain;
432
+
433
+ if ((!store && !tracing) || !finalDrain) {
434
+ return Promise.resolve(observePhase(spec, fn));
435
+ }
436
+ return runDrainBoundPhase<R>(
437
+ spec,
438
+ fn,
439
+ tracing,
440
+ store,
441
+ finalDrain,
442
+ (value) => value,
443
+ );
192
444
  }
193
445
 
194
446
  /**
@@ -19,7 +19,7 @@ import {
19
19
  } from "../redirect-origin.js";
20
20
  import { isAutoGeneratedRouteName } from "../route-name.js";
21
21
  import { appendMetric, createMetricsStore } from "./metrics.js";
22
- import { observePhase, PHASES } from "./instrument.js";
22
+ import { observeStreamingPhase, PHASES } from "./instrument.js";
23
23
  import { stripInternalParams } from "./handler-context.js";
24
24
  import { isWebSocketUpgradeResponse } from "../response-utils.js";
25
25
 
@@ -487,7 +487,7 @@ export async function executeMiddleware<TEnv>(
487
487
  // when neither surface is active.
488
488
  let result: Response | void;
489
489
  try {
490
- result = await observePhase(PHASES.middleware(metricLabel), () =>
490
+ result = await observeStreamingPhase(PHASES.middleware(metricLabel), () =>
491
491
  entry.handler(ctx, wrappedNext),
492
492
  );
493
493
  } catch (error) {
@@ -664,7 +664,7 @@ export async function executeInterceptMiddleware<TEnv>(
664
664
 
665
665
  let result: Response | void;
666
666
  try {
667
- result = await observePhase(PHASES.middleware(label), () =>
667
+ result = await observeStreamingPhase(PHASES.middleware(label), () =>
668
668
  middleware(ctx, guardedNext),
669
669
  );
670
670
  } catch (error) {
@@ -30,6 +30,7 @@ import {
30
30
  import { applyViewTransitionDefault } from "./view-transition-default.js";
31
31
  import { getRouterContext } from "../router-context.js";
32
32
  import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
33
+ import { observePhase, PHASES } from "../instrument.js";
33
34
  import {
34
35
  track,
35
36
  RangoContext,
@@ -258,7 +259,9 @@ export async function resolveSegment<TEnv>(
258
259
  !context.build && entry.liveHandler ? entry.liveHandler : entry.handler;
259
260
  const doneRouteHandler = track(`handler:${entry.id}`, 2);
260
261
  if (entry.loading) {
261
- const result = handleHandlerResult(handler(context));
262
+ const result = handleHandlerResult(
263
+ observePhase(PHASES.handler(entry.id), () => handler(context)),
264
+ );
262
265
  if (result instanceof Promise) {
263
266
  warnOnStreamedResponse(result, entry.id);
264
267
  result.finally(doneRouteHandler).catch(() => {});
@@ -280,7 +283,9 @@ export async function resolveSegment<TEnv>(
280
283
  component = result;
281
284
  }
282
285
  } else {
283
- component = handleHandlerResult(await handler(context));
286
+ component = handleHandlerResult(
287
+ await observePhase(PHASES.handler(entry.id), () => handler(context)),
288
+ );
284
289
  doneRouteHandler();
285
290
  }
286
291
  }
@@ -505,7 +510,11 @@ export async function resolveParallelEntry<TEnv>(
505
510
  parallelEntry.loading !== undefined && parallelEntry.loading !== false;
506
511
  if (hasLoadingFallback) {
507
512
  const result =
508
- typeof handler === "function" ? handler(context) : handler;
513
+ typeof handler === "function"
514
+ ? observePhase(PHASES.handler(`${parallelEntry.id}.${slot}`), () =>
515
+ handler(context),
516
+ )
517
+ : handler;
509
518
  if (result instanceof Promise) {
510
519
  result.finally(doneParallelHandler).catch(() => {});
511
520
  const tracked = deps.trackHandler(result, {
@@ -527,7 +536,12 @@ export async function resolveParallelEntry<TEnv>(
527
536
  }
528
537
  } else {
529
538
  component =
530
- typeof handler === "function" ? await handler(context) : handler;
539
+ typeof handler === "function"
540
+ ? await observePhase(
541
+ PHASES.handler(`${parallelEntry.id}.${slot}`),
542
+ () => handler(context),
543
+ )
544
+ : handler;
531
545
  doneParallelHandler();
532
546
  }
533
547
  }
@@ -23,6 +23,7 @@ import type { ResolvedSegment, ErrorInfo, HandlerContext } from "../../types";
23
23
  import type { SegmentResolutionDeps } from "../types.js";
24
24
  import { debugLog } from "../logging.js";
25
25
  import { tryStaticLookup } from "./static-store.js";
26
+ import { observePhase, PHASES } from "../instrument.js";
26
27
  import type { TelemetrySink } from "../telemetry.js";
27
28
  import { resolveSink, safeEmit, getRequestId } from "../telemetry.js";
28
29
 
@@ -130,11 +131,15 @@ export async function resolveLayoutComponent<TEnv>(
130
131
  entry: EntryData,
131
132
  context: HandlerContext<any, TEnv>,
132
133
  ): Promise<ReactNode> {
133
- const component = await tryStaticHandler(entry, entry.shortCode);
134
- if (component !== undefined) return component;
135
- return typeof entry.handler === "function"
136
- ? handleHandlerResult(await entry.handler(context))
137
- : (entry.handler as ReactNode);
134
+ // rango.handler span for this layout/cache handler (the perf metric is owned
135
+ // by the track("handler:<id>") at the call site; this adds the span only).
136
+ return observePhase(PHASES.handler(entry.id), async () => {
137
+ const component = await tryStaticHandler(entry, entry.shortCode);
138
+ if (component !== undefined) return component;
139
+ return typeof entry.handler === "function"
140
+ ? handleHandlerResult(await entry.handler(context))
141
+ : (entry.handler as ReactNode);
142
+ });
138
143
  }
139
144
 
140
145
  // ---------------------------------------------------------------------------
@@ -43,7 +43,7 @@ import {
43
43
  } from "./helpers.js";
44
44
  import { applyViewTransitionDefault } from "./view-transition-default.js";
45
45
  import { getRouterContext } from "../router-context.js";
46
- import { observeEvent } from "../instrument.js";
46
+ import { observeEvent, observePhase, PHASES } from "../instrument.js";
47
47
  import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
48
48
  import {
49
49
  track,
@@ -793,12 +793,16 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
793
793
  ? routeEntry.liveHandler
794
794
  : routeEntry.handler;
795
795
  if (!routeEntry.loading) {
796
- const result = handleHandlerResult(await handler(context));
796
+ const result = handleHandlerResult(
797
+ await observePhase(PHASES.handler(entry.id), () => handler(context)),
798
+ );
797
799
  doneHandler();
798
800
  return result;
799
801
  }
800
802
  if (!actionContext) {
801
- const result = handleHandlerResult(handler(context));
803
+ const result = handleHandlerResult(
804
+ observePhase(PHASES.handler(entry.id), () => handler(context)),
805
+ );
802
806
  if (result instanceof Promise) {
803
807
  warnOnStreamedResponse(result, routeEntry.id);
804
808
  result.finally(doneHandler).catch(() => {});
@@ -822,7 +826,9 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
822
826
  debugLog("segment.action", "resolving action route with awaited value", {
823
827
  entryId: entry.id,
824
828
  });
825
- const actionResult = handleHandlerResult(await handler(context));
829
+ const actionResult = handleHandlerResult(
830
+ await observePhase(PHASES.handler(entry.id), () => handler(context)),
831
+ );
826
832
  doneHandler();
827
833
  return {
828
834
  content: Promise.resolve(actionResult),
@@ -26,16 +26,23 @@
26
26
  * metered directly), rango.middleware (span-only incl. intercept middleware;
27
27
  * pre/post metered directly), rango.action (action:<id>; server-action
28
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
29
+ * useLoader, plus the fetchable path), rango.handler (span-only, one per segment
30
+ * route/layout handler execution; the handler:<id> perf metric is owned by the
31
+ * track() at the call site), rango.render (render:total:<route>; normal AND
30
32
  * action-revalidation renders), rango.ssr (ssr:render-html).
31
33
  *
32
- * Span-duration caveat: a span ends when its callback's value (or promise)
33
- * settles. For the streaming phases (request/render/ssr) that is when the
34
- * Response / HTML / RSC stream is constructed, NOT when the body finishes
35
- * draining. Loader/Suspense work that settles during stream drain extends past
36
- * the parent span's end, so parent durations under-report streamed time and a
37
- * rango.loader child can end after its parent. This is the streaming + end-on-
38
- * settle contract, not a defect; phase spans bound setup-to-stream-handoff.
34
+ * Streaming-phase span lifetime: a span ends when its callback's value (or
35
+ * promise) settles. The streaming phases (request, middleware, render, ssr) are
36
+ * wrapped by observeRequestPhase / observeStreamingPhase (instrument.ts), whose
37
+ * callback hands the constructed Response/stream to the caller immediately
38
+ * (streaming preserved) but then awaits a request-scoped drain barrier, so the
39
+ * SPAN ends when the response BODY finishes draining, not at stream construction.
40
+ * That keeps span durations covering the real streamed work and the trace tree
41
+ * valid: a loader/Suspense child that resolves while the body streams ends before
42
+ * its now-drain-bound parent. The co-emitted perf METRIC (render:total, …) is
43
+ * still recorded at construction — it ships in the Server-Timing header, flushed
44
+ * before the body drains — so a streaming span legitimately reads longer than its
45
+ * same-named metric by the time the body spent streaming after construction.
39
46
  *
40
47
  * Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
41
48
  * platform-specific bridge lives at the edge behind the SpanRunner contract.
@@ -62,6 +69,7 @@ export type TracePhase =
62
69
  | "middleware"
63
70
  | "action"
64
71
  | "loader"
72
+ | "handler"
65
73
  | "render"
66
74
  | "ssr";
67
75
 
@@ -71,6 +79,7 @@ export interface TracePhaseToggles {
71
79
  middleware?: boolean;
72
80
  action?: boolean;
73
81
  loader?: boolean;
82
+ handler?: boolean;
74
83
  render?: boolean;
75
84
  ssr?: boolean;
76
85
  }
@@ -107,6 +116,7 @@ const ALL_PHASES_ON: Record<TracePhase, boolean> = {
107
116
  middleware: true,
108
117
  action: true,
109
118
  loader: true,
119
+ handler: true,
110
120
  render: true,
111
121
  ssr: true,
112
122
  };
@@ -134,6 +144,7 @@ export function resolveTracing(
134
144
  middleware: spans.middleware ?? true,
135
145
  action: spans.action ?? true,
136
146
  loader: spans.loader ?? true,
147
+ handler: spans.handler ?? true,
137
148
  render: spans.render ?? true,
138
149
  ssr: spans.ssr ?? true,
139
150
  }
@@ -82,7 +82,12 @@ import {
82
82
  appendMetric,
83
83
  buildMetricsTiming,
84
84
  } from "../router/metrics.js";
85
- import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
85
+ import {
86
+ observePhase,
87
+ observeRequestPhase,
88
+ observeEvent,
89
+ PHASES,
90
+ } from "../router/instrument.js";
86
91
  import {
87
92
  startSSRSetup,
88
93
  getSSRSetup,
@@ -496,11 +501,14 @@ export function createRSCHandler<
496
501
  // The "rango.request" span is opened inside the request context so the
497
502
  // Cloudflare runner can read executionContext.tracing, and so every nested
498
503
  // phase span (and the platform's automatic KV/D1/fetch spans) nests under
499
- // it. metric:false handler:total is metered directly below (a grand total
500
- // incl. the pre-context bootstrap timings, finer than a single wrap). When
501
- // tracing is off this is a direct pass-through.
504
+ // it. observeRequestPhase owns the drain barrier: it instruments the final
505
+ // response body so this span (and the streaming inner phases) stay open
506
+ // until the body drains, keeping the tree valid. metric:false — handler:total
507
+ // is metered directly below (a grand total incl. the pre-context bootstrap
508
+ // timings) and stays construction-bound (it ships as a Server-Timing header,
509
+ // flushed before drain). When no surface is active this is a pass-through.
502
510
  return runWithRequestContext(requestContext, () =>
503
- observePhase(PHASES.request, async (span) => {
511
+ observeRequestPhase(PHASES.request, async (span) => {
504
512
  span.setAttribute("http.method", request.method);
505
513
  // The matched route template is not known until match() runs later, so
506
514
  // emit the concrete path as url.path (low-level), NOT http.route — the
@@ -11,7 +11,7 @@ import {
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
13
  import { appendMetric } from "../router/metrics.js";
14
- import { observePhase, PHASES } from "../router/instrument.js";
14
+ import { observeStreamingPhase, PHASES } from "../router/instrument.js";
15
15
  import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
16
16
  import type { RscPayload } from "./types.js";
17
17
  import type { MatchResult } from "../types.js";
@@ -36,7 +36,7 @@ export function handleRscRendering<TEnv>(
36
36
  // same boundary (match -> serialize -> SSR), so the two surfaces agree.
37
37
  // Loaders kicked off during matching nest under the span; the SSR HTML pass
38
38
  // below opens "rango.ssr" the same way.
39
- return observePhase(PHASES.render, () =>
39
+ return observeStreamingPhase(PHASES.render, () =>
40
40
  handleRscRenderingInner(
41
41
  ctx,
42
42
  request,
@@ -248,7 +248,7 @@ async function handleRscRenderingInner<TEnv>(
248
248
 
249
249
  // ssr-render-html metric + rango.ssr span from one boundary. render:total is
250
250
  // recorded by the observePhase wrapper around this function.
251
- const htmlStream = await observePhase(PHASES.ssr, () =>
251
+ const htmlStream = await observeStreamingPhase(PHASES.ssr, () =>
252
252
  ssrModule.renderHTML(rscStream, {
253
253
  nonce,
254
254
  streamMode,
@@ -20,7 +20,7 @@ import {
20
20
  setRequestContextParams,
21
21
  } from "../server/request-context.js";
22
22
  import { appendMetric } from "../router/metrics.js";
23
- import { observePhase, PHASES } from "../router/instrument.js";
23
+ import { observeStreamingPhase, PHASES } from "../router/instrument.js";
24
24
  import type { RscPayload } from "./types.js";
25
25
  import {
26
26
  hasBodyContent,
@@ -270,7 +270,7 @@ export function revalidateAfterAction<TEnv>(
270
270
  // "render:total" AND opens "rango.render" from one boundary covering
271
271
  // matchPartial -> serialize, so the revalidation loaders' rango.loader spans
272
272
  // nest under a rango.render parent instead of dangling at the request root.
273
- return observePhase(PHASES.render, () =>
273
+ return observeStreamingPhase(PHASES.render, () =>
274
274
  revalidateAfterActionInner(
275
275
  ctx,
276
276
  request,
@@ -368,6 +368,16 @@ export interface RequestContext<
368
368
  /** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
369
369
  _tracing?: ResolvedTracing;
370
370
 
371
+ /**
372
+ * @internal Drain barrier for streaming phase spans. The request phase
373
+ * (observeRequestPhase) sets this to a promise that resolves when the final
374
+ * response body finishes draining; the streaming inner phases
375
+ * (observeStreamingPhase: middleware/render/ssr) await it so their span AND
376
+ * perf metric end at body-drain rather than at stream construction. Undefined
377
+ * when neither the perf store nor tracing is active (no instrumentation).
378
+ */
379
+ _finalDrain?: Promise<void>;
380
+
371
381
  /** @internal Router basename for this request (used by redirect()) */
372
382
  _basename?: string;
373
383