@rangojs/router 0.0.0-experimental.127 → 0.0.0-experimental.129
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/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/observability/SKILL.md +27 -1
- package/skills/router-setup/SKILL.md +12 -0
- package/src/cloudflare/tracing.ts +16 -13
- package/src/router/instrument.ts +263 -11
- package/src/router/middleware.ts +3 -3
- package/src/router/segment-resolution/fresh.ts +18 -4
- package/src/router/segment-resolution/helpers.ts +10 -5
- package/src/router/segment-resolution/revalidation.ts +10 -4
- package/src/router/tracing.ts +19 -8
- package/src/rsc/handler.ts +13 -5
- package/src/rsc/rsc-rendering.ts +3 -3
- package/src/rsc/server-action.ts +2 -2
- package/src/server/request-context.ts +10 -0
package/dist/vite/index.js
CHANGED
|
@@ -2133,7 +2133,7 @@ import { resolve } from "node:path";
|
|
|
2133
2133
|
// package.json
|
|
2134
2134
|
var package_default = {
|
|
2135
2135
|
name: "@rangojs/router",
|
|
2136
|
-
version: "0.0.0-experimental.
|
|
2136
|
+
version: "0.0.0-experimental.129",
|
|
2137
2137
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2138
2138
|
keywords: [
|
|
2139
2139
|
"react",
|
package/package.json
CHANGED
|
@@ -95,6 +95,31 @@ const router = createRouter({
|
|
|
95
95
|
});
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
On **Cloudflare Workers**, use `createCloudflareTracing` for the `tracing` slot
|
|
99
|
+
instead — it emits the same phases as native Cloudflare custom spans (in the
|
|
100
|
+
Workers trace waterfall, next to the automatic KV/D1/fetch spans), with no
|
|
101
|
+
`@opentelemetry/api` dependency:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { createRouter } from "@rangojs/router";
|
|
105
|
+
import { createCloudflareTracing } from "@rangojs/router/cloudflare";
|
|
106
|
+
|
|
107
|
+
const router = createRouter({
|
|
108
|
+
document: Document,
|
|
109
|
+
urls: urlpatterns,
|
|
110
|
+
tracing: createCloudflareTracing(), // all phases on by default
|
|
111
|
+
// tracing: createCloudflareTracing({ spans: { ssr: false } }), // toggle phases
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Both factories return a `RouterTracingConfig` for the same `tracing` slot;
|
|
116
|
+
`telemetry` stays independent (events only, no phase spans). Phase spans:
|
|
117
|
+
`rango.request`, `rango.middleware`, `rango.action`, `rango.loader`,
|
|
118
|
+
`rango.render`, `rango.ssr` — the same phases the `debugPerformance` timeline
|
|
119
|
+
shows, co-emitted from one site. Off-platform (no Cloudflare tracing destination
|
|
120
|
+
/ no OTel SDK) every span call is a transparent pass-through, so the request
|
|
121
|
+
behaves as if tracing were off.
|
|
122
|
+
|
|
98
123
|
Custom sinks implement `emit(event)`:
|
|
99
124
|
|
|
100
125
|
```typescript
|
|
@@ -112,7 +137,8 @@ const router = createRouter({
|
|
|
112
137
|
```
|
|
113
138
|
|
|
114
139
|
Events include `request.start/end/error`, `loader.start/end/error`,
|
|
115
|
-
`handler.error`, `cache.decision`,
|
|
140
|
+
`handler.error`, `cache.decision`, `revalidation.decision`, `request.timeout`,
|
|
141
|
+
and `request.origin-rejected`.
|
|
116
142
|
|
|
117
143
|
## Debugging revalidation and stale data
|
|
118
144
|
|
|
@@ -488,6 +488,18 @@ const router = createRouter({
|
|
|
488
488
|
});
|
|
489
489
|
```
|
|
490
490
|
|
|
491
|
+
```typescript
|
|
492
|
+
// On Cloudflare Workers, swap the tracing factory for native custom spans
|
|
493
|
+
// (no @opentelemetry/api dependency); the telemetry slot is unchanged.
|
|
494
|
+
import { createCloudflareTracing } from "@rangojs/router/cloudflare";
|
|
495
|
+
|
|
496
|
+
const router = createRouter({
|
|
497
|
+
document: Document,
|
|
498
|
+
urls: urlpatterns,
|
|
499
|
+
tracing: createCloudflareTracing(), // { spans: { ssr: false } } to toggle phases
|
|
500
|
+
});
|
|
501
|
+
```
|
|
502
|
+
|
|
491
503
|
```typescript
|
|
492
504
|
// Custom sink
|
|
493
505
|
const router = createRouter({
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Cloudflare custom-spans integration.
|
|
3
3
|
*
|
|
4
4
|
* Bridges the router's performance phases (request, middleware, action,
|
|
5
|
-
* loaders, render, ssr) onto Cloudflare Workers custom spans so they show
|
|
6
|
-
* trace waterfall and OpenTelemetry exports next to the platform's
|
|
7
|
-
* spans (KV reads, D1 queries, fetch calls), with correct
|
|
5
|
+
* loaders, handler, render, ssr) onto Cloudflare Workers custom spans so they show
|
|
6
|
+
* up in the trace waterfall and OpenTelemetry exports next to the platform's
|
|
7
|
+
* automatic spans (KV reads, D1 queries, fetch calls), with correct nesting.
|
|
8
8
|
*
|
|
9
9
|
* Usage (Cloudflare preset only):
|
|
10
10
|
*
|
|
@@ -25,14 +25,17 @@
|
|
|
25
25
|
* request behaves exactly as if tracing were off. Whether spans are actually
|
|
26
26
|
* recorded is governed by the `observability`/tracing block in wrangler config.
|
|
27
27
|
*
|
|
28
|
-
* Span duration note: a
|
|
29
|
-
* promise) settles
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
28
|
+
* Span duration note: enterSpan ends a span when its callback's returned value
|
|
29
|
+
* (or promise) settles. The streaming phases (request/render/ssr/middleware) are
|
|
30
|
+
* wrapped (in instrument.ts) so their callback awaits the response body's drain
|
|
31
|
+
* before settling — the constructed Response is handed to the client immediately,
|
|
32
|
+
* but the callback (hence the SPAN) stays open until the body finishes draining.
|
|
33
|
+
* So these spans cover the full streamed request and loader/Suspense children
|
|
34
|
+
* that resolve mid-stream nest under a still-open parent. This uses only the
|
|
35
|
+
* typed enterSpan API; no startActiveSpan/end. The perf METRICS (render:total,
|
|
36
|
+
* the middleware own-time, handler:total) stay construction-bound — they ship in
|
|
37
|
+
* the Server-Timing header, flushed before drain — so a span reads at least as
|
|
38
|
+
* long as its same-named metric, the difference being post-construction streaming.
|
|
36
39
|
*/
|
|
37
40
|
|
|
38
41
|
import { _getRequestContext } from "../server/request-context.js";
|
|
@@ -92,8 +95,8 @@ const cloudflareSpanRunner: SpanRunner = (name, fn) => {
|
|
|
92
95
|
/**
|
|
93
96
|
* Create the tracing config for a Cloudflare router. Pass the result to
|
|
94
97
|
* `createRouter({ tracing })`. Spans are emitted for the request, middleware,
|
|
95
|
-
* action, loaders, render, and ssr phases; pass `spans` to turn
|
|
96
|
-
* phases off.
|
|
98
|
+
* action, loaders, handler, render, and ssr phases; pass `spans` to turn
|
|
99
|
+
* individual phases off.
|
|
97
100
|
*
|
|
98
101
|
* @see createOTelTracing (`@rangojs/router`) for the same slot on any platform
|
|
99
102
|
* with an OpenTelemetry SDK.
|
package/src/router/instrument.ts
CHANGED
|
@@ -35,13 +35,16 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
|
35
35
|
import { getRouterContext } from "./router-context.js";
|
|
36
36
|
import { resolveSink, safeEmit, type TelemetryEvent } from "./telemetry.js";
|
|
37
37
|
import { appendMetric } from "./metrics.js";
|
|
38
|
+
import { type MetricsStore } from "../server/context.js";
|
|
38
39
|
import {
|
|
39
40
|
NOOP_TRACE_SPAN,
|
|
40
41
|
traceSpan,
|
|
41
42
|
runThenSettle,
|
|
42
43
|
type TracePhase,
|
|
43
44
|
type TraceSpan,
|
|
45
|
+
type ResolvedTracing,
|
|
44
46
|
} from "./tracing.js";
|
|
47
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* Perf-metric boundary for a phase, or `false` for span-only. `false` means the
|
|
@@ -63,6 +66,25 @@ export interface PhaseSpec {
|
|
|
63
66
|
spanName: string;
|
|
64
67
|
/** Span attributes set automatically when the span opens. */
|
|
65
68
|
attributes?: Record<string, string | number | boolean>;
|
|
69
|
+
/**
|
|
70
|
+
* Span attributes resolved AFTER the wrapped work runs (so they can read state
|
|
71
|
+
* that only exists once the work is underway, e.g. the matched route name).
|
|
72
|
+
* Applied for streaming phases once fn has constructed its value. Return
|
|
73
|
+
* undefined to add nothing.
|
|
74
|
+
*/
|
|
75
|
+
lazyAttributes?: () => Record<string, string | number | boolean> | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The matched route name for the current request, or undefined when there is no
|
|
80
|
+
* named route (unmatched / auto-generated). Shared by the render phase's metric
|
|
81
|
+
* label and its rango.route span attribute so the two can't disagree.
|
|
82
|
+
*/
|
|
83
|
+
function currentRouteName(): string | undefined {
|
|
84
|
+
const routeName = _getRequestContext()?._routeName;
|
|
85
|
+
return routeName && !isAutoGeneratedRouteName(routeName)
|
|
86
|
+
? routeName
|
|
87
|
+
: undefined;
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
/**
|
|
@@ -112,6 +134,18 @@ export const PHASES = {
|
|
|
112
134
|
attributes: { "rango.loader_id": id },
|
|
113
135
|
}),
|
|
114
136
|
|
|
137
|
+
/** One segment route/layout handler execution (the component/handler that
|
|
138
|
+
* produces a segment). Span only — the perf metric (handler:<id>) is owned by
|
|
139
|
+
* the legacy track() at the same call site, so observePhase here adds the
|
|
140
|
+
* rango.handler span without double-recording. `id` is the segment id, carried
|
|
141
|
+
* as the rango.segment_id attribute to match the handler:<id> perf row. */
|
|
142
|
+
handler: (id: string): PhaseSpec => ({
|
|
143
|
+
metric: false,
|
|
144
|
+
tracePhase: "handler",
|
|
145
|
+
spanName: "rango.handler",
|
|
146
|
+
attributes: { "rango.segment_id": id },
|
|
147
|
+
}),
|
|
148
|
+
|
|
115
149
|
/** Whole render phase: match + serialize + SSR. The metric label is resolved
|
|
116
150
|
* lazily at record time (after match has set the route name) so the perf
|
|
117
151
|
* timeline shows WHICH route rendered: `render:total:<routeName>`, falling back
|
|
@@ -119,14 +153,20 @@ export const PHASES = {
|
|
|
119
153
|
render: {
|
|
120
154
|
metric: {
|
|
121
155
|
label: () => {
|
|
122
|
-
const routeName =
|
|
123
|
-
return routeName
|
|
124
|
-
? `render:total:${routeName}`
|
|
125
|
-
: "render:total";
|
|
156
|
+
const routeName = currentRouteName();
|
|
157
|
+
return routeName ? `render:total:${routeName}` : "render:total";
|
|
126
158
|
},
|
|
127
159
|
},
|
|
128
160
|
tracePhase: "render",
|
|
129
161
|
spanName: "rango.render",
|
|
162
|
+
// Tag the render span with the matched route so the Cloudflare/OTel waterfall
|
|
163
|
+
// shows WHICH route rendered (rango.render + rango.route=index), resolved
|
|
164
|
+
// after match has run. Kept an attribute (not baked into the span name) so the
|
|
165
|
+
// span name stays low-cardinality and aggregatable across routes.
|
|
166
|
+
lazyAttributes: () => {
|
|
167
|
+
const routeName = currentRouteName();
|
|
168
|
+
return routeName ? { "rango.route": routeName } : undefined;
|
|
169
|
+
},
|
|
130
170
|
} as PhaseSpec,
|
|
131
171
|
|
|
132
172
|
/** SSR HTML render from the RSC stream. Colon-delimited like the other ssr:*
|
|
@@ -138,6 +178,29 @@ export const PHASES = {
|
|
|
138
178
|
} as PhaseSpec,
|
|
139
179
|
} as const;
|
|
140
180
|
|
|
181
|
+
/** Apply a phase spec's static attributes to a span (the no-op span ignores them). */
|
|
182
|
+
function applyAttributes(
|
|
183
|
+
span: TraceSpan,
|
|
184
|
+
attributes: Record<string, string | number | boolean>,
|
|
185
|
+
): void {
|
|
186
|
+
for (const key in attributes) span.setAttribute(key, attributes[key]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Record a phase's perf metric for the interval [start, now]. The label may be
|
|
191
|
+
* lazy (resolved here, e.g. render:total needs the route name that match sets
|
|
192
|
+
* partway through the wrapped work).
|
|
193
|
+
*/
|
|
194
|
+
function recordPhaseMetric(
|
|
195
|
+
store: MetricsStore,
|
|
196
|
+
metric: Exclude<PhaseMetric, false>,
|
|
197
|
+
start: number,
|
|
198
|
+
): void {
|
|
199
|
+
const label =
|
|
200
|
+
typeof metric.label === "function" ? metric.label() : metric.label;
|
|
201
|
+
appendMetric(store, label, start, performance.now() - start, metric.depth);
|
|
202
|
+
}
|
|
203
|
+
|
|
141
204
|
/**
|
|
142
205
|
* Instrument one unit of work: open its span AND (unless `metric: false`) record
|
|
143
206
|
* its perf metric, from a single wrap site. fn is invoked exactly once with the
|
|
@@ -145,6 +208,12 @@ export const PHASES = {
|
|
|
145
208
|
* unchanged and thrown errors / rejected promises propagate unchanged. When fn
|
|
146
209
|
* returns a promise both the metric duration and the span end when it settles.
|
|
147
210
|
*
|
|
211
|
+
* This is the boundary for NON-streaming phases (action, loader): both the span
|
|
212
|
+
* and the metric settle when their own work completes. Streaming phases (request,
|
|
213
|
+
* middleware, render, ssr) use observeRequestPhase / observeStreamingPhase, where
|
|
214
|
+
* the SPAN is held open until body-drain (valid tree) while the perf metric is
|
|
215
|
+
* still recorded at construction (Server-Timing parity).
|
|
216
|
+
*
|
|
148
217
|
* Reads the metrics store + tracing off the RequestContext ALS, which is active
|
|
149
218
|
* for the WHOLE request — contrast observeEvent, which reads the RouterContext
|
|
150
219
|
* ALS (entered later, during match).
|
|
@@ -166,7 +235,7 @@ export function observePhase<T>(
|
|
|
166
235
|
const wrapped: (span: TraceSpan) => T =
|
|
167
236
|
attributes && tracing
|
|
168
237
|
? (span) => {
|
|
169
|
-
|
|
238
|
+
applyAttributes(span, attributes);
|
|
170
239
|
return fn(span);
|
|
171
240
|
}
|
|
172
241
|
: fn;
|
|
@@ -182,13 +251,196 @@ export function observePhase<T>(
|
|
|
182
251
|
// failed loader/render still shows its timing in the perf report (parity with
|
|
183
252
|
// the old track().finally() path it replaced).
|
|
184
253
|
const start = performance.now();
|
|
185
|
-
return runThenSettle(runSpan, () =>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
254
|
+
return runThenSettle(runSpan, () => recordPhaseMetric(store, metric, start));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Re-stream `response`'s body through a pass-through that fires `onDrain` exactly
|
|
259
|
+
* once when the body finishes — on natural end, a stream error, or a client
|
|
260
|
+
* cancel (so a span can never leak on an aborted response). A bodyless response
|
|
261
|
+
* fires immediately. Only used while instrumentation is active, so the per-chunk
|
|
262
|
+
* relay cost never touches an untraced request.
|
|
263
|
+
*/
|
|
264
|
+
function instrumentResponseDrain(
|
|
265
|
+
response: Response,
|
|
266
|
+
onDrain: () => void,
|
|
267
|
+
): Response {
|
|
268
|
+
// WS-upgrade responses (status 101 / workerd `webSocket` property) must never
|
|
269
|
+
// be reconstructed: `new Response(body, { status: 101 })` throws and a copy
|
|
270
|
+
// drops the non-standard webSocket handoff (the invariant every other Response
|
|
271
|
+
// reconstruction site honors). A bodyless response has nothing to drain.
|
|
272
|
+
const source = response.body;
|
|
273
|
+
if (!source || isWebSocketUpgradeResponse(response)) {
|
|
274
|
+
onDrain();
|
|
275
|
+
return response;
|
|
276
|
+
}
|
|
277
|
+
let fired = false;
|
|
278
|
+
const fire = (): void => {
|
|
279
|
+
if (fired) return;
|
|
280
|
+
fired = true;
|
|
281
|
+
onDrain();
|
|
282
|
+
};
|
|
283
|
+
const reader = source.getReader();
|
|
284
|
+
const wrapped = new ReadableStream<Uint8Array>({
|
|
285
|
+
async pull(controller) {
|
|
286
|
+
try {
|
|
287
|
+
const { done, value } = await reader.read();
|
|
288
|
+
if (done) {
|
|
289
|
+
controller.close();
|
|
290
|
+
fire();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
controller.enqueue(value);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
controller.error(error);
|
|
296
|
+
fire();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
cancel(reason) {
|
|
300
|
+
fire();
|
|
301
|
+
return reader.cancel(reason);
|
|
302
|
+
},
|
|
191
303
|
});
|
|
304
|
+
return new Response(wrapped, response);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Shared engine for the streaming phases (request, middleware, render, ssr). It
|
|
309
|
+
* opens the span, runs fn, records the phase's perf metric at CONSTRUCTION (so it
|
|
310
|
+
* still reaches the Server-Timing header / [RSC Perf] table, both built before
|
|
311
|
+
* the body drains), hands the constructed value to the caller via a side channel
|
|
312
|
+
* (streaming preserved), then holds the span open until `drain` resolves. The
|
|
313
|
+
* SPAN therefore ends at body-drain — keeping the trace tree valid (a loader
|
|
314
|
+
* child that resolves mid-stream ends before its parent) — while the perf metric
|
|
315
|
+
* stays the construction work-time. `onDeliver` lets the request phase instrument
|
|
316
|
+
* the final body before handing it back; `onError` lets it release the barrier on
|
|
317
|
+
* failure. Fire-and-forget: the value reaches the caller via the returned
|
|
318
|
+
* promise, so the span promise's rejection is swallowed (already surfaced there).
|
|
319
|
+
*/
|
|
320
|
+
function runDrainBoundPhase<R>(
|
|
321
|
+
spec: PhaseSpec,
|
|
322
|
+
fn: (span: TraceSpan) => R | Promise<R>,
|
|
323
|
+
tracing: ResolvedTracing | undefined,
|
|
324
|
+
store: MetricsStore | undefined,
|
|
325
|
+
drain: Promise<void>,
|
|
326
|
+
onDeliver: (value: R) => R,
|
|
327
|
+
onError?: () => void,
|
|
328
|
+
): Promise<R> {
|
|
329
|
+
let deliver!: (value: R) => void;
|
|
330
|
+
let reject!: (error: unknown) => void;
|
|
331
|
+
const delivered = new Promise<R>((res, rej) => {
|
|
332
|
+
deliver = res;
|
|
333
|
+
reject = rej;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const start = performance.now();
|
|
337
|
+
const attributes = spec.attributes;
|
|
338
|
+
const metric = spec.metric;
|
|
339
|
+
const record = (): void => {
|
|
340
|
+
if (store && metric !== false) recordPhaseMetric(store, metric, start);
|
|
341
|
+
};
|
|
342
|
+
const spanCallback = async (span: TraceSpan): Promise<void> => {
|
|
343
|
+
if (attributes && tracing) applyAttributes(span, attributes);
|
|
344
|
+
let value: R;
|
|
345
|
+
try {
|
|
346
|
+
value = await fn(span);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
record(); // a failed phase still shows its (construction) timing
|
|
349
|
+
onError?.();
|
|
350
|
+
reject(error);
|
|
351
|
+
throw error; // settle the span with the error, at construction
|
|
352
|
+
}
|
|
353
|
+
// Late attributes (e.g. rango.route) — resolved now that the work has run,
|
|
354
|
+
// so they can read state like the matched route name that match sets midway.
|
|
355
|
+
const lazy =
|
|
356
|
+
tracing && spec.lazyAttributes ? spec.lazyAttributes() : undefined;
|
|
357
|
+
if (lazy) applyAttributes(span, lazy);
|
|
358
|
+
record(); // construction-bound metric, before the response/header is built
|
|
359
|
+
deliver(onDeliver(value));
|
|
360
|
+
await drain; // hold the span open until the response body drains
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
traceSpan(tracing, spec.tracePhase, spec.spanName, spanCallback).catch(
|
|
364
|
+
() => {},
|
|
365
|
+
);
|
|
366
|
+
return delivered;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The request phase (rango.request, metric:false). Owns the drain barrier: it
|
|
371
|
+
* runs fn to construct the final Response, instruments that Response's body so
|
|
372
|
+
* the barrier resolves at drain, hands the Response to the caller immediately
|
|
373
|
+
* (streaming preserved), and holds the span open until the body drains. Every
|
|
374
|
+
* streaming inner phase awaits the same barrier (via observeStreamingPhase), so
|
|
375
|
+
* the request/middleware/render/ssr chain ends at body-drain together and the
|
|
376
|
+
* trace tree is valid (no child span outlives its parent). The perf metrics
|
|
377
|
+
* (render:total, …) are recorded at construction so they still reach the
|
|
378
|
+
* Server-Timing header; only the SPANS are drain-bound. ctx.waitUntil holds the
|
|
379
|
+
* worker alive until drain so the span end runs. Pass-through when no surface is
|
|
380
|
+
* active.
|
|
381
|
+
*/
|
|
382
|
+
export function observeRequestPhase(
|
|
383
|
+
spec: PhaseSpec,
|
|
384
|
+
fn: (span: TraceSpan) => Promise<Response>,
|
|
385
|
+
): Promise<Response> {
|
|
386
|
+
const reqCtx = _getRequestContext();
|
|
387
|
+
const store = reqCtx?._metricsStore;
|
|
388
|
+
const tracing = reqCtx?._tracing;
|
|
389
|
+
|
|
390
|
+
if ((!store && !tracing) || !reqCtx) return fn(NOOP_TRACE_SPAN);
|
|
391
|
+
|
|
392
|
+
let resolveDrain!: () => void;
|
|
393
|
+
const finalDrain = new Promise<void>((resolve) => {
|
|
394
|
+
resolveDrain = resolve;
|
|
395
|
+
});
|
|
396
|
+
reqCtx._finalDrain = finalDrain;
|
|
397
|
+
|
|
398
|
+
// Keep the worker alive until the body drains, so the drain-bound span end
|
|
399
|
+
// (and the inner phases' settle) runs before the runtime can reclaim it.
|
|
400
|
+
const ec = reqCtx.executionContext;
|
|
401
|
+
if (typeof ec?.waitUntil === "function") ec.waitUntil(finalDrain);
|
|
402
|
+
|
|
403
|
+
return runDrainBoundPhase<Response>(
|
|
404
|
+
spec,
|
|
405
|
+
fn,
|
|
406
|
+
tracing,
|
|
407
|
+
store,
|
|
408
|
+
finalDrain,
|
|
409
|
+
(response) => instrumentResponseDrain(response, resolveDrain),
|
|
410
|
+
resolveDrain, // release the barrier if fn fails before constructing a body
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A streaming inner phase (rango.middleware / render / ssr). Its SPAN settles
|
|
416
|
+
* when the request's final response body drains (the barrier owned by
|
|
417
|
+
* observeRequestPhase), not when fn returns the constructed stream — so
|
|
418
|
+
* loader/Suspense children that resolve mid-stream nest under a still-open
|
|
419
|
+
* parent. fn's result is delivered at construction (streaming preserved) and the
|
|
420
|
+
* perf metric is recorded at construction (Server-Timing parity). Falls back to
|
|
421
|
+
* observePhase (construction-bound span) when there is no barrier — a
|
|
422
|
+
* non-streaming request, or instrumentation off.
|
|
423
|
+
*/
|
|
424
|
+
export function observeStreamingPhase<R>(
|
|
425
|
+
spec: PhaseSpec,
|
|
426
|
+
fn: (span: TraceSpan) => R | Promise<R>,
|
|
427
|
+
): Promise<R> {
|
|
428
|
+
const reqCtx = _getRequestContext();
|
|
429
|
+
const store = reqCtx?._metricsStore;
|
|
430
|
+
const tracing = reqCtx?._tracing;
|
|
431
|
+
const finalDrain = reqCtx?._finalDrain;
|
|
432
|
+
|
|
433
|
+
if ((!store && !tracing) || !finalDrain) {
|
|
434
|
+
return Promise.resolve(observePhase(spec, fn));
|
|
435
|
+
}
|
|
436
|
+
return runDrainBoundPhase<R>(
|
|
437
|
+
spec,
|
|
438
|
+
fn,
|
|
439
|
+
tracing,
|
|
440
|
+
store,
|
|
441
|
+
finalDrain,
|
|
442
|
+
(value) => value,
|
|
443
|
+
);
|
|
192
444
|
}
|
|
193
445
|
|
|
194
446
|
/**
|
package/src/router/middleware.ts
CHANGED
|
@@ -19,7 +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 {
|
|
22
|
+
import { observeStreamingPhase, PHASES } from "./instrument.js";
|
|
23
23
|
import { stripInternalParams } from "./handler-context.js";
|
|
24
24
|
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
25
25
|
|
|
@@ -487,7 +487,7 @@ export async function executeMiddleware<TEnv>(
|
|
|
487
487
|
// when neither surface is active.
|
|
488
488
|
let result: Response | void;
|
|
489
489
|
try {
|
|
490
|
-
result = await
|
|
490
|
+
result = await observeStreamingPhase(PHASES.middleware(metricLabel), () =>
|
|
491
491
|
entry.handler(ctx, wrappedNext),
|
|
492
492
|
);
|
|
493
493
|
} catch (error) {
|
|
@@ -664,7 +664,7 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
664
664
|
|
|
665
665
|
let result: Response | void;
|
|
666
666
|
try {
|
|
667
|
-
result = await
|
|
667
|
+
result = await observeStreamingPhase(PHASES.middleware(label), () =>
|
|
668
668
|
middleware(ctx, guardedNext),
|
|
669
669
|
);
|
|
670
670
|
} catch (error) {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { applyViewTransitionDefault } from "./view-transition-default.js";
|
|
31
31
|
import { getRouterContext } from "../router-context.js";
|
|
32
32
|
import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
|
|
33
|
+
import { observePhase, PHASES } from "../instrument.js";
|
|
33
34
|
import {
|
|
34
35
|
track,
|
|
35
36
|
RangoContext,
|
|
@@ -258,7 +259,9 @@ export async function resolveSegment<TEnv>(
|
|
|
258
259
|
!context.build && entry.liveHandler ? entry.liveHandler : entry.handler;
|
|
259
260
|
const doneRouteHandler = track(`handler:${entry.id}`, 2);
|
|
260
261
|
if (entry.loading) {
|
|
261
|
-
const result = handleHandlerResult(
|
|
262
|
+
const result = handleHandlerResult(
|
|
263
|
+
observePhase(PHASES.handler(entry.id), () => handler(context)),
|
|
264
|
+
);
|
|
262
265
|
if (result instanceof Promise) {
|
|
263
266
|
warnOnStreamedResponse(result, entry.id);
|
|
264
267
|
result.finally(doneRouteHandler).catch(() => {});
|
|
@@ -280,7 +283,9 @@ export async function resolveSegment<TEnv>(
|
|
|
280
283
|
component = result;
|
|
281
284
|
}
|
|
282
285
|
} else {
|
|
283
|
-
component = handleHandlerResult(
|
|
286
|
+
component = handleHandlerResult(
|
|
287
|
+
await observePhase(PHASES.handler(entry.id), () => handler(context)),
|
|
288
|
+
);
|
|
284
289
|
doneRouteHandler();
|
|
285
290
|
}
|
|
286
291
|
}
|
|
@@ -505,7 +510,11 @@ export async function resolveParallelEntry<TEnv>(
|
|
|
505
510
|
parallelEntry.loading !== undefined && parallelEntry.loading !== false;
|
|
506
511
|
if (hasLoadingFallback) {
|
|
507
512
|
const result =
|
|
508
|
-
typeof handler === "function"
|
|
513
|
+
typeof handler === "function"
|
|
514
|
+
? observePhase(PHASES.handler(`${parallelEntry.id}.${slot}`), () =>
|
|
515
|
+
handler(context),
|
|
516
|
+
)
|
|
517
|
+
: handler;
|
|
509
518
|
if (result instanceof Promise) {
|
|
510
519
|
result.finally(doneParallelHandler).catch(() => {});
|
|
511
520
|
const tracked = deps.trackHandler(result, {
|
|
@@ -527,7 +536,12 @@ export async function resolveParallelEntry<TEnv>(
|
|
|
527
536
|
}
|
|
528
537
|
} else {
|
|
529
538
|
component =
|
|
530
|
-
typeof handler === "function"
|
|
539
|
+
typeof handler === "function"
|
|
540
|
+
? await observePhase(
|
|
541
|
+
PHASES.handler(`${parallelEntry.id}.${slot}`),
|
|
542
|
+
() => handler(context),
|
|
543
|
+
)
|
|
544
|
+
: handler;
|
|
531
545
|
doneParallelHandler();
|
|
532
546
|
}
|
|
533
547
|
}
|
|
@@ -23,6 +23,7 @@ import type { ResolvedSegment, ErrorInfo, HandlerContext } from "../../types";
|
|
|
23
23
|
import type { SegmentResolutionDeps } from "../types.js";
|
|
24
24
|
import { debugLog } from "../logging.js";
|
|
25
25
|
import { tryStaticLookup } from "./static-store.js";
|
|
26
|
+
import { observePhase, PHASES } from "../instrument.js";
|
|
26
27
|
import type { TelemetrySink } from "../telemetry.js";
|
|
27
28
|
import { resolveSink, safeEmit, getRequestId } from "../telemetry.js";
|
|
28
29
|
|
|
@@ -130,11 +131,15 @@ export async function resolveLayoutComponent<TEnv>(
|
|
|
130
131
|
entry: EntryData,
|
|
131
132
|
context: HandlerContext<any, TEnv>,
|
|
132
133
|
): Promise<ReactNode> {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
// rango.handler span for this layout/cache handler (the perf metric is owned
|
|
135
|
+
// by the track("handler:<id>") at the call site; this adds the span only).
|
|
136
|
+
return observePhase(PHASES.handler(entry.id), async () => {
|
|
137
|
+
const component = await tryStaticHandler(entry, entry.shortCode);
|
|
138
|
+
if (component !== undefined) return component;
|
|
139
|
+
return typeof entry.handler === "function"
|
|
140
|
+
? handleHandlerResult(await entry.handler(context))
|
|
141
|
+
: (entry.handler as ReactNode);
|
|
142
|
+
});
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
// ---------------------------------------------------------------------------
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
} from "./helpers.js";
|
|
44
44
|
import { applyViewTransitionDefault } from "./view-transition-default.js";
|
|
45
45
|
import { getRouterContext } from "../router-context.js";
|
|
46
|
-
import { observeEvent } from "../instrument.js";
|
|
46
|
+
import { observeEvent, observePhase, PHASES } from "../instrument.js";
|
|
47
47
|
import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
|
|
48
48
|
import {
|
|
49
49
|
track,
|
|
@@ -793,12 +793,16 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
793
793
|
? routeEntry.liveHandler
|
|
794
794
|
: routeEntry.handler;
|
|
795
795
|
if (!routeEntry.loading) {
|
|
796
|
-
const result = handleHandlerResult(
|
|
796
|
+
const result = handleHandlerResult(
|
|
797
|
+
await observePhase(PHASES.handler(entry.id), () => handler(context)),
|
|
798
|
+
);
|
|
797
799
|
doneHandler();
|
|
798
800
|
return result;
|
|
799
801
|
}
|
|
800
802
|
if (!actionContext) {
|
|
801
|
-
const result = handleHandlerResult(
|
|
803
|
+
const result = handleHandlerResult(
|
|
804
|
+
observePhase(PHASES.handler(entry.id), () => handler(context)),
|
|
805
|
+
);
|
|
802
806
|
if (result instanceof Promise) {
|
|
803
807
|
warnOnStreamedResponse(result, routeEntry.id);
|
|
804
808
|
result.finally(doneHandler).catch(() => {});
|
|
@@ -822,7 +826,9 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
822
826
|
debugLog("segment.action", "resolving action route with awaited value", {
|
|
823
827
|
entryId: entry.id,
|
|
824
828
|
});
|
|
825
|
-
const actionResult = handleHandlerResult(
|
|
829
|
+
const actionResult = handleHandlerResult(
|
|
830
|
+
await observePhase(PHASES.handler(entry.id), () => handler(context)),
|
|
831
|
+
);
|
|
826
832
|
doneHandler();
|
|
827
833
|
return {
|
|
828
834
|
content: Promise.resolve(actionResult),
|
package/src/router/tracing.ts
CHANGED
|
@@ -26,16 +26,23 @@
|
|
|
26
26
|
* metered directly), rango.middleware (span-only incl. intercept middleware;
|
|
27
27
|
* pre/post metered directly), rango.action (action:<id>; server-action
|
|
28
28
|
* execution, JS + no-JS/PE), rango.loader (loader:<id>; single metering site at
|
|
29
|
-
* useLoader, plus the fetchable path), rango.
|
|
29
|
+
* useLoader, plus the fetchable path), rango.handler (span-only, one per segment
|
|
30
|
+
* route/layout handler execution; the handler:<id> perf metric is owned by the
|
|
31
|
+
* track() at the call site), rango.render (render:total:<route>; normal AND
|
|
30
32
|
* action-revalidation renders), rango.ssr (ssr:render-html).
|
|
31
33
|
*
|
|
32
|
-
*
|
|
33
|
-
* settles.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* Streaming-phase span lifetime: a span ends when its callback's value (or
|
|
35
|
+
* promise) settles. The streaming phases (request, middleware, render, ssr) are
|
|
36
|
+
* wrapped by observeRequestPhase / observeStreamingPhase (instrument.ts), whose
|
|
37
|
+
* callback hands the constructed Response/stream to the caller immediately
|
|
38
|
+
* (streaming preserved) but then awaits a request-scoped drain barrier, so the
|
|
39
|
+
* SPAN ends when the response BODY finishes draining, not at stream construction.
|
|
40
|
+
* That keeps span durations covering the real streamed work and the trace tree
|
|
41
|
+
* valid: a loader/Suspense child that resolves while the body streams ends before
|
|
42
|
+
* its now-drain-bound parent. The co-emitted perf METRIC (render:total, …) is
|
|
43
|
+
* still recorded at construction — it ships in the Server-Timing header, flushed
|
|
44
|
+
* before the body drains — so a streaming span legitimately reads longer than its
|
|
45
|
+
* same-named metric by the time the body spent streaming after construction.
|
|
39
46
|
*
|
|
40
47
|
* Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
|
|
41
48
|
* platform-specific bridge lives at the edge behind the SpanRunner contract.
|
|
@@ -62,6 +69,7 @@ export type TracePhase =
|
|
|
62
69
|
| "middleware"
|
|
63
70
|
| "action"
|
|
64
71
|
| "loader"
|
|
72
|
+
| "handler"
|
|
65
73
|
| "render"
|
|
66
74
|
| "ssr";
|
|
67
75
|
|
|
@@ -71,6 +79,7 @@ export interface TracePhaseToggles {
|
|
|
71
79
|
middleware?: boolean;
|
|
72
80
|
action?: boolean;
|
|
73
81
|
loader?: boolean;
|
|
82
|
+
handler?: boolean;
|
|
74
83
|
render?: boolean;
|
|
75
84
|
ssr?: boolean;
|
|
76
85
|
}
|
|
@@ -107,6 +116,7 @@ const ALL_PHASES_ON: Record<TracePhase, boolean> = {
|
|
|
107
116
|
middleware: true,
|
|
108
117
|
action: true,
|
|
109
118
|
loader: true,
|
|
119
|
+
handler: true,
|
|
110
120
|
render: true,
|
|
111
121
|
ssr: true,
|
|
112
122
|
};
|
|
@@ -134,6 +144,7 @@ export function resolveTracing(
|
|
|
134
144
|
middleware: spans.middleware ?? true,
|
|
135
145
|
action: spans.action ?? true,
|
|
136
146
|
loader: spans.loader ?? true,
|
|
147
|
+
handler: spans.handler ?? true,
|
|
137
148
|
render: spans.render ?? true,
|
|
138
149
|
ssr: spans.ssr ?? true,
|
|
139
150
|
}
|
package/src/rsc/handler.ts
CHANGED
|
@@ -82,7 +82,12 @@ import {
|
|
|
82
82
|
appendMetric,
|
|
83
83
|
buildMetricsTiming,
|
|
84
84
|
} from "../router/metrics.js";
|
|
85
|
-
import {
|
|
85
|
+
import {
|
|
86
|
+
observePhase,
|
|
87
|
+
observeRequestPhase,
|
|
88
|
+
observeEvent,
|
|
89
|
+
PHASES,
|
|
90
|
+
} from "../router/instrument.js";
|
|
86
91
|
import {
|
|
87
92
|
startSSRSetup,
|
|
88
93
|
getSSRSetup,
|
|
@@ -496,11 +501,14 @@ export function createRSCHandler<
|
|
|
496
501
|
// The "rango.request" span is opened inside the request context so the
|
|
497
502
|
// Cloudflare runner can read executionContext.tracing, and so every nested
|
|
498
503
|
// phase span (and the platform's automatic KV/D1/fetch spans) nests under
|
|
499
|
-
// it.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
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.
|
|
502
510
|
return runWithRequestContext(requestContext, () =>
|
|
503
|
-
|
|
511
|
+
observeRequestPhase(PHASES.request, async (span) => {
|
|
504
512
|
span.setAttribute("http.method", request.method);
|
|
505
513
|
// The matched route template is not known until match() runs later, so
|
|
506
514
|
// emit the concrete path as url.path (low-level), NOT http.route — the
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
setRequestContextParams,
|
|
12
12
|
} from "../server/request-context.js";
|
|
13
13
|
import { appendMetric } from "../router/metrics.js";
|
|
14
|
-
import {
|
|
14
|
+
import { observeStreamingPhase, PHASES } from "../router/instrument.js";
|
|
15
15
|
import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
|
|
16
16
|
import type { RscPayload } from "./types.js";
|
|
17
17
|
import type { MatchResult } from "../types.js";
|
|
@@ -36,7 +36,7 @@ export function handleRscRendering<TEnv>(
|
|
|
36
36
|
// same boundary (match -> serialize -> SSR), so the two surfaces agree.
|
|
37
37
|
// Loaders kicked off during matching nest under the span; the SSR HTML pass
|
|
38
38
|
// below opens "rango.ssr" the same way.
|
|
39
|
-
return
|
|
39
|
+
return observeStreamingPhase(PHASES.render, () =>
|
|
40
40
|
handleRscRenderingInner(
|
|
41
41
|
ctx,
|
|
42
42
|
request,
|
|
@@ -248,7 +248,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
248
248
|
|
|
249
249
|
// ssr-render-html metric + rango.ssr span from one boundary. render:total is
|
|
250
250
|
// recorded by the observePhase wrapper around this function.
|
|
251
|
-
const htmlStream = await
|
|
251
|
+
const htmlStream = await observeStreamingPhase(PHASES.ssr, () =>
|
|
252
252
|
ssrModule.renderHTML(rscStream, {
|
|
253
253
|
nonce,
|
|
254
254
|
streamMode,
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
setRequestContextParams,
|
|
21
21
|
} from "../server/request-context.js";
|
|
22
22
|
import { appendMetric } from "../router/metrics.js";
|
|
23
|
-
import {
|
|
23
|
+
import { observeStreamingPhase, PHASES } from "../router/instrument.js";
|
|
24
24
|
import type { RscPayload } from "./types.js";
|
|
25
25
|
import {
|
|
26
26
|
hasBodyContent,
|
|
@@ -270,7 +270,7 @@ export function revalidateAfterAction<TEnv>(
|
|
|
270
270
|
// "render:total" AND opens "rango.render" from one boundary covering
|
|
271
271
|
// matchPartial -> serialize, so the revalidation loaders' rango.loader spans
|
|
272
272
|
// nest under a rango.render parent instead of dangling at the request root.
|
|
273
|
-
return
|
|
273
|
+
return observeStreamingPhase(PHASES.render, () =>
|
|
274
274
|
revalidateAfterActionInner(
|
|
275
275
|
ctx,
|
|
276
276
|
request,
|
|
@@ -368,6 +368,16 @@ export interface RequestContext<
|
|
|
368
368
|
/** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
|
|
369
369
|
_tracing?: ResolvedTracing;
|
|
370
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
|
+
|
|
371
381
|
/** @internal Router basename for this request (used by redirect()) */
|
|
372
382
|
_basename?: string;
|
|
373
383
|
|