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

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 +12 -3
  5. package/skills/prerender/SKILL.md +30 -11
  6. package/skills/router-setup/SKILL.md +11 -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 +109 -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 +230 -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 +198 -0
  30. package/src/router.ts +9 -0
  31. package/src/rsc/handler.ts +132 -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 +13 -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
@@ -524,6 +524,22 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
524
524
  } as MiddlewareItem;
525
525
  };
526
526
 
527
+ // Slot names become part of segment ids: a parallel/intercept slot is encoded
528
+ // as `${shortCode}.${slotName}`, and loader segments append `D${index}.${loaderId}`.
529
+ // A "." in the slot name collides with that separator -- loaderParentId
530
+ // (segment-system.tsx) strips from the FIRST `D<index>.`, so a name like
531
+ // "@D3.foo" is mis-cut to "@" and the loader's data is silently dropped. Reject
532
+ // the dot at definition time so the failure is loud, not a corrupted tree at
533
+ // runtime. (A bare "D" without a trailing dot -- e.g. "@Detail" -- is fine.)
534
+ function assertValidSlotName(slotName: string): void {
535
+ invariant(
536
+ !slotName.includes("."),
537
+ `Slot name "${slotName}" must not contain ".". The dot is a reserved ` +
538
+ `segment-id separator; a name like "@D3.foo" corrupts loader segment-id ` +
539
+ `parsing and silently drops the loader's data. Rename the slot.`,
540
+ );
541
+ }
542
+
527
543
  const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
528
544
  const { store, ctx } = requireDslContext(
529
545
  "parallel() must be called inside urls()",
@@ -539,6 +555,7 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
539
555
  );
540
556
 
541
557
  const slotNames = Object.keys(slots as Record<string, any>) as `@${string}`[];
558
+ for (const slotName of slotNames) assertValidSlotName(slotName);
542
559
 
543
560
  const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
544
561
 
@@ -698,6 +715,8 @@ const intercept = (
698
715
  "intercept() cannot be used inside parallel()",
699
716
  );
700
717
 
718
+ assertValidSlotName(slotName);
719
+
701
720
  const namespace = `${ctx.namespace}.$${store.getNextIndex("intercept")}.${slotName}`;
702
721
 
