@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
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Span tracing hook (platform-agnostic).
3
+ *
4
+ * The core router emits its existing performance phases (request, middleware, action,
5
+ * loaders, render, ssr) as spans by calling traceSpan() at a small set of
6
+ * execution boundaries. When no tracing is configured the call is a direct
7
+ * pass-through: fn is invoked with a no-op span, with no wrapper and no
8
+ * allocation, so a non-traced request behaves exactly as before.
9
+ *
10
+ * A platform integration supplies a SpanRunner that wraps fn in a real span.
11
+ * Two runners ship: the Cloudflare one (createCloudflareTracing in
12
+ * src/cloudflare/tracing.ts), which bridges onto executionContext.tracing.
13
+ * enterSpan, and the OTel one (createOTelTracing in router/telemetry-otel.ts),
14
+ * which bridges onto tracer.startActiveSpan. Both wrap the actual work — not a
15
+ * post-hoc event — so spans nest by async context and the platform's automatic
16
+ * spans (KV/D1/fetch) nest under the right phase.
17
+ *
18
+ * traceSpan() below is the low-level wrap primitive. It is INTERNAL: the only
19
+ * caller is observePhase() (instrument.ts), the single phase-instrumentation
20
+ * API, which co-emits the span AND the debugPerformance perf metric from one
21
+ * wrap site (or just the span, for metric:false phases) so the two surfaces
22
+ * can't drift. Every router phase routes through observePhase via the PHASES
23
+ * registry; do not call traceSpan directly from new code.
24
+ *
25
+ * Phase coverage (all via observePhase): rango.request (span-only; handler:total
26
+ * metered directly), rango.middleware (span-only incl. intercept middleware;
27
+ * pre/post metered directly), rango.action (action:<id>; server-action
28
+ * execution, JS + no-JS/PE), rango.loader (loader:<id>; single metering site at
29
+ * useLoader, plus the fetchable path), rango.render (render:total:<route>; normal AND
30
+ * action-revalidation renders), rango.ssr (ssr:render-html).
31
+ *
32
+ * 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.
39
+ *
40
+ * Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
41
+ * platform-specific bridge lives at the edge behind the SpanRunner contract.
42
+ */
43
+
44
+ /**
45
+ * Minimal span handle passed to traced work. Structurally compatible with both
46
+ * Cloudflare's `Span` and OTel's `Span` (only setAttribute is used here).
47
+ */
48
+ export interface TraceSpan {
49
+ setAttribute(key: string, value: string | number | boolean): void;
50
+ }
51
+
52
+ /**
53
+ * Wraps a unit of work in a span. A runner MUST invoke fn exactly once, pass it
54
+ * a span, return fn's result unchanged, and propagate thrown errors / rejected
55
+ * promises unchanged. When fn returns a promise the span ends once it settles.
56
+ */
57
+ export type SpanRunner = <T>(name: string, fn: (span: TraceSpan) => T) => T;
58
+
59
+ /** The router phases that can be wrapped in a span. */
60
+ export type TracePhase =
61
+ | "request"
62
+ | "middleware"
63
+ | "action"
64
+ | "loader"
65
+ | "render"
66
+ | "ssr";
67
+
68
+ /** Per-phase span toggles. Omitted phases default to enabled. */
69
+ export interface TracePhaseToggles {
70
+ request?: boolean;
71
+ middleware?: boolean;
72
+ action?: boolean;
73
+ loader?: boolean;
74
+ render?: boolean;
75
+ ssr?: boolean;
76
+ }
77
+
78
+ /**
79
+ * Value passed to `createRouter({ tracing })`. Produced by a platform factory
80
+ * such as `createCloudflareTracing()`.
81
+ */
82
+ export interface RouterTracingConfig {
83
+ /** Platform span runner. */
84
+ runner: SpanRunner;
85
+ /** Master switch. Defaults to true when a config object is provided. */
86
+ enabled?: boolean;
87
+ /** Per-phase span toggles. */
88
+ spans?: TracePhaseToggles;
89
+ }
90
+
91
+ /**
92
+ * Resolved tracing state stored on the router/request context. `undefined`
93
+ * means tracing is fully disabled and every traceSpan() call is a pass-through.
94
+ */
95
+ export interface ResolvedTracing {
96
+ runner: SpanRunner;
97
+ phases: Record<TracePhase, boolean>;
98
+ }
99
+
100
+ /** Shared no-op span. setAttribute is a no-op so disabled call sites stay free. */
101
+ export const NOOP_TRACE_SPAN: TraceSpan = {
102
+ setAttribute() {},
103
+ };
104
+
105
+ const ALL_PHASES_ON: Record<TracePhase, boolean> = {
106
+ request: true,
107
+ middleware: true,
108
+ action: true,
109
+ loader: true,
110
+ render: true,
111
+ ssr: true,
112
+ };
113
+
114
+ /**
115
+ * Resolve a user-supplied tracing config into the fast internal form, or
116
+ * `undefined` when tracing is off (no config, `enabled: false`, or no runner).
117
+ */
118
+ export function resolveTracing(
119
+ config: RouterTracingConfig | undefined,
120
+ ): ResolvedTracing | undefined {
121
+ if (
122
+ !config ||
123
+ config.enabled === false ||
124
+ typeof config.runner !== "function"
125
+ ) {
126
+ return undefined;
127
+ }
128
+ const spans = config.spans;
129
+ return {
130
+ runner: config.runner,
131
+ phases: spans
132
+ ? {
133
+ request: spans.request ?? true,
134
+ middleware: spans.middleware ?? true,
135
+ action: spans.action ?? true,
136
+ loader: spans.loader ?? true,
137
+ render: spans.render ?? true,
138
+ ssr: spans.ssr ?? true,
139
+ }
140
+ : ALL_PHASES_ON,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Wrap `fn` in a span for `phase`. When tracing is off (or the phase is
146
+ * disabled) fn runs directly with a no-op span — identical to the untraced
147
+ * path. Otherwise the platform runner wraps fn so the span covers the real
148
+ * work and nests by async context.
149
+ */
150
+ export function traceSpan<T>(
151
+ tracing: ResolvedTracing | undefined,
152
+ phase: TracePhase,
153
+ name: string,
154
+ fn: (span: TraceSpan) => T,
155
+ ): T {
156
+ if (tracing === undefined || tracing.phases[phase] === false) {
157
+ return fn(NOOP_TRACE_SPAN);
158
+ }
159
+ return tracing.runner(name, fn);
160
+ }
161
+
162
+ /**
163
+ * Run `fn` once and invoke `onSettle` exactly once when it terminates — on a
164
+ * synchronous return, a synchronous throw, an async resolution, or an async
165
+ * rejection. `onSettle` receives the error (or `undefined` on success). fn's
166
+ * value is returned and errors propagate unchanged.
167
+ *
168
+ * Centralizes the run-once-then-settle control flow shared by the two span
169
+ * surfaces: observePhase records the perf metric on settle, and the OTel runner
170
+ * ends (or error-marks) the span on settle. The Cloudflare runner delegates
171
+ * settling to enterSpan, so it does not use this.
172
+ */
173
+ export function runThenSettle<T>(
174
+ fn: () => T,
175
+ onSettle: (error: unknown) => void,
176
+ ): T {
177
+ let out: T;
178
+ try {
179
+ out = fn();
180
+ } catch (error) {
181
+ onSettle(error);
182
+ throw error;
183
+ }
184
+ if (out instanceof Promise) {
185
+ return out.then(
186
+ (value) => {
187
+ onSettle(undefined);
188
+ return value;
189
+ },
190
+ (error) => {
191
+ onSettle(error);
192
+ throw error;
193
+ },
194
+ ) as unknown as T;
195
+ }
196
+ onSettle(undefined);
197
+ return out;
198
+ }
package/src/router.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  traverseBack,
74
74
  } from "./router/pattern-matching.js";
75
75
  import { resolveSink, safeEmit, getRequestId } from "./router/telemetry.js";
76
+ import { resolveTracing } from "./router/tracing.js";
76
77
  import { evaluateRevalidation } from "./router/revalidation.js";
77
78
  import {
78
79
  type RouterContext,
@@ -152,6 +153,7 @@ export function createRouter<TEnv = any>(
152
153
  warmup: warmupOption,
153
154
  allowDebugManifest: allowDebugManifestOption = false,
154
155
  telemetry: telemetrySink,
156
+ tracing: tracingOption,
155
157
  ssr: ssrOption,
156
158
  timeout: timeoutShorthand,
157
159
  timeouts: timeoutsOption,
@@ -178,6 +180,10 @@ export function createRouter<TEnv = any>(
178
180
  // Resolve telemetry sink (no-op when not configured)
179
181
  const telemetry = resolveSink(telemetrySink);
180
182
 
183
+ // Resolve span tracing (undefined when not configured; every traceSpan() call
184
+ // is then a direct pass-through with zero behavior change).
185
+ const resolvedTracing = resolveTracing(tracingOption);
186
+
181
187
  // Resolve cache profiles: merge user config with the guaranteed default
182
188
  // profile. This resolved map is threaded onto each request context; the
183
189
  // "use cache: <profile>" runtime path reads it request-scoped.
@@ -968,6 +974,9 @@ export function createRouter<TEnv = any>(
968
974
  // Expose router-wide performance debugging for request-level metrics setup
969
975
  debugPerformance,
970
976
 
977
+ // Expose resolved span tracing for the handler (Cloudflare custom spans)
978
+ tracing: resolvedTracing,
979
+
971
980
  // Expose debug manifest flag for handler
972
981
  allowDebugManifest: allowDebugManifestOption,
973
982
 
@@ -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,7 @@ import {
84
82
  appendMetric,
85
83
  buildMetricsTiming,
86
84
  } from "../router/metrics.js";
85
+ import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
87
86
  import {
88
87
  startSSRSetup,
89
88
  getSSRSetup,
@@ -244,24 +243,16 @@ export function createRSCHandler<
244
243
  metadata: { timeout: true, phase, durationMs },
245
244
  });
246
245
 
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
- }
246
+ observeEvent({
247
+ type: "request.timeout",
248
+ timestamp: performance.now(),
249
+ phase,
250
+ pathname: url.pathname,
251
+ routeKey,
252
+ actionId,
253
+ durationMs,
254
+ customHandler: !!router.onTimeout,
255
+ });
265
256
 
266
257
  if (router.onTimeout) {
267
258
  try {
@@ -499,86 +490,103 @@ export function createRSCHandler<
499
490
  // Store basename on request context (scoped per-request via existing ALS)
500
491
  requestContext._basename = router.basename;
501
492
 
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
- );
493
+ // Resolved span tracing for this request (read at each traced phase).
494
+ requestContext._tracing = router.tracing;
495
+
496
+ // The "rango.request" span is opened inside the request context so the
497
+ // Cloudflare runner can read executionContext.tracing, and so every nested
498
+ // 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.
502
+ return runWithRequestContext(requestContext, () =>
503
+ observePhase(PHASES.request, async (span) => {
504
+ span.setAttribute("http.method", request.method);
505
+ // The matched route template is not known until match() runs later, so
506
+ // emit the concrete path as url.path (low-level), NOT http.route — the
507
+ // latter is reserved for the low-cardinality template (OTel convention).
508
+ span.setAttribute("url.path", url.pathname);
509
+
510
+ // Core handler logic (wrapped by middleware)
511
+ const coreHandler = async (): Promise<Response> => {
512
+ return coreRequestHandler(request, env, url, variables, nonce);
513
+ };
519
514
 
520
- if (
521
- url.searchParams.has("_rsc_partial") ||
522
- url.searchParams.has("_rsc_action")
523
- ) {
524
- const intercepted = interceptRedirectForPartial(
525
- mwResponse,
526
- createRedirectFlightResponse,
515
+ // Execute middleware chain if any, otherwise call core handler directly
516
+ let response: Response;
517
+ if (matchedMiddleware.length > 0) {
518
+ const mwResponse = await executeMiddleware(
519
+ matchedMiddleware,
520
+ request,
521
+ env,
522
+ variables,
523
+ coreHandler,
524
+ createReverseFunction(getRequiredRouteMap()),
527
525
  );
528
- response = intercepted ?? finalizeResponse(mwResponse);
526
+
527
+ if (
528
+ url.searchParams.has("_rsc_partial") ||
529
+ url.searchParams.has("_rsc_action")
530
+ ) {
531
+ const intercepted = interceptRedirectForPartial(
532
+ mwResponse,
533
+ createRedirectFlightResponse,
534
+ );
535
+ response = intercepted ?? finalizeResponse(mwResponse);
536
+ } else {
537
+ response = finalizeResponse(mwResponse);
538
+ }
529
539
  } else {
530
- response = finalizeResponse(mwResponse);
540
+ response = await coreHandler();
531
541
  }
532
- } else {
533
- response = await coreHandler();
534
- }
535
542
 
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
- }
543
+ // Finalize metrics after all middleware (including post-next work)
544
+ // has completed so :post spans are captured in the timeline.
545
+ // Handler timing parts are always emitted (even without debug metrics)
546
+ // so non-debug requests still get bootstrap Server-Timing entries.
547
+ const handlerTimingArr: string[] = variables.__handlerTiming || [];
548
+ // Preserve any existing Server-Timing set by response routes or middleware
549
+ const existingTiming = response.headers.get("Server-Timing");
550
+ const timingParts = existingTiming
551
+ ? [existingTiming, ...handlerTimingArr]
552
+ : [...handlerTimingArr];
553
+
554
+ const metricsStore = requestContext._metricsStore;
555
+ if (metricsStore) {
556
+ // When the store was created at handler start (earlyMetricsStore),
557
+ // handler:total covers the full request. When ctx.debugPerformance()
558
+ // created the store mid-request, use its requestStart to avoid a
559
+ // negative startTime offset.
560
+ const totalStart = earlyMetricsStore
561
+ ? handlerStart
562
+ : metricsStore.requestStart;
563
+ appendMetric(
564
+ metricsStore,
565
+ "handler:total",
566
+ totalStart,
567
+ performance.now() - totalStart,
568
+ );
569
+ const metricsTiming = buildMetricsTiming(
570
+ request.method,
571
+ url.pathname,
572
+ metricsStore,
573
+ );
574
+ if (metricsTiming) timingParts.push(metricsTiming);
575
+ }
569
576
 
570
- const fullTiming = timingParts.join(", ");
571
- if (fullTiming && !isWebSocketUpgradeResponse(response)) {
572
- response.headers.set("Server-Timing", fullTiming);
573
- }
577
+ const fullTiming = timingParts.join(", ");
578
+ if (fullTiming && !isWebSocketUpgradeResponse(response)) {
579
+ response.headers.set("Server-Timing", fullTiming);
580
+ }
574
581
 
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
- });
582
+ // Single open-redirect chokepoint: every response (PE, full-page,
583
+ // middleware short-circuit, response-route) funnels through here, so
584
+ // guarding browser-followed (3xx) redirects once covers them all and any
585
+ // future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
586
+ // through untouched (validated client-side instead).
587
+ return guardOutgoingRedirect(response, url.origin, router.basename);
588
+ }),
589
+ );
582
590
  };
583
591
 
584
592
  // Core request handling logic (separated for middleware wrapping).
@@ -715,23 +723,15 @@ export function createRSCHandler<
715
723
  },
