@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
@@ -46,8 +46,6 @@ import {
46
46
  createReverseFunction,
47
47
  stripInternalParams,
48
48
  } from "../router/handler-context.js";
49
- import { getRouterContext } from "../router/router-context.js";
50
- import { resolveSink, safeEmit } from "../router/telemetry.js";
51
49
  import { contextSet } from "../context-var.js";
52
50
  import {
53
51
  hasCachedManifest,
@@ -84,6 +82,12 @@ import {
84
82
  appendMetric,
85
83
  buildMetricsTiming,
86
84
  } from "../router/metrics.js";
85
+ import {
86
+ observePhase,
87
+ observeRequestPhase,
88
+ observeEvent,
89
+ PHASES,
90
+ } from "../router/instrument.js";
87
91
  import {
88
92
  startSSRSetup,
89
93
  getSSRSetup,
@@ -244,24 +248,16 @@ export function createRSCHandler<
244
248
  metadata: { timeout: true, phase, durationMs },
245
249
  });
246
250
 
247
- try {
248
- const routerCtx = getRouterContext();
249
- if (routerCtx?.telemetry) {
250
- safeEmit(resolveSink(routerCtx.telemetry), {
251
- type: "request.timeout" as const,
252
- timestamp: performance.now(),
253
- requestId: routerCtx.requestId,
254
- phase,
255
- pathname: url.pathname,
256
- routeKey,
257
- actionId,
258
- durationMs,
259
- customHandler: !!router.onTimeout,
260
- });
261
- }
262
- } catch {
263
- // Router context may not be available
264
- }
251
+ observeEvent({
252
+ type: "request.timeout",
253
+ timestamp: performance.now(),
254
+ phase,
255
+ pathname: url.pathname,
256
+ routeKey,
257
+ actionId,
258
+ durationMs,
259
+ customHandler: !!router.onTimeout,
260
+ });
265
261
 