703
722
  // Dot-prefixed = local (add include prefix), unprefixed = global (use as-is)
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Phase + event instrumentation — the single internal API for observing router
3
+ * work.
4
+ *
5
+ * The router exposes the same work on three surfaces, and the rule is: each
6
+ * surface has exactly one owner here, so they cannot drift.
7
+ *
8
+ * - observePhase(): a span of work. Co-emits the `debugPerformance` perf
9
+ * metric (metrics store -> [RSC Perf] timeline + Server-Timing) AND the
10
+ * platform span (tracing runner -> Cloudflare custom spans / OTel). From one
11
+ * wrap site, so the span set is always a subset of the perf phases and the
12
+ * two can't disagree. Phases that meter their own perf metric with a finer
13
+ * decomposition (request, middleware) pass `metric: false` and get the span
14
+ * only — still co-located, still one owner per surface.
15
+ * - observeEvent(): a discrete fact (TelemetrySink): cache decisions,
16
+ * revalidation decisions, handler errors, timeouts, origin rejections.
17
+ * Event-shaped, not phase-shaped — derived from the same call sites but a
18
+ * separate surface from spans.
19
+ *
20
+ * Why phases, not events, are the parent abstraction: Cloudflare's span API is
21
+ * callback-bound (enterSpan wraps the actual work), so the callback boundary is
22
+ * the source of truth — async-context nesting (a loader's KV/D1/fetch spans
23
+ * landing under rango.loader) cannot be faithfully reconstructed from
24
+ * after-the-fact start/end events. Spans drive; events are emitted alongside.
25
+ *
26
+ * Phase identity lives in the PHASES registry below, so the raw `rango.*` span
27
+ * names, perf-metric labels, and span attributes have a single definition each.
28
+ *
29
+ * When neither perf surface nor tracing is active on the request, observePhase
30
+ * is a direct call — no wrapper, no timestamp, no allocation.
31
+ */
32
+
33
+ import { _getRequestContext } from "../server/request-context.js";
34
+ import { isAutoGeneratedRouteName } from "../route-name.js";
35
+ import { getRouterContext } from "./router-context.js";
36
+ import { resolveSink, safeEmit, type TelemetryEvent } from "./telemetry.js";
37
+ import { appendMetric } from "./metrics.js";
38
+ import {
39
+ NOOP_TRACE_SPAN,
40
+ traceSpan,
41
+ runThenSettle,
42
+ type TracePhase,
43
+ type TraceSpan,
44
+ } from "./tracing.js";
45
+
46
+ /**
47
+ * Perf-metric boundary for a phase, or `false` for span-only. `false` means the
48
+ * caller records its own perf metric with a finer decomposition than a single
49
+ * wrap (request: a grand total incl. pre-context bootstrap; middleware: pre/post
50
+ * own-time), so observePhase opens the span but records no metric of its own.
51
+ */
52
+ export type PhaseMetric =
53
+ | { label: string | (() => string); depth?: number }
54
+ | false;
55
+
56
+ /** Describes one observable phase across the perf and span surfaces. */
57
+ export interface PhaseSpec {
58
+ /** Perf timeline label + Server-Timing name, or false for span-only. */
59
+ metric: PhaseMetric;
60
+ /** Span phase gate (per-phase toggle in the tracing config). */
61
+ tracePhase: TracePhase;
62
+ /** Span name (rango.*). */
63
+ spanName: string;
64
+ /** Span attributes set automatically when the span opens. */
65
+ attributes?: Record<string, string | number | boolean>;
66
+ }
67
+
68
+ /**
69
+ * The router's observable phases. One definition per phase keeps the `rango.*`
70
+ * span names, perf-metric labels, and identifying attributes from spreading
71
+ * across call sites.
72
+ */
73
+ export const PHASES = {
74
+ /** Whole request pipeline. Span only — handler:total is metered directly. */
75
+ request: {
76
+ metric: false,
77
+ tracePhase: "request",
78
+ spanName: "rango.request",
79
+ } as PhaseSpec,
80
+
81
+ /** One middleware (incl. its downstream onion). Span only — the perf metric
82
+ * is the middleware's exclusive pre/post own-time, recorded directly.
83
+ * `metricLabel` is that metric's label (e.g. "middleware:auth@*"); it doubles
84
+ * as the rango.middleware_name span attribute. */
85
+ middleware: (metricLabel: string): PhaseSpec => ({
86
+ metric: false,
87
+ tracePhase: "middleware",
88
+ spanName: "rango.middleware",
89
+ attributes: { "rango.middleware_name": metricLabel },
90
+ }),
91
+
92
+ /** The server-action execution (decode args + run the action body), before
93
+ * the revalidation render. The metric label carries the action id (the
94
+ * _rsc_action / action $$id) so the perf timeline shows WHICH action ran, not
95
+ * just "an action"; the span also gets it as rango.action_id. */
96
+ action: (id: string): PhaseSpec => ({
97
+ metric: { label: `action:${id}` },
98
+ tracePhase: "action",
99
+ spanName: "rango.action",
100
+ attributes: { "rango.action_id": id },
101
+ }),
102
+
103
+ /**
104
+ * One loader execution. `depth` is the perf-timeline indentation: 2 (default)
105
+ * for render-time loaders that nest under the render phase; 1 for a standalone
106
+ * fetchable `_rsc_loader` request, which has no render parent.
107
+ */
108
+ loader: (id: string, depth: number = 2): PhaseSpec => ({
109
+ metric: { label: `loader:${id}`, depth },
110
+ tracePhase: "loader",
111
+ spanName: "rango.loader",
112
+ attributes: { "rango.loader_id": id },
113
+ }),
114
+
115
+ /** Whole render phase: match + serialize + SSR. The metric label is resolved
116
+ * lazily at record time (after match has set the route name) so the perf
117
+ * timeline shows WHICH route rendered: `render:total:<routeName>`, falling back
118
+ * to `render:total` when there is no named route (unmatched / auto-generated). */
119
+ render: {
120
+ metric: {
121
+ label: () => {
122
+ const routeName = _getRequestContext()?._routeName;
123
+ return routeName && !isAutoGeneratedRouteName(routeName)
124
+ ? `render:total:${routeName}`
125
+ : "render:total";
126
+ },
127
+ },
128
+ tracePhase: "render",
129
+ spanName: "rango.render",
130
+ } as PhaseSpec,
131
+
132
+ /** SSR HTML render from the RSC stream. Colon-delimited like the other ssr:*
133
+ * setup metrics (ssr:module-load / ssr:stream-mode). */
134
+ ssr: {
135
+ metric: { label: "ssr:render-html" },
136
+ tracePhase: "ssr",
137
+ spanName: "rango.ssr",
138
+ } as PhaseSpec,
139
+ } as const;
140
+
141
+ /**
142
+ * Instrument one unit of work: open its span AND (unless `metric: false`) record
143
+ * its perf metric, from a single wrap site. fn is invoked exactly once with the
144
+ * span (a no-op span when tracing is off); its return value is returned
145
+ * unchanged and thrown errors / rejected promises propagate unchanged. When fn
146
+ * returns a promise both the metric duration and the span end when it settles.
147
+ *
148
+ * Reads the metrics store + tracing off the RequestContext ALS, which is active
149
+ * for the WHOLE request — contrast observeEvent, which reads the RouterContext
150
+ * ALS (entered later, during match).
151
+ */
152
+ export function observePhase<T>(
153
+ spec: PhaseSpec,
154
+ fn: (span: TraceSpan) => T,
155
+ ): T {
156
+ const reqCtx = _getRequestContext();
157
+ const store = reqCtx?._metricsStore;
158
+ const tracing = reqCtx?._tracing;
159
+
160
+ // Neither surface active: direct call, zero overhead.
161
+ if (!store && !tracing) return fn(NOOP_TRACE_SPAN);
162
+
163
+ // Attributes only land on a real span, so skip the wrapper when only the perf
164
+ // surface is active (traceSpan would apply them to NOOP_TRACE_SPAN for nothing).
165
+ const attributes = spec.attributes;
166
+ const wrapped: (span: TraceSpan) => T =
167
+ attributes && tracing
168
+ ? (span) => {
169
+ for (const key in attributes) span.setAttribute(key, attributes[key]);
170
+ return fn(span);
171
+ }
172
+ : fn;
173
+
174
+ const runSpan = (): T =>
175
+ traceSpan(tracing, spec.tracePhase, spec.spanName, wrapped);
176
+
177
+ // Span-only — no perf metric to record (metric:false, or perf surface off).
178
+ const metric = spec.metric;
179
+ if (!store || metric === false) return runSpan();
180
+
181
+ // Record the phase duration on EVERY termination — success or failure — so a
182
+ // failed loader/render still shows its timing in the perf report (parity with
183
+ // the old track().finally() path it replaced).
184
+ 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);
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Emit one discrete telemetry event (the event-shaped counterpart to
196
+ * observePhase). Resolves the sink from the active router context and stamps the
197
+ * request id when the event omits it. No-op (and total — never throws) when no
198
+ * sink is configured.
199
+ *
200
+ * This is the canonical emitter for SYNCHRONOUS facts that fire inside the
201
+ * request's ALS scope (handler errors, timeouts, origin rejections, revalidation
202
+ * decisions). A few emitters deliberately stay on the lower-level
203
+ * resolveSink + safeEmit because observeEvent's lazy, per-call
204
+ * getRouterContext() read does not fit them — keep this the complete list:
205
+ * - router.ts wrapLoaderPromise (loader.start/end/error) and
206
+ * segment-resolution/streamed-handler-telemetry.ts (streamed handler.error)
207
+ * capture the sink + request id EAGERLY and emit from a fire-and-forget
208
+ * continuation that runs after the ALS scope may have unwound.
209
+ * - router/match-handlers.ts resolves the sink ONCE for the hot match-pipeline
210
+ * loop (request.start/end/error, cache.decision, ...).
211
+ * - segment-resolution/helpers.ts emits via a caller-provided report.telemetry
212
+ * sink rather than the ALS router context.
213
+ */
214
+ export function observeEvent(event: TelemetryEvent): void {
215
+ // getRouterContext() either throws (real impl, outside a router context — e.g.
216
+ // the build-time prerender path) or returns null/undefined (e.g. mocked).
217
+ // Either way there is no sink to emit to, so swallow and return.
218
+ let routerCtx: ReturnType<typeof getRouterContext> | null | undefined;
219
+ try {
220
+ routerCtx = getRouterContext();
221
+ } catch {
222
+ return;
223
+ }
224
+ if (!routerCtx?.telemetry) return;
225
+ const stamped =
226
+ event.requestId === undefined && routerCtx.requestId !== undefined
227
+ ? ({ ...event, requestId: routerCtx.requestId } as TelemetryEvent)
228
+ : event;
229
+ safeEmit(resolveSink(routerCtx.telemetry), stamped);
230
+ }
@@ -5,8 +5,8 @@
5
5
  */
