@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
package/src/index.ts CHANGED
@@ -344,7 +344,12 @@ export {
344
344
  // bundle analysis output and slow build-time module resolution. Consumers
345
345
  // who need the values in non-RSC contexts can import from
346
346
  // `@rangojs/router/server`.
347
- export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
347
+ export type {
348
+ OTelTracer,
349
+ OTelActiveSpanTracer,
350
+ OTelSpan,
351
+ OTelTracingOptions,
352
+ } from "./router/telemetry-otel.js";
348
353
  // The full TelemetryEvent union PLUS its member types, so a consumer writing a
349
354
  // TelemetrySink can annotate a per-`type` handler (or construct an event literal
350
355
  // in a test) instead of only narrowing the opaque union.
@@ -366,6 +371,16 @@ export type {
366
371
  OriginCheckRejectedEvent,
367
372
  } from "./router/telemetry.js";
368
373
 
374
+ // Span tracing config types a consumer annotates. SpanRunner/TraceSpan are the
375
+ // internal runner contract (consumers go through createOTelTracing /
376
+ // createCloudflareTracing, which return a ready RouterTracingConfig) and are not
377
+ // exported.
378
+ export type {
379
+ RouterTracingConfig,
380
+ TracePhase,
381
+ TracePhaseToggles,
382
+ } from "./router/tracing.js";
383
+
369
384
  // Timeout types and error class
370
385
  export { RouterTimeoutError } from "./router/timeout.js";
371
386
  export type {
@@ -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,440 @@
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 { type MetricsStore } from "../server/context.js";
39
+ import {
40
+ NOOP_TRACE_SPAN,
41
+ traceSpan,
42
+ runThenSettle,
43
+ type TracePhase,
44
+ type TraceSpan,
45
+ type ResolvedTracing,
46
+ } from "./tracing.js";
47
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
48
+
49
+ /**
50
+ * Perf-metric boundary for a phase, or `false` for span-only. `false` means the
51
+ * caller records its own perf metric with a finer decomposition than a single
52
+ * wrap (request: a grand total incl. pre-context bootstrap; middleware: pre/post
53
+ * own-time), so observePhase opens the span but records no metric of its own.
54
+ */
55
+ export type PhaseMetric =
56
+ | { label: string | (() => string); depth?: number }
57
+ | false;
58
+
59
+ /** Describes one observable phase across the perf and span surfaces. */
60
+ export interface PhaseSpec {
61
+ /** Perf timeline label + Server-Timing name, or false for span-only. */
62
+ metric: PhaseMetric;
63
+ /** Span phase gate (per-phase toggle in the tracing config). */
64
+ tracePhase: TracePhase;
65
+ /** Span name (rango.*). */
66
+ spanName: string;
67
+ /** Span attributes set automatically when the span opens. */
68
+ attributes?: Record<string, string | number | boolean>;
69
+ }
70
+
71
+ /**
72
+ * The router's observable phases. One definition per phase keeps the `rango.*`
73
+ * span names, perf-metric labels, and identifying attributes from spreading
74
+ * across call sites.
75
+ */
76
+ export const PHASES = {
77
+ /** Whole request pipeline. Span only — handler:total is metered directly. */
78
+ request: {
79
+ metric: false,
80
+ tracePhase: "request",
81
+ spanName: "rango.request",
82
+ } as PhaseSpec,
83
+
84
+ /** One middleware (incl. its downstream onion). Span only — the perf metric
85
+ * is the middleware's exclusive pre/post own-time, recorded directly.
86
+ * `metricLabel` is that metric's label (e.g. "middleware:auth@*"); it doubles
87
+ * as the rango.middleware_name span attribute. */
88
+ middleware: (metricLabel: string): PhaseSpec => ({
89
+ metric: false,
90
+ tracePhase: "middleware",
91
+ spanName: "rango.middleware",
92
+ attributes: { "rango.middleware_name": metricLabel },
93
+ }),
94
+
95
+ /** The server-action execution (decode args + run the action body), before
96
+ * the revalidation render. The metric label carries the action id (the
97
+ * _rsc_action / action $$id) so the perf timeline shows WHICH action ran, not
98
+ * just "an action"; the span also gets it as rango.action_id. */
99
+ action: (id: string): PhaseSpec => ({
100
+ metric: { label: `action:${id}` },
101
+ tracePhase: "action",
102
+ spanName: "rango.action",
103
+ attributes: { "rango.action_id": id },
104
+ }),
105
+
106
+ /**
107
+ * One loader execution. `depth` is the perf-timeline indentation: 2 (default)
108
+ * for render-time loaders that nest under the render phase; 1 for a standalone
109
+ * fetchable `_rsc_loader` request, which has no render parent.
110
+ */
111
+ loader: (id: string, depth: number = 2): PhaseSpec => ({
112
+ metric: { label: `loader:${id}`, depth },
113
+ tracePhase: "loader",
114
+ spanName: "rango.loader",
115
+ attributes: { "rango.loader_id": id },
116
+ }),
117
+
118
+ /** Whole render phase: match + serialize + SSR. The metric label is resolved
119
+ * lazily at record time (after match has set the route name) so the perf
120
+ * timeline shows WHICH route rendered: `render:total:<routeName>`, falling back
121
+ * to `render:total` when there is no named route (unmatched / auto-generated). */
122
+ render: {
123
+ metric: {
124
+ label: () => {
125
+ const routeName = _getRequestContext()?._routeName;
126
+ return routeName && !isAutoGeneratedRouteName(routeName)
127
+ ? `render:total:${routeName}`
128
+ : "render:total";
129
+ },
130
+ },
131
+ tracePhase: "render",
132
+ spanName: "rango.render",
133
+ } as PhaseSpec,
134
+
135
+ /** SSR HTML render from the RSC stream. Colon-delimited like the other ssr:*
136
+ * setup metrics (ssr:module-load / ssr:stream-mode). */
137
+ ssr: {
138
+ metric: { label: "ssr:render-html" },
139
+ tracePhase: "ssr",
140
+ spanName: "rango.ssr",
141
+ } as PhaseSpec,
142
+ } as const;
143
+
144
+ /** Apply a phase spec's static attributes to a span (the no-op span ignores them). */
145
+ function applyAttributes(
146
+ span: TraceSpan,
147
+ attributes: Record<string, string | number | boolean>,
148
+ ): void {
149
+ for (const key in attributes) span.setAttribute(key, attributes[key]);
150
+ }
151
+
152
+ /**
153
+ * Record a phase's perf metric for the interval [start, now]. The label may be
154
+ * lazy (resolved here, e.g. render:total needs the route name that match sets
155
+ * partway through the wrapped work).
156
+ */
157
+ function recordPhaseMetric(
158
+ store: MetricsStore,
159
+ metric: Exclude<PhaseMetric, false>,
160
+ start: number,
161
+ ): void {
162
+ const label =
163
+ typeof metric.label === "function" ? metric.label() : metric.label;
164
+ appendMetric(store, label, start, performance.now() - start, metric.depth);
165
+ }
166
+
167
+ /**
168
+ * Instrument one unit of work: open its span AND (unless `metric: false`) record
169
+ * its perf metric, from a single wrap site. fn is invoked exactly once with the
170
+ * span (a no-op span when tracing is off); its return value is returned
171
+ * unchanged and thrown errors / rejected promises propagate unchanged. When fn
172
+ * returns a promise both the metric duration and the span end when it settles.
173
+ *
174
+ * This is the boundary for NON-streaming phases (action, loader): both the span
175
+ * and the metric settle when their own work completes. Streaming phases (request,
176
+ * middleware, render, ssr) use observeRequestPhase / observeStreamingPhase, where
177
+ * the SPAN is held open until body-drain (valid tree) while the perf metric is
178
+ * still recorded at construction (Server-Timing parity).
179
+ *
180
+ * Reads the metrics store + tracing off the RequestContext ALS, which is active
181
+ * for the WHOLE request — contrast observeEvent, which reads the RouterContext
182
+ * ALS (entered later, during match).
183
+ */
184
+ export function observePhase<T>(
185
+ spec: PhaseSpec,
186
+ fn: (span: TraceSpan) => T,
187
+ ): T {
188
+ const reqCtx = _getRequestContext();
189
+ const store = reqCtx?._metricsStore;
190
+ const tracing = reqCtx?._tracing;
191
+
192
+ // Neither surface active: direct call, zero overhead.
193
+ if (!store && !tracing) return fn(NOOP_TRACE_SPAN);
194
+
195
+ // Attributes only land on a real span, so skip the wrapper when only the perf
196
+ // surface is active (traceSpan would apply them to NOOP_TRACE_SPAN for nothing).
197
+ const attributes = spec.attributes;
198
+ const wrapped: (span: TraceSpan) => T =
199
+ attributes && tracing
200
+ ? (span) => {
201
+ applyAttributes(span, attributes);
202
+ return fn(span);
203
+ }
204
+ : fn;
205
+
206
+ const runSpan = (): T =>
207
+ traceSpan(tracing, spec.tracePhase, spec.spanName, wrapped);
208
+
209
+ // Span-only — no perf metric to record (metric:false, or perf surface off).
210
+ const metric = spec.metric;
211
+ if (!store || metric === false) return runSpan();
212
+
213
+ // Record the phase duration on EVERY termination — success or failure — so a
214
+ // failed loader/render still shows its timing in the perf report (parity with
215
+ // the old track().finally() path it replaced).
216
+ const start = performance.now();
217
+ return runThenSettle(runSpan, () => recordPhaseMetric(store, metric, start));
218
+ }
219
+
220
+ /**
221
+ * Re-stream `response`'s body through a pass-through that fires `onDrain` exactly
222
+ * once when the body finishes — on natural end, a stream error, or a client
223
+ * cancel (so a span can never leak on an aborted response). A bodyless response
224
+ * fires immediately. Only used while instrumentation is active, so the per-chunk
225
+ * relay cost never touches an untraced request.
226
+ */
227
+ function instrumentResponseDrain(
228
+ response: Response,
229
+ onDrain: () => void,
230
+ ): Response {
231
+ // WS-upgrade responses (status 101 / workerd `webSocket` property) must never
232
+ // be reconstructed: `new Response(body, { status: 101 })` throws and a copy
233
+ // drops the non-standard webSocket handoff (the invariant every other Response
234
+ // reconstruction site honors). A bodyless response has nothing to drain.
235
+ const source = response.body;
236
+ if (!source || isWebSocketUpgradeResponse(response)) {
237
+ onDrain();
238
+ return response;
239
+ }
240
+ let fired = false;
241
+ const fire = (): void => {
242
+ if (fired) return;
243
+ fired = true;
244
+ onDrain();
245
+ };
246
+ const reader = source.getReader();
247
+ const wrapped = new ReadableStream<Uint8Array>({
248
+ async pull(controller) {
249
+ try {
250
+ const { done, value } = await reader.read();
251
+ if (done) {
252
+ controller.close();
253
+ fire();
254
+ return;
255
+ }
256
+ controller.enqueue(value);
257
+ } catch (error) {
258
+ controller.error(error);
259
+ fire();
260
+ }
261
+ },
262
+ cancel(reason) {
263
+ fire();
264
+ return reader.cancel(reason);
265
+ },
266
+ });
267
+ return new Response(wrapped, response);
268
+ }
269
+
270
+ /**
271
+ * Shared engine for the streaming phases (request, middleware, render, ssr). It
272
+ * opens the span, runs fn, records the phase's perf metric at CONSTRUCTION (so it
273
+ * still reaches the Server-Timing header / [RSC Perf] table, both built before
274
+ * the body drains), hands the constructed value to the caller via a side channel
275
+ * (streaming preserved), then holds the span open until `drain` resolves. The
276
+ * SPAN therefore ends at body-drain — keeping the trace tree valid (a loader
277
+ * child that resolves mid-stream ends before its parent) — while the perf metric
278
+ * stays the construction work-time. `onDeliver` lets the request phase instrument
279
+ * the final body before handing it back; `onError` lets it release the barrier on
280
+ * failure. Fire-and-forget: the value reaches the caller via the returned
281
+ * promise, so the span promise's rejection is swallowed (already surfaced there).
282
+ */
283
+ function runDrainBoundPhase<R>(
284
+ spec: PhaseSpec,
285
+ fn: (span: TraceSpan) => R | Promise<R>,
286
+ tracing: ResolvedTracing | undefined,
287
+ store: MetricsStore | undefined,
288
+ drain: Promise<void>,
289
+ onDeliver: (value: R) => R,
290
+ onError?: () => void,
291
+ ): Promise<R> {
292
+ let deliver!: (value: R) => void;
293
+ let reject!: (error: unknown) => void;
294
+ const delivered = new Promise<R>((res, rej) => {
295
+ deliver = res;
296
+ reject = rej;
297
+ });
298
+
299
+ const start = performance.now();
300
+ const attributes = spec.attributes;
301
+ const metric = spec.metric;
302
+ const record = (): void => {
303
+ if (store && metric !== false) recordPhaseMetric(store, metric, start);
304
+ };
305
+ const spanCallback = async (span: TraceSpan): Promise<void> => {
306
+ if (attributes && tracing) applyAttributes(span, attributes);
307
+ let value: R;
308
+ try {
309
+ value = await fn(span);
310
+ } catch (error) {
311
+ record(); // a failed phase still shows its (construction) timing
312
+ onError?.();
313
+ reject(error);
314
+ throw error; // settle the span with the error, at construction
315
+ }
316
+ record(); // construction-bound metric, before the response/header is built
317
+ deliver(onDeliver(value));
318
+ await drain; // hold the span open until the response body drains
319
+ };
320
+
321
+ traceSpan(tracing, spec.tracePhase, spec.spanName, spanCallback).catch(
322
+ () => {},
323
+ );
324
+ return delivered;
325
+ }
326
+
327
+ /**
328
+ * The request phase (rango.request, metric:false). Owns the drain barrier: it
329
+ * runs fn to construct the final Response, instruments that Response's body so
330
+ * the barrier resolves at drain, hands the Response to the caller immediately
331
+ * (streaming preserved), and holds the span open until the body drains. Every
332
+ * streaming inner phase awaits the same barrier (via observeStreamingPhase), so
333
+ * the request/middleware/render/ssr chain ends at body-drain together and the
334
+ * trace tree is valid (no child span outlives its parent). The perf metrics
335
+ * (render:total, …) are recorded at construction so they still reach the
336
+ * Server-Timing header; only the SPANS are drain-bound. ctx.waitUntil holds the
337
+ * worker alive until drain so the span end runs. Pass-through when no surface is
338
+ * active.
339
+ */
340
+ export function observeRequestPhase(
341
+ spec: PhaseSpec,
342
+ fn: (span: TraceSpan) => Promise<Response>,
343
+ ): Promise<Response> {
344
+ const reqCtx = _getRequestContext();
345
+ const store = reqCtx?._metricsStore;
346
+ const tracing = reqCtx?._tracing;
347
+
348
+ if ((!store && !tracing) || !reqCtx) return fn(NOOP_TRACE_SPAN);
349
+
350
+ let resolveDrain!: () => void;
351
+ const finalDrain = new Promise<void>((resolve) => {
352
+ resolveDrain = resolve;
353
+ });
354
+ reqCtx._finalDrain = finalDrain;
355
+
356
+ // Keep the worker alive until the body drains, so the drain-bound span end
357
+ // (and the inner phases' settle) runs before the runtime can reclaim it.
358
+ const ec = reqCtx.executionContext;
359
+ if (typeof ec?.waitUntil === "function") ec.waitUntil(finalDrain);
360
+
361
+ return runDrainBoundPhase<Response>(
362
+ spec,
363
+ fn,
364
+ tracing,
365
+ store,
366
+ finalDrain,
367
+ (response) => instrumentResponseDrain(response, resolveDrain),
368
+ resolveDrain, // release the barrier if fn fails before constructing a body
369
+ );
370
+ }
371
+
372
+ /**
373
+ * A streaming inner phase (rango.middleware / render / ssr). Its SPAN settles
374
+ * when the request's final response body drains (the barrier owned by
375
+ * observeRequestPhase), not when fn returns the constructed stream — so
376
+ * loader/Suspense children that resolve mid-stream nest under a still-open
377
+ * parent. fn's result is delivered at construction (streaming preserved) and the
378
+ * perf metric is recorded at construction (Server-Timing parity). Falls back to
379
+ * observePhase (construction-bound span) when there is no barrier — a
380
+ * non-streaming request, or instrumentation off.
381
+ */
382
+ export function observeStreamingPhase<R>(
383
+ spec: PhaseSpec,
384
+ fn: (span: TraceSpan) => R | Promise<R>,
385
+ ): Promise<R> {
386
+ const reqCtx = _getRequestContext();
387
+ const store = reqCtx?._metricsStore;
388
+ const tracing = reqCtx?._tracing;
389
+ const finalDrain = reqCtx?._finalDrain;
390
+
391
+ if ((!store && !tracing) || !finalDrain) {
392
+ return Promise.resolve(observePhase(spec, fn));
393
+ }
394
+ return runDrainBoundPhase<R>(
395
+ spec,
396
+ fn,
397
+ tracing,
398
+ store,
399
+ finalDrain,
400
+ (value) => value,
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Emit one discrete telemetry event (the event-shaped counterpart to
406
+ * observePhase). Resolves the sink from the active router context and stamps the
407
+ * request id when the event omits it. No-op (and total — never throws) when no
408
+ * sink is configured.
409
+ *
410
+ * This is the canonical emitter for SYNCHRONOUS facts that fire inside the
411
+ * request's ALS scope (handler errors, timeouts, origin rejections, revalidation
412
+ * decisions). A few emitters deliberately stay on the lower-level
413
+ * resolveSink + safeEmit because observeEvent's lazy, per-call
414
+ * getRouterContext() read does not fit them — keep this the complete list:
415
+ * - router.ts wrapLoaderPromise (loader.start/end/error) and
416
+ * segment-resolution/streamed-handler-telemetry.ts (streamed handler.error)
417
+ * capture the sink + request id EAGERLY and emit from a fire-and-forget
418
+ * continuation that runs after the ALS scope may have unwound.
419
+ * - router/match-handlers.ts resolves the sink ONCE for the hot match-pipeline
420
+ * loop (request.start/end/error, cache.decision, ...).
421
+ * - segment-resolution/helpers.ts emits via a caller-provided report.telemetry
422
+ * sink rather than the ALS router context.
423
+ */
424
+ export function observeEvent(event: TelemetryEvent): void {
425
+ // getRouterContext() either throws (real impl, outside a router context — e.g.
426
+ // the build-time prerender path) or returns null/undefined (e.g. mocked).
427
+ // Either way there is no sink to emit to, so swallow and return.
428
+ let routerCtx: ReturnType<typeof getRouterContext> | null | undefined;
429
+ try {
430
+ routerCtx = getRouterContext();
431
+ } catch {
432
+ return;
433
+ }
434
+ if (!routerCtx?.telemetry) return;
435
+ const stamped =
436
+ event.requestId === undefined && routerCtx.requestId !== undefined
437
+ ? ({ ...event, requestId: routerCtx.requestId } as TelemetryEvent)
438
+ : event;
439
+ safeEmit(resolveSink(routerCtx.telemetry), stamped);
440
+ }
@@ -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 { observeStreamingPhase, 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 observeStreamingPhase(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 observeStreamingPhase(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.