266
262
  if (router.onTimeout) {
267
263
  try {
@@ -499,86 +495,106 @@ export function createRSCHandler<
499
495
  // Store basename on request context (scoped per-request via existing ALS)
500
496
  requestContext._basename = router.basename;
501
497
 
502
- return runWithRequestContext(requestContext, async () => {
503
- // Core handler logic (wrapped by middleware)
504
- const coreHandler = async (): Promise<Response> => {
505
- return coreRequestHandler(request, env, url, variables, nonce);
506
- };
507
-
508
- // Execute middleware chain if any, otherwise call core handler directly
509
- let response: Response;
510
- if (matchedMiddleware.length > 0) {
511
- const mwResponse = await executeMiddleware(
512
- matchedMiddleware,
513
- request,
514
- env,
515
- variables,
516
- coreHandler,
517
- createReverseFunction(getRequiredRouteMap()),
518
- );
498
+ // Resolved span tracing for this request (read at each traced phase).
499
+ requestContext._tracing = router.tracing;
500
+
501
+ // The "rango.request" span is opened inside the request context so the
502
+ // Cloudflare runner can read executionContext.tracing, and so every nested
503
+ // phase span (and the platform's automatic KV/D1/fetch spans) nests under
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.
510
+ return runWithRequestContext(requestContext, () =>
511
+ observeRequestPhase(PHASES.request, async (span) => {
512
+ span.setAttribute("http.method", request.method);
513
+ // The matched route template is not known until match() runs later, so
514
+ // emit the concrete path as url.path (low-level), NOT http.route — the
515
+ // latter is reserved for the low-cardinality template (OTel convention).
516
+ span.setAttribute("url.path", url.pathname);
517
+
518
+ // Core handler logic (wrapped by middleware)
519
+ const coreHandler = async (): Promise<Response> => {
520
+ return coreRequestHandler(request, env, url, variables, nonce);
521
+ };
519
522
 
520
- if (
521
- url.searchParams.has("_rsc_partial") ||
522
- url.searchParams.has("_rsc_action")
523
- ) {
524
- const intercepted = interceptRedirectForPartial(
525
- mwResponse,
526
- createRedirectFlightResponse,
523
+ // Execute middleware chain if any, otherwise call core handler directly
524
+ let response: Response;
525
+ if (matchedMiddleware.length > 0) {
526
+ const mwResponse = await executeMiddleware(
527
+ matchedMiddleware,
528
+ request,
529
+ env,
530
+ variables,
531
+ coreHandler,
532
+ createReverseFunction(getRequiredRouteMap()),
527
533
  );
528
- response = intercepted ?? finalizeResponse(mwResponse);
534
+
535
+ if (
536
+ url.searchParams.has("_rsc_partial") ||
537
+ url.searchParams.has("_rsc_action")
538
+ ) {
539
+ const intercepted = interceptRedirectForPartial(
540
+ mwResponse,
541
+ createRedirectFlightResponse,
542
+ );
543
+ response = intercepted ?? finalizeResponse(mwResponse);
544
+ } else {
545
+ response = finalizeResponse(mwResponse);
546
+ }
529
547
  } else {
530
- response = finalizeResponse(mwResponse);
548
+ response = await coreHandler();
531
549
  }
532
- } else {
533
- response = await coreHandler();
534
- }
535
550
 
536
- // Finalize metrics after all middleware (including post-next work)
537
- // has completed so :post spans are captured in the timeline.
538
- // Handler timing parts are always emitted (even without debug metrics)
539
- // so non-debug requests still get bootstrap Server-Timing entries.
540
- const handlerTimingArr: string[] = variables.__handlerTiming || [];
541
- // Preserve any existing Server-Timing set by response routes or middleware
542
- const existingTiming = response.headers.get("Server-Timing");
543
- const timingParts = existingTiming
544
- ? [existingTiming, ...handlerTimingArr]
545
- : [...handlerTimingArr];
546
-
547
- const metricsStore = requestContext._metricsStore;
548
- if (metricsStore) {
549
- // When the store was created at handler start (earlyMetricsStore),
550
- // handler:total covers the full request. When ctx.debugPerformance()
551
- // created the store mid-request, use its requestStart to avoid a
552
- // negative startTime offset.
553
- const totalStart = earlyMetricsStore
554
- ? handlerStart
555
- : metricsStore.requestStart;
556
- appendMetric(
557
- metricsStore,
558
- "handler:total",
559
- totalStart,
560
- performance.now() - totalStart,
561
- );
562
- const metricsTiming = buildMetricsTiming(
563
- request.method,
564
- url.pathname,
565
- metricsStore,
566
- );
567
- if (metricsTiming) timingParts.push(metricsTiming);
568
- }
551
+ // Finalize metrics after all middleware (including post-next work)
552
+ // has completed so :post spans are captured in the timeline.
553
+ // Handler timing parts are always emitted (even without debug metrics)
554
+ // so non-debug requests still get bootstrap Server-Timing entries.
555
+ const handlerTimingArr: string[] = variables.__handlerTiming || [];
556
+ // Preserve any existing Server-Timing set by response routes or middleware
557
+ const existingTiming = response.headers.get("Server-Timing");
558
+ const timingParts = existingTiming
559
+ ? [existingTiming, ...handlerTimingArr]
560
+ : [...handlerTimingArr];
561
+
562
+ const metricsStore = requestContext._metricsStore;
563
+ if (metricsStore) {
564
+ // When the store was created at handler start (earlyMetricsStore),
565
+ // handler:total covers the full request. When ctx.debugPerformance()
566
+ // created the store mid-request, use its requestStart to avoid a
567
+ // negative startTime offset.
568
+ const totalStart = earlyMetricsStore
569
+ ? handlerStart
570
+ : metricsStore.requestStart;
571
+ appendMetric(
572
+ metricsStore,
573
+ "handler:total",
574
+ totalStart,
575
+ performance.now() - totalStart,
576
+ );
577
+ const metricsTiming = buildMetricsTiming(
578
+ request.method,
579
+ url.pathname,
580
+ metricsStore,
581
+ );
582
+ if (metricsTiming) timingParts.push(metricsTiming);
583
+ }
569
584
 
570
- const fullTiming = timingParts.join(", ");
571
- if (fullTiming && !isWebSocketUpgradeResponse(response)) {
572
- response.headers.set("Server-Timing", fullTiming);
573
- }
585
+ const fullTiming = timingParts.join(", ");
586
+ if (fullTiming && !isWebSocketUpgradeResponse(response)) {
587
+ response.headers.set("Server-Timing", fullTiming);
588
+ }
574
589
 
575
- // Single open-redirect chokepoint: every response (PE, full-page,
576
- // middleware short-circuit, response-route) funnels through here, so
577
- // guarding browser-followed (3xx) redirects once covers them all and any
578
- // future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
579
- // through untouched (validated client-side instead).
580
- return guardOutgoingRedirect(response, url.origin, router.basename);
581
- });
590
+ // Single open-redirect chokepoint: every response (PE, full-page,
591
+ // middleware short-circuit, response-route) funnels through here, so
592
+ // guarding browser-followed (3xx) redirects once covers them all and any
593
+ // future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
594
+ // through untouched (validated client-side instead).
595
+ return guardOutgoingRedirect(response, url.origin, router.basename);
596
+ }),
597
+ );
582
598
  };
583
599
 
584
600
  // Core request handling logic (separated for middleware wrapping).
@@ -715,23 +731,15 @@ export function createRSCHandler<
715
731
  },
716
732
  });