6
6
 
7
7
  import type { ReactNode } from "react";
8
- import { track } from "../server/context";
9
8
  import type { EntryData } from "../server/context";
9
+ import { observePhase, PHASES } from "./instrument.js";
10
10
  import { contextGet } from "../context-var.js";
11
11
  import type {
12
12
  ResolvedSegment,
@@ -382,7 +382,11 @@ function createLoaderExecutor<TEnv>(
382
382
  },
383
383
  };
384
384
 
385
- const doneLoader = track(`loader:${loader.$$id}`, 2);
385
+ // Meter this loader once via observePhase (loader:<id> perf metric +
386
+ // rango.loader span); loaderFn runs inside the span callback so its KV/D1/
387
+ // fetch spans nest under it. This is one of the observePhase loader funnels —
388
+ // see instrument.ts for the single-metering contract.
389
+ //
386
390
  // Run the loader body inside loader scope so request-scoped reads
387
391
  // (cookies()/headers() and non-cacheable ctx.get) are exempt from the
388
392
  // cache-purity guards: loaders always run fresh, so their reads never leak
@@ -392,14 +396,15 @@ function createLoaderExecutor<TEnv>(
392
396
  // throw. rendered() gating uses the captured isDslLoader (above), so this
393
397
  // does not grant rendered() to handler-invoked loaders. Uses a body-only
394
398
  // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
395
- const promise = Promise.resolve(
396
- runInsideLoaderBodyScope(() =>
397
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
398
- ),
399
- ).finally(() => {
400
- pendingLoaders.delete(loader.$$id);
401
- doneLoader();
402
- });
399
+ const promise = observePhase(PHASES.loader(loader.$$id), () =>
400
+ Promise.resolve(
401
+ runInsideLoaderBodyScope(() =>
402
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
403
+ ),
404
+ ).finally(() => {
405
+ pendingLoaders.delete(loader.$$id);
406
+ }),
407
+ );
403
408
 
404
409
  loaderPromises.set(loader.$$id, promise);
405
410
  return promise;
@@ -94,7 +94,7 @@
94
94
  import type { ResolvedSegment } from "../../types.js";
95
95
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
96
96
  import { getRouterContext, type RouterContext } from "../router-context.js";
97
- import { resolveSink, safeEmit } from "../telemetry.js";
97
+ import { observeEvent } from "../instrument.js";
98
98
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
99
99
  import { treeHasStreaming } from "./segment-resolution.js";
100
100
  import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
@@ -546,19 +546,14 @@ export function withCacheLookup<TEnv>(
546
546
  traceSource: "cache-hit",
547
547
  });
