@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.
- package/dist/bin/rango.js +5 -1
- package/dist/vite/index.js +55 -40
- package/package.json +23 -19
- package/skills/observability/SKILL.md +39 -4
- package/skills/prerender/SKILL.md +30 -11
- package/skills/router-setup/SKILL.md +23 -3
- package/src/build/route-types/codegen.ts +12 -1
- package/src/cache/cache-scope.ts +20 -0
- package/src/cloudflare/index.ts +11 -0
- package/src/cloudflare/tracing.ts +112 -0
- package/src/index.rsc.ts +19 -2
- package/src/index.ts +16 -1
- package/src/route-definition/dsl-helpers.ts +19 -0
- package/src/router/instrument.ts +440 -0
- package/src/router/loader-resolution.ts +15 -10
- package/src/router/match-middleware/cache-lookup.ts +9 -14
- package/src/router/match-middleware/cache-store.ts +12 -0
- package/src/router/middleware.ts +23 -2
- package/src/router/prerender-match.ts +5 -2
- package/src/router/router-context.ts +2 -1
- package/src/router/router-interfaces.ts +8 -0
- package/src/router/router-options.ts +58 -4
- package/src/router/segment-resolution/fresh.ts +15 -18
- package/src/router/segment-resolution/helpers.ts +6 -0
- package/src/router/segment-resolution/loader-cache.ts +5 -0
- package/src/router/segment-resolution/revalidation.ts +9 -18
- package/src/router/segment-wrappers.ts +3 -2
- package/src/router/telemetry-otel.ts +161 -179
- package/src/router/tracing.ts +203 -0
- package/src/router.ts +9 -0
- package/src/rsc/handler.ts +140 -134
- package/src/rsc/loader-fetch.ts +7 -1
- package/src/rsc/progressive-enhancement.ts +9 -2
- package/src/rsc/rsc-rendering.ts +38 -14
- package/src/rsc/server-action.ts +28 -7
- package/src/segment-system.tsx +4 -1
- package/src/server/request-context.ts +23 -5
- package/src/vite/discovery/prerender-collection.ts +26 -37
- package/src/vite/discovery/state.ts +6 -0
- package/src/vite/plugin-types.ts +25 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +10 -0
- package/src/vite/rango.ts +1 -0
- package/src/vite/router-discovery.ts +9 -3
- 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 {
|
|
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
|
-
|
|
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 =
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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 {
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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;
|
package/src/router/middleware.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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.
|