717
733
 
718
- try {
719
- const routerCtx = getRouterContext();
720
- if (routerCtx?.telemetry) {
721
- safeEmit(resolveSink(routerCtx.telemetry), {
722
- type: "request.origin-rejected" as const,
723
- timestamp: performance.now(),
724
- requestId: routerCtx.requestId,
725
- method: request.method,
726
- pathname: url.pathname,
727
- phase: originPhase,
728
- origin: request.headers.get("origin"),
729
- host: request.headers.get("host"),
730
- });
731
- }
732
- } catch {
733
- // Router context may not be available
734
- }
734
+ observeEvent({
735
+ type: "request.origin-rejected",
736
+ timestamp: performance.now(),
737
+ method: request.method,
738
+ pathname: url.pathname,
739
+ phase: originPhase,
740
+ origin: request.headers.get("origin"),
741
+ host: request.headers.get("host"),
742
+ });
735
743
 
736
744
  return originResult;
737
745
  }
@@ -773,23 +781,15 @@ export function createRSCHandler<
773
781
  params: reqCtx.params as Record<string, string>,
774
782
  handledByBoundary: true,
775
783
  });
776
- try {
777
- const routerCtx = getRouterContext();
778
- if (routerCtx?.telemetry) {
779
- safeEmit(resolveSink(routerCtx.telemetry), {
780
- type: "handler.error" as const,
781
- timestamp: performance.now(),
782
- requestId: routerCtx.requestId,
783
- error,
784
- handledByBoundary: true,
785
- pathname: url.pathname,
786
- routeKey: reqCtx._routeName,
787
- params: reqCtx.params as Record<string, string>,
788
- });
789
- }
790
- } catch {
791
- // Router context may not be available (e.g. prerender path)
792
- }
784
+ observeEvent({
785
+ type: "handler.error",
786
+ timestamp: performance.now(),
787
+ error,
788
+ handledByBoundary: true,
789
+ pathname: url.pathname,
790
+ routeKey: reqCtx._routeName,
791
+ params: reqCtx.params as Record<string, string>,
792
+ });
793
793
  };
794
794
 
795
795
  // Set route params early so all execution paths can access ctx.params.
@@ -894,14 +894,20 @@ export function createRSCHandler<
894
894
  if (plan.mode === "action") {
895
895
  let actionContinuation: ActionContinuation | undefined;
896
896
  try {
897
+ // Instrument the action execution as its own phase (action:<actionId> +
898
+ // rango.action), so a POST shows the mutation time AND which action ran,
899
+ // not just the downstream revalidation render. The action's own
900
+ // loaders/fetches nest under rango.action.
897
901
  const actionOutcome = await withTimeout(
898
- executeServerAction(
899
- handlerCtx,
900
- request,
901
- env,
902
- url,
903
- plan.actionId,
904
- handleStore,
902
+ observePhase(PHASES.action(plan.actionId), () =>
903
+ executeServerAction(
904
+ handlerCtx,
905
+ request,
906
+ env,
907
+ url,
908
+ plan.actionId,
909
+ handleStore,
910
+ ),
905
911
  ),
906
912
  router.timeouts.actionMs,
907
913
  "action",
@@ -14,6 +14,7 @@
14
14
  import { getLoaderLazy } from "../server/loader-registry.js";
15
15
  import { executeLoaderMiddleware } from "../router/middleware.js";
16
16
  import { requireRequestContext } from "../server/request-context.js";
17
+ import { observePhase, PHASES } from "../router/instrument.js";
17
18
  import {
18
19
  createReverseFunction,
19
20
  stripInternalParams,
@@ -162,7 +163,12 @@ export async function handleLoaderFetch<TEnv>(
162
163
  ...(loaderFormData ? { formData: loaderFormData } : {}),
163
164
  };
164
165
 
165
- const result = await fn(loaderCtx);
166
+ // Meter the fetchable-loader execution via observePhase, the sole
167
+ // funnel for this path (fn is called directly, not via ctx.use).
168
+ // depth:1 — a fetchable request has no render-phase parent.
169
+ const result = await observePhase(PHASES.loader(loaderId, 1), () =>
170
+ fn(loaderCtx),
171
+ );
166
172
 
167
173
  interface LoaderPayload {
168
174
  loaderResult: unknown;
@@ -13,6 +13,7 @@ import {
13
13
  import { getSSRSetup } from "./ssr-setup.js";
14
14
  import type { MiddlewareFn } from "../router/middleware.js";
15
15
  import { executeMiddleware } from "../router/middleware.js";
16
+ import { observePhase, PHASES } from "../router/instrument.js";
16
17
  import type { RscPayload, ReactFormState } from "./types.js";
17
18
  import {
18
19
  createResponseWithMergedHeaders,
@@ -124,7 +125,11 @@ export async function handleProgressiveEnhancement<TEnv>(
124
125
  const boundAction = await ctx.decodeAction(formData);
125
126
  // React's custom .bind() preserves $$id on server references.
126
127
  useActionStateId = (boundAction as { $$id?: string }).$$id ?? undefined;
127
- actionResult = await boundAction();
128
+ // Meter the no-JS form action as the action phase, same as the JS path.
129
+ actionResult = await observePhase(
130
+ PHASES.action(useActionStateId ?? "useActionState"),
131
+ () => boundAction(),
132
+ );
128
133
  } catch (error) {
129
134
  // Handle thrown redirect (e.g., throw redirect('/path'))
130
135
  const redirectResponse = extractRedirectResponse(error);
@@ -172,7 +177,9 @@ export async function handleProgressiveEnhancement<TEnv>(
172
177
 
173
178
  try {
174
179
  const loadedAction = await ctx.loadServerAction(directActionId);
175
- actionResult = await loadedAction.apply(null, args);
180
+ actionResult = await observePhase(PHASES.action(directActionId), () =>
181
+ loadedAction.apply(null, args),
182
+ );
176
183
  } catch (error) {
177
184
  // Handle thrown redirect (e.g., throw redirect('/path'))
178
185
  const redirectResponse = extractRedirectResponse(error);
@@ -11,6 +11,7 @@ import {
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
13
  import { appendMetric } from "../router/metrics.js";
14
+ import { observeStreamingPhase, PHASES } from "../router/instrument.js";
14
15
  import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
15
16
  import type { RscPayload } from "./types.js";
16
17
  import type { MatchResult } from "../types.js";
@@ -21,7 +22,34 @@ import {
21
22
  } from "./helpers.js";
22
23
  import type { HandlerContext } from "./handler-context.js";
23
24
 
24
- export async function handleRscRendering<TEnv>(
25
+ export function handleRscRendering<TEnv>(
26
+ ctx: HandlerContext<TEnv>,
27
+ request: Request,
28
+ env: TEnv,
29
+ url: URL,
30
+ isPartial: boolean,
31
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
32
+ nonce: string | undefined,
33
+ ): Promise<Response> {
34
+ // Instrument the whole render phase once through the unified API: it records
35
+ // the "render:total" perf metric AND opens the "rango.render" span from the
36
+ // same boundary (match -> serialize -> SSR), so the two surfaces agree.
37
+ // Loaders kicked off during matching nest under the span; the SSR HTML pass
38
+ // below opens "rango.ssr" the same way.
39
+ return observeStreamingPhase(PHASES.render, () =>
40
+ handleRscRenderingInner(
41
+ ctx,
42
+ request,
43
+ env,
44
+ url,
45
+ isPartial,
46
+ handleStore,
47
+ nonce,
48
+ ),
49
+ );
50
+ }
51
+
52
+ async function handleRscRenderingInner<TEnv>(
25
53
  ctx: HandlerContext<TEnv>,
26
54
  request: Request,
27
55
  env: TEnv,
@@ -158,7 +186,6 @@ export async function handleRscRendering<TEnv>(
158
186
  }
159
187
 
160
188
  const metricsStore = reqCtx._metricsStore;
161
- const renderStart = performance.now();
162
189
 
163
190
  // Serialize to RSC stream
164
191
  const rscSerializeStart = performance.now();
@@ -177,8 +204,7 @@ export async function handleRscRendering<TEnv>(
177
204
  );
178
205
 
179
206
  if (isRscRequest(request, url, isPartial)) {
180
- const renderDur = performance.now() - renderStart;
181
- appendMetric(metricsStore, "render:total", renderStart, renderDur);
207
+ // render:total is recorded by the observePhase wrapper around this function.
182
208
  const rscHeaders: Record<string, string> = {
183
209
  "content-type": "text/x-component;charset=utf-8",
184
210
  vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
@@ -220,16 +246,14 @@ export async function handleRscRendering<TEnv>(
220
246
  metricsStore,
221
247
  );
222
248
 
223
- const ssrRenderStart = performance.now();
224
- const htmlStream = await ssrModule.renderHTML(rscStream, {
225
- nonce,
226
- streamMode,
227
- });
228
- const ssrRenderDur = performance.now() - ssrRenderStart;
229
- appendMetric(metricsStore, "ssr-render-html", ssrRenderStart, ssrRenderDur);
230
-
231
- const renderDur = performance.now() - renderStart;
232
- appendMetric(metricsStore, "render:total", renderStart, renderDur);
249
+ // ssr-render-html metric + rango.ssr span from one boundary. render:total is
250
+ // recorded by the observePhase wrapper around this function.
251
+ const htmlStream = await observeStreamingPhase(PHASES.ssr, () =>
252
+ ssrModule.renderHTML(rscStream, {
253
+ nonce,
254
+ streamMode,
255
+ }),
256
+ );
233
257
 
234
258
  return createResponseWithMergedHeaders(htmlStream, {
235
259
  headers: { "content-type": "text/html;charset=utf-8" },
@@ -20,6 +20,7 @@ import {
20
20
  setRequestContextParams,
21
21
  } from "../server/request-context.js";
22
22
  import { appendMetric } from "../router/metrics.js";
23
+ import { observeStreamingPhase, PHASES } from "../router/instrument.js";
23
24
  import type { RscPayload } from "./types.js";
24
25
  import {
25
26
  hasBodyContent,
@@ -256,7 +257,32 @@ export async function executeServerAction<TEnv>(
256
257
  * provide. Redirects are the only non-partial outcome and are handled via
257
258
  * X-RSC-Redirect headers before Flight deserialization.
258
259
  */
259
- export async function revalidateAfterAction<TEnv>(
260
+ export function revalidateAfterAction<TEnv>(
261
+ ctx: HandlerContext<TEnv>,
262
+ request: Request,
263
+ env: TEnv,
264
+ url: URL,
265
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
266
+ continuation: ActionContinuation,
267
+ ): Promise<Response> {
268
+ // Instrument the action-revalidation render through the unified phase API,
269
+ // exactly like a normal navigation render (handleRscRendering). It records
270
+ // "render:total" AND opens "rango.render" from one boundary covering
271
+ // matchPartial -> serialize, so the revalidation loaders' rango.loader spans
272
+ // nest under a rango.render parent instead of dangling at the request root.
273
+ return observeStreamingPhase(PHASES.render, () =>
274
+ revalidateAfterActionInner(
275
+ ctx,
276
+ request,
277
+ env,
278
+ url,
279
+ handleStore,
280
+ continuation,
281
+ ),
282
+ );
283
+ }
284
+
285
+ async function revalidateAfterActionInner<TEnv>(
260
286
  ctx: HandlerContext<TEnv>,
261
287
  request: Request,
262
288
  env: TEnv,
@@ -335,13 +361,8 @@ export async function revalidateAfterAction<TEnv>(
335
361
  });
336
362
  const rscSerializeDur = performance.now() - renderStart;
337
363
  // This measures synchronous stream creation, not end-to-end stream consumption.
364
+ // render:total is recorded by the observePhase wrapper in revalidateAfterAction.
338
365
  appendMetric(metricsStore, "rsc-serialize", renderStart, rscSerializeDur);
339
- appendMetric(
340
- metricsStore,
341
- "render:total",
342
- renderStart,
343
- performance.now() - renderStart,
344
- );
345
366
 
346
367
  return createResponseWithMergedHeaders(rscStream, {
347
368
  status: actionStatus,
@@ -504,7 +504,10 @@ export async function renderSegments(
504
504
  // slot name is user-controlled (`@${string}`) and may contain an uppercase "D"
505
505
  // (e.g. "@Detail"). Strip from the first `D<index>.` separator so the slot name
506
506
  // is preserved; splitting on a bare "D" mis-cut "@Detail" to "@" and silently
507
- // dropped the loader's data.
507
+ // dropped the loader's data. The first-`D<index>.` strip is only correct because
508
+ // slot names cannot contain "." -- assertValidSlotName (route-definition/
509
+ // dsl-helpers.ts) rejects a "." at definition time, so a name like "@D3.foo"
510
+ // (which WOULD mis-cut here) can never reach this function.
508
511
  function loaderParentId(loaderSegmentId: string): string {
509
512
  return loaderSegmentId.replace(/D\d+\..*$/, "");
510
513
  }
@@ -41,11 +41,13 @@ import {
41
41
  } from "./handle-store.js";
42
42
  import { isHandle } from "../handle.js";
43
43
  import { withDefer } from "../defer.js";
44
- import { track, type MetricsStore } from "./context.js";
44
+ import { type MetricsStore } from "./context.js";
45
+ import { observePhase, PHASES } from "../router/instrument.js";
45
46
  import { getFetchableLoader } from "./fetchable-loader-store.js";
46
47
  import type { SegmentCacheStore } from "../cache/types.js";
47
48
  import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
48
49
  import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
50
+ import type { ResolvedTracing } from "../router/tracing.js";
49
51
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
50
52
  import { THEME_COOKIE } from "../theme/constants.js";
51
53
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
@@ -363,6 +365,19 @@ export interface RequestContext<
363
365
  /** @internal Request-scoped performance metrics store */
364
366
  _metricsStore?: MetricsStore;
365
367
 
368
+ /** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
369
+ _tracing?: ResolvedTracing;
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
+
366
381
  /** @internal Router basename for this request (used by redirect()) */
367
382
  _basename?: string;
368
383
 
@@ -1114,10 +1129,13 @@ export function createUseFunction<TEnv>(
1114
1129
  },
1115
1130
  };
1116
1131
 
1117
- const doneLoader = track(`loader:${loader.$$id}`, 2);
1118
- const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
1119
- doneLoader();
1120
- });
1132
+ // Meter through the same unified phase API as the loader-resolution funnel
1133
+ // (observePhase), so a loader resolved via this base request-context ctx.use
1134
+ // co-emits the "loader:<id>" perf metric AND the "rango.loader" span — no
1135
+ // drift between the two ctx.use implementations.
1136
+ const promise = observePhase(PHASES.loader(loader.$$id), () =>
1137
+ Promise.resolve(loaderFn(loaderCtx)),
1138
+ );
1121
1139
 
1122
1140
  loaderPromises.set(loader.$$id, promise);
1123
1141