548
548
 
549
- const routerCtx = getRouterContext<TEnv>();
550
- if (routerCtx.telemetry) {
551
- const tSink = resolveSink(routerCtx.telemetry);
552
- safeEmit(tSink, {
553
- type: "revalidation.decision",
554
- timestamp: performance.now(),
555
- requestId: routerCtx.requestId,
556
- segmentId: segment.id,
557
- pathname: ctx.pathname,
558
- routeKey: ctx.routeKey,
559
- shouldRevalidate,
560
- });
561
- }
549
+ observeEvent({
550
+ type: "revalidation.decision",
551
+ timestamp: performance.now(),
552
+ segmentId: segment.id,
553
+ pathname: ctx.pathname,
554
+ routeKey: ctx.routeKey,
555
+ shouldRevalidate,
556
+ });
562
557
 
563
558
  if (!shouldRevalidate) {
564
559
  segment.component = null;
@@ -168,6 +168,18 @@ export function withCacheStore<TEnv>(
168
168
  if (!requestCtx) return;
169
169
 
170
170
  const cacheScope = ctx.cacheScope;
171
+
172
+ // Record the route's segment-DSL cache tags into the request tag union NOW,
173
+ // synchronously in the pipeline. The actual store write (cacheRoute) runs in
174
+ // requestCtx.waitUntil() below — and the proactive path re-resolves the whole
175
+ // tree first — so cacheRoute's own tag recording races the document cache's
176
+ // post-body-drain snapshot of _requestTags. On a first-write miss the document
177
+ // tag union could miss these tags and revalidateTag()/updateTag() would not
178
+ // invalidate the cached document. Recording here (before the snapshot) closes
179
+ // the window for both the direct and proactive write paths; the duplicate
180
+ // record inside cacheRoute is idempotent.
181
+ cacheScope.recordTags(requestCtx);
182
+
171
183
  const reqId = INTERNAL_RANGO_DEBUG
172
184
  ? getOrCreateRequestId(ctx.request)
173
185
  : undefined;
@@ -19,6 +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
23
  import { stripInternalParams } from "./handler-context.js";
23
24
  import { isWebSocketUpgradeResponse } from "../response-utils.js";
24
25
 
@@ -478,9 +479,17 @@ export async function executeMiddleware<TEnv>(
478
479
  return nextPromise;
479
480
  };
480
481
 
482
+ // Wrap the middleware (including its downstream next() chain) in its span
483
+ // via the unified phase API. metric:false — the middleware's perf metric is
484
+ // its exclusive pre/post own-time, recorded directly above and below, finer
485
+ // than a single wrap. Spans nest by async context, so this onions
486
+ // middleware-over-middleware and the core handler underneath. Pass-through
487
+ // when neither surface is active.
481
488
  let result: Response | void;
482
489
  try {
483
- result = await entry.handler(ctx, wrappedNext);
490
+ result = await observePhase(PHASES.middleware(metricLabel), () =>
491
+ entry.handler(ctx, wrappedNext),
492
+ );
484
493
  } catch (error) {
485
494
  // Thrown Response is short-circuit control flow, not an error.
486
495
  // Fall through to the `if (result instanceof Response)` branch below
@@ -622,6 +631,7 @@ export async function executeInterceptMiddleware<TEnv>(
622
631
  return stubResponse;
623
632
  }
624
633
 
634
+ const ordinal = index;
625
635
  const middleware = middlewares[index++];
626
636
  const ctx = createMiddlewareContext(
627
637
  request,
@@ -643,9 +653,20 @@ export async function executeInterceptMiddleware<TEnv>(
643
653
  return next();
644
654
  };
645
655
 
656
+ // Span-wrap each intercept middleware as rango.middleware (metric:false —
657
+ // intercept runs inside the render phase already metered by render:total, so
658
+ // it contributes a span but no separate perf metric). Bare MiddlewareFns
659
+ // have no pattern, so the label is scoped to "*" like a pattern-less entry.
660
+ const label = getMiddlewareMetricLabel(
661
+ { handler: middleware, pattern: null } as MiddlewareEntry<TEnv>,
662
+ ordinal,
663
+ );
664
+
646
665
  let result: Response | void;
647
666
  try {
648
- result = await middleware(ctx, guardedNext);
667
+ result = await observePhase(PHASES.middleware(label), () =>
668
+ middleware(ctx, guardedNext),
669
+ );
649
670
  } catch (error) {
650
671
  // Thrown Response is short-circuit control flow, parity with the
651
672
  // explicit-return path below. Real errors propagate.
@@ -17,6 +17,7 @@ import { setupBuildUse } from "./loader-resolution.js";
17
17
  import { loadManifest } from "./manifest.js";
18
18
  import { traverseBack } from "./pattern-matching.js";
19
19
  import type { RouterContext } from "./router-context.js";
20
+ import type { ResolveSegmentOptions } from "./segment-resolution.js";
20
21
  import { runWithRouterContext } from "./router-context.js";
21
22
  import type { EntryData, InterceptEntry } from "../server/context";
22
23
  import type {
@@ -40,7 +41,7 @@ export interface PrerenderMatchDeps<TEnv = any> {
40
41
  params: Record<string, string>,
41
42
  context: HandlerContext<any, TEnv>,
42
43
  loaderPromises: Map<string, Promise<any>>,
43
- options?: { skipLoaders?: boolean },
44
+ options?: ResolveSegmentOptions,
44
45
  ) => Promise<ResolvedSegment[]>;
45
46
  }
46
47
 
@@ -257,7 +258,9 @@ export async function matchForPrerender<TEnv = any>(
257
258
  matchedParams,
258
259
  buildCtx,
259
260
  loaderPromises,
260
- { skipLoaders: true },
261
+ // throwOnError: a render failure (or `throw new Skip()`) must reach the
262
+ // build/dev caller, not be baked into a frozen error page (issue #587).
263
+ { skipLoaders: true, throwOnError: true },
261
264
  );
262
265
 
263
266
  // 9. Detect passthrough sentinel: handler returned ctx.passthrough().
@@ -19,6 +19,7 @@ import type {
19
19
  } from "../types.js";
20
20
  import type { RouteMatchResult } from "./pattern-matching.js";
21
21
  import type { TelemetrySink } from "./telemetry.js";
22
+ import type { ResolveSegmentOptions } from "./segment-resolution.js";
22
23
 
23
24
  /**
24
25
  * Revalidation context passed to segment resolution
@@ -195,7 +196,7 @@ export interface RouterContext<TEnv = any> {
195
196
  params: Record<string, string>,
196
197
  handlerContext: HandlerContext<any, TEnv>,
197
198
  loaderPromises: Map<string, Promise<any>>,
198
- options?: { skipLoaders?: boolean },
199
+ options?: ResolveSegmentOptions,
199
200
  ) => Promise<ResolvedSegment[]>;
200
201
 
201
202
  resolveAllSegmentsGenerator?: (
@@ -14,6 +14,7 @@ import { RSC_ROUTER_BRAND } from "./router-registry.js";
14
14
  import type { RangoOptions, RootLayoutProps } from "./router-options.js";
15
15
  import type { DefaultVars } from "../types/global-namespace.js";
16
16
  import type { ResolvedTimeouts, OnTimeoutCallback } from "./timeout.js";
17
+ import type { ResolvedTracing } from "./tracing.js";
17
18
 
18
19
  /**
19
20
  * Options passed to router.fetch(), router.match(), and other request entrypoints.
@@ -320,6 +321,13 @@ export interface RangoInternal<
320
321
  */
321
322
  readonly debugPerformance?: boolean;
322
323
 
324
+ /**
325
+ * Resolved platform phase-span tracing (Cloudflare custom spans or OTel), or
326
+ * undefined when off. Threaded onto the request context and read at each
327
+ * traced phase.
328
+ */
329
+ readonly tracing?: ResolvedTracing;
330
+
323
331
  /**
324
332
  * Whether ?__debug_manifest is allowed in production.
325
333
  * Always enabled in development.
@@ -11,6 +11,7 @@ import type { UrlPatterns } from "../urls.js";
11
11
  import type { UrlBuilder } from "../urls/pattern-types.js";
12
12
  import type { NamedRouteEntry } from "./content-negotiation.js";
13
13
  import type { TelemetrySink } from "./telemetry.js";
14
+ import type { RouterTracingConfig } from "./tracing.js";
14
15
  import type { RouterTimeouts, OnTimeoutCallback } from "./timeout.js";
15
16
 
16
17
  /**
@@ -574,11 +575,14 @@ export interface RangoOptions<TEnv = any> {
574
575
  onTimeout?: OnTimeoutCallback<TEnv>;
575
576
 
576
577
  /**
577
- * Telemetry sink for structured lifecycle events.
578
+ * Telemetry sink for structured, discrete lifecycle EVENTS: request
579
+ * start/end/error, loader start/end/error, handler errors, cache decisions,
580
+ * revalidation decisions, timeouts, origin rejections.
578
581
  *
579
- * When provided, the router emits events for request start/end,
580
- * loader start/end/error, handler errors, cache decisions, and
581
- * revalidation decisions.
582
+ * This is the EVENT surface. Phase-duration SPANS (request/loader/render/ssr
583
+ * timing wired into a tracing backend) come from the separate `tracing`
584
+ * option below — a sink does not emit them, because async-context nesting
585
+ * cannot be faithfully reconstructed from after-the-fact start/end events.
582
586
  *
583
587
  * No-op when not configured (zero overhead).
584
588
  *
@@ -591,6 +595,18 @@ export interface RangoOptions<TEnv = any> {
591
595
  * });
592
596
  * ```
593
597
  *
598
+ * @example OpenTelemetry — pair the event sink with the tracing slot
599
+ * ```typescript
600
+ * import { createOTelTracing, createOTelSink } from "@rangojs/router";
601
+ * import { trace } from "@opentelemetry/api";
602
+ *
603
+ * const tracer = trace.getTracer("my-app");
604
+ * const router = createRouter({
605
+ * tracing: createOTelTracing(tracer), // phase spans
606
+ * telemetry: createOTelSink(tracer), // discrete-fact events
607
+ * });
608
+ * ```
609
+ *
594
610
  * @example Custom sink
595
611
  * ```typescript
596
612
  * const router = createRouter({
@@ -604,6 +620,44 @@ export interface RangoOptions<TEnv = any> {
604
620
  */
605
621
  telemetry?: TelemetrySink;
606
622
 
623
+ /**
624
+ * Span tracing for the router's performance phases (request, middleware, action,
625
+ * loaders, render, ssr). Connects the same phases shown in the
626
+ * `debugPerformance` timeline to the host platform's tracing system. This is
627
+ * the SPAN surface (the `telemetry` option above is the event surface).
628
+ *
629
+ * Two factories produce a config, both for this slot:
630
+ * - `createOTelTracing(tracer)` from `@rangojs/router` — any platform with an
631
+ * OpenTelemetry SDK (including Node). Bridges the phases onto
632
+ * `tracer.startActiveSpan`.
633
+ * - `createCloudflareTracing()` from `@rangojs/router/cloudflare` — Cloudflare
634
+ * Workers native custom spans, alongside the automatic KV/D1/fetch spans.
635
+ *
636
+ * When tracing is unset — or off-platform (no OTel SDK / no Cloudflare tracing
637
+ * destination) — every span call falls through to the work directly, so the
638
+ * request behaves exactly as if tracing were off.
639
+ *
640
+ * @example OpenTelemetry
641
+ * ```typescript
642
+ * import { createOTelTracing } from "@rangojs/router";
643
+ * import { trace } from "@opentelemetry/api";
644
+ *
645
+ * const router = createRouter({
646
+ * tracing: createOTelTracing(trace.getTracer("my-app")),
647
+ * });
648
+ * ```
649
+ *
650
+ * @example Cloudflare
651
+ * ```typescript
652
+ * import { createCloudflareTracing } from "@rangojs/router/cloudflare";
653
+ *
654
+ * const router = createRouter({
655
+ * tracing: createCloudflareTracing({ spans: { ssr: false } }),
656
+ * });
657
+ * ```
658
+ */
659
+ tracing?: RouterTracingConfig;
660
+
607
661
  /**
608
662
  * SSR configuration options.
609
663
  *
@@ -19,8 +19,6 @@ import type {
19
19
  } from "../../types";
20
20
  import type { SegmentResolutionDeps } from "../types.js";
21
21
  import { resolveLoaderData } from "./loader-cache.js";
22
- import { _getRequestContext } from "../../server/request-context.js";
23
- import { appendMetric } from "../metrics.js";
24
22
  import {
25
23
  handleHandlerResult,
26
24
  tryStaticHandler,
@@ -59,7 +57,6 @@ export async function resolveLoaders<TEnv>(
59
57
  const shortCode = shortCodeOverride ?? entry.shortCode;
60
58
  const hasLoading = "loading" in entry && entry.loading !== undefined;
61
59
  const loadingDisabled = hasLoading && entry.loading === false;
62
- const ms = _getRequestContext()?._metricsStore;
63
60
 
64
61
  if (!loadingDisabled) {
65
62
  // Streaming loaders: promises kick off now, settle during RSC serialization.
@@ -102,7 +99,6 @@ export async function resolveLoaders<TEnv>(
102
99
  const pendingLoaderData = loaderEntries.map((loaderEntry, i) => {
103
100
  const { loader } = loaderEntry;
104
101
  const segmentId = `${shortCode}D${i}.${loader.$$id}`;
105
- const start = performance.now();
106
102
  const wrapped = deps.wrapLoaderPromise(
107
103
  runInsideLoaderScope(() =>
108
104
  resolveLoaderData(loaderEntry, ctx, ctx.pathname),
@@ -111,26 +107,17 @@ export async function resolveLoaders<TEnv>(
111
107
  segmentId,
112
108
  ctx.pathname,
113
109
  );
114
- return { wrapped, start, segmentId, loaderId: loader.$$id };
110
+ return { wrapped, segmentId };
115
111
  });
116
112
  await Promise.all(pendingLoaderData.map((p) => p.wrapped));
117
113
 
118
114
  return loaderEntries.map((loaderEntry, i) => {
119
115
  const { loader } = loaderEntry;
120
116
  const pending = pendingLoaderData[i]!;
121
- if (ms && !ms.metrics.some((m) => m.label === `loader:${loader.$$id}`)) {
122
- // All loaders ran in parallel via Promise.all — each span covers
123
- // from its own kickoff to the batch settlement, giving a ceiling
124
- // on that loader's contribution to the overall wait.
125
- const batchEnd = performance.now();
126
- appendMetric(
127
- ms,
128
- `loader:${loader.$$id}`,
129
- pending.start,
130
- batchEnd - pending.start,
131
- 2,
132
- );
133
- }
117
+ // The "loader:<id>" perf metric is recorded by observePhase at the single
118
+ // loader-metering site (useLoader, reached via ctx.use during
119
+ // resolveLoaderData), with the real per-loader duration rather than a
120
+ // Promise.all batch ceiling.
134
121
  return {
135
122
  id: pending.segmentId,
136
123
  namespace: entry.id,
@@ -151,6 +138,15 @@ export async function resolveLoaders<TEnv>(
151
138
  export interface ResolveSegmentOptions {
152
139
  /** When true, skip resolveLoaders() calls (used for pre-rendering) */
153
140
  skipLoaders?: boolean;
141
+ /**
142
+ * When true, a thrown render error is re-thrown instead of being converted
143
+ * into an error-boundary segment. Set only by the pre-render path so a
144
+ * build-time render failure (and a `throw new Skip()` inside a render fn)
145
+ * surfaces to the build instead of being silently baked into a frozen error
146
+ * page served as a 200 (issue #587). The live request path leaves this unset,
147
+ * so error boundaries keep catching at request time.
148
+ */
149
+ throwOnError?: boolean;
154
150
  }
155
151
 
156
152
  /**
@@ -635,6 +631,7 @@ export async function resolveAllSegments<TEnv>(
635
631
  deps,
636
632
  { request: safeRequest, url: context.url, routeKey, telemetry },
637
633
  context.pathname,
634
+ options?.throwOnError,
638
635
  );
639
636
  doneEntry();
640
637
  // Deduplicate by segment ID. include() scopes can produce entries that