716
724
  });
717
725
 
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
- }
726
+ observeEvent({
727
+ type: "request.origin-rejected",
728
+ timestamp: performance.now(),
729
+ method: request.method,
730
+ pathname: url.pathname,
731
+ phase: originPhase,
732
+ origin: request.headers.get("origin"),
733
+ host: request.headers.get("host"),
734
+ });
735
735
 
736
736
  return originResult;
737
737
  }
@@ -773,23 +773,15 @@ export function createRSCHandler<
773
773
  params: reqCtx.params as Record<string, string>,
774
774
  handledByBoundary: true,
775
775
  });
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
- }
776
+ observeEvent({
777
+ type: "handler.error",
778
+ timestamp: performance.now(),
779
+ error,
780
+ handledByBoundary: true,
781
+ pathname: url.pathname,
782
+ routeKey: reqCtx._routeName,
783
+ params: reqCtx.params as Record<string, string>,
784
+ });
793
785
  };
794
786
 
795
787
  // Set route params early so all execution paths can access ctx.params.
@@ -894,14 +886,20 @@ export function createRSCHandler<
894
886
  if (plan.mode === "action") {
895
887
  let actionContinuation: ActionContinuation | undefined;
896
888
  try {
889
+ // Instrument the action execution as its own phase (action:<actionId> +
890
+ // rango.action), so a POST shows the mutation time AND which action ran,
891
+ // not just the downstream revalidation render. The action's own
892
+ // loaders/fetches nest under rango.action.
897
893
  const actionOutcome = await withTimeout(
898
- executeServerAction(
899
- handlerCtx,
900
- request,
901
- env,
902
- url,
903
- plan.actionId,
904
- handleStore,
894
+ observePhase(PHASES.action(plan.actionId), () =>
895
+ executeServerAction(
896
+ handlerCtx,
897
+ request,
898
+ env,
899
+ url,
900
+ plan.actionId,
901
+ handleStore,
902
+ ),
905
903
  ),
906
904
  router.timeouts.actionMs,
907
905
  "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);