@rangojs/router 0.0.0-experimental.127 → 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/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 +11 -8
- package/src/router/instrument.ts +217 -7
- package/src/router/middleware.ts +3 -3
- package/src/router/tracing.ts +12 -7
- 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.128",
|
|
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({
|
|
@@ -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";
|
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
|
|
@@ -138,6 +141,29 @@ export const PHASES = {
|
|
|
138
141
|
} as PhaseSpec,
|
|
139
142
|
} as const;
|
|
140
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
|
+
|
|
141
167
|
/**
|
|
142
168
|
* Instrument one unit of work: open its span AND (unless `metric: false`) record
|
|
143
169
|
* its perf metric, from a single wrap site. fn is invoked exactly once with the
|
|
@@ -145,6 +171,12 @@ export const PHASES = {
|
|
|
145
171
|
* unchanged and thrown errors / rejected promises propagate unchanged. When fn
|
|
146
172
|
* returns a promise both the metric duration and the span end when it settles.
|
|
147
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
|
+
*
|
|
148
180
|
* Reads the metrics store + tracing off the RequestContext ALS, which is active
|
|
149
181
|
* for the WHOLE request — contrast observeEvent, which reads the RouterContext
|
|
150
182
|
* ALS (entered later, during match).
|
|
@@ -166,7 +198,7 @@ export function observePhase<T>(
|
|
|
166
198
|
const wrapped: (span: TraceSpan) => T =
|
|
167
199
|
attributes && tracing
|
|
168
200
|
? (span) => {
|
|
169
|
-
|
|
201
|
+
applyAttributes(span, attributes);
|
|
170
202
|
return fn(span);
|
|
171
203
|
}
|
|
172
204
|
: fn;
|
|
@@ -182,13 +214,191 @@ export function observePhase<T>(
|
|
|
182
214
|
// failed loader/render still shows its timing in the perf report (parity with
|
|
183
215
|
// the old track().finally() path it replaced).
|
|
184
216
|
const start = performance.now();
|
|
185
|
-
return runThenSettle(runSpan, () =>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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;
|
|
191
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
|
+
);
|
|
192
402
|
}
|
|
193
403
|
|
|
194
404
|
/**
|
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) {
|
package/src/router/tracing.ts
CHANGED
|
@@ -29,13 +29,18 @@
|
|
|
29
29
|
* useLoader, plus the fetchable path), rango.render (render:total:<route>; normal AND
|
|
30
30
|
* action-revalidation renders), rango.ssr (ssr:render-html).
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
* settles.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
32
|
+
* Streaming-phase span lifetime: a span ends when its callback's value (or
|
|
33
|
+
* promise) settles. The streaming phases (request, middleware, render, ssr) are
|
|
34
|
+
* wrapped by observeRequestPhase / observeStreamingPhase (instrument.ts), whose
|
|
35
|
+
* callback hands the constructed Response/stream to the caller immediately
|
|
36
|
+
* (streaming preserved) but then awaits a request-scoped drain barrier, so the
|
|
37
|
+
* SPAN ends when the response BODY finishes draining, not at stream construction.
|
|
38
|
+
* That keeps span durations covering the real streamed work and the trace tree
|
|
39
|
+
* valid: a loader/Suspense child that resolves while the body streams ends before
|
|
40
|
+
* its now-drain-bound parent. The co-emitted perf METRIC (render:total, …) is
|
|
41
|
+
* still recorded at construction — it ships in the Server-Timing header, flushed
|
|
42
|
+
* before the body drains — so a streaming span legitimately reads longer than its
|
|
43
|
+
* same-named metric by the time the body spent streaming after construction.
|
|
39
44
|
*
|
|
40
45
|
* Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
|
|
41
46
|
* platform-specific bridge lives at the edge behind the SpanRunner contract.
|
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
|
|