@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
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenTelemetry
|
|
2
|
+
* OpenTelemetry adapters for the router.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Two adapters, two surfaces — matching the split in instrument.ts:
|
|
5
|
+
*
|
|
6
|
+
* - createOTelTracing(tracer): the OTel adapter for the `tracing` SLOT — the
|
|
7
|
+
* canonical phase-span layer. It bridges observePhase's callback boundary
|
|
8
|
+
* onto OTel's callback-bound `startActiveSpan`, so the router's phase spans
|
|
9
|
+
* (rango.request/middleware/loader/render/ssr) nest by async context and the
|
|
10
|
+
* loader's own OTel spans (db/fetch) land under rango.loader. This is the
|
|
11
|
+
* OTel equivalent of createCloudflareTracing — pass it to
|
|
12
|
+
* `createRouter({ tracing })`.
|
|
13
|
+
*
|
|
14
|
+
* - createOTelSink(tracer): the OTel adapter for the `telemetry` SLOT — a
|
|
15
|
+
* TelemetrySink for the EVENT-shaped facts (handler errors, cache decisions,
|
|
16
|
+
* revalidation decisions, timeouts, origin rejections). It emits one instant
|
|
17
|
+
* OTel span per fact. It deliberately does NOT emit request/loader phase
|
|
18
|
+
* spans: those are owned by the tracing slot (createOTelTracing) so the two
|
|
19
|
+
* layers don't produce duplicate rango.request / rango.loader spans.
|
|
20
|
+
*
|
|
21
|
+
* The core router stays OTel-agnostic — these adapters accept a standard OTel
|
|
22
|
+
* Tracer (structurally typed, no import needed) and bridge the gap.
|
|
7
23
|
*
|
|
8
24
|
* Usage:
|
|
9
25
|
* import { trace } from "@opentelemetry/api";
|
|
10
|
-
* import { createOTelSink } from "@rangojs/router";
|
|
26
|
+
* import { createRouter, createOTelTracing, createOTelSink } from "@rangojs/router";
|
|
11
27
|
*
|
|
28
|
+
* const tracer = trace.getTracer("my-app");
|
|
12
29
|
* const router = createRouter({
|
|
13
|
-
*
|
|
30
|
+
* tracing: createOTelTracing(tracer), // phase spans (callback-bound)
|
|
31
|
+
* telemetry: createOTelSink(tracer), // discrete-fact instant spans
|
|
14
32
|
* });
|
|
33
|
+
*
|
|
34
|
+
* Faithful nesting requires an OTel async context manager
|
|
35
|
+
* (AsyncLocalStorageContextManager) configured in your OTel setup — standard for
|
|
36
|
+
* any startActiveSpan-based instrumentation.
|
|
15
37
|
*/
|
|
16
38
|
|
|
17
39
|
import type { TelemetrySink, TelemetryEvent } from "./telemetry.js";
|
|
40
|
+
import { runThenSettle } from "./tracing.js";
|
|
41
|
+
import type {
|
|
42
|
+
RouterTracingConfig,
|
|
43
|
+
SpanRunner,
|
|
44
|
+
TracePhaseToggles,
|
|
45
|
+
} from "./tracing.js";
|
|
18
46
|
|
|
19
47
|
// ---------------------------------------------------------------------------
|
|
20
48
|
// Minimal OTel-compatible types (structurally typed, no import needed)
|
|
@@ -22,21 +50,21 @@ import type { TelemetrySink, TelemetryEvent } from "./telemetry.js";
|
|
|
22
50
|
|
|
23
51
|
/**
|
|
24
52
|
* Minimal Span interface compatible with @opentelemetry/api Span.
|
|
25
|
-
* Only the methods used by the
|
|
53
|
+
* Only the methods used by the adapters are declared.
|
|
26
54
|
*/
|
|
27
55
|
export interface OTelSpan {
|
|
28
56
|
setAttribute(key: string, value: string | number | boolean): OTelSpan | void;
|
|
29
|
-
addEvent(
|
|
30
|
-
name: string,
|
|
31
|
-
attributes?: Record<string, string | number | boolean>,
|
|
32
|
-
): OTelSpan | void;
|
|
33
57
|
setStatus(status: { code: number; message?: string }): OTelSpan | void;
|
|
34
58
|
recordException(exception: Error): void;
|
|
35
59
|
end(): void;
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
/**
|
|
39
|
-
* Minimal Tracer interface
|
|
63
|
+
* Minimal Tracer interface for the EVENT sink (createOTelSink): only `startSpan`
|
|
64
|
+
* is used (one instant span per discrete fact). Kept narrow so a custom,
|
|
65
|
+
* event-only tracer that does not implement `startActiveSpan` still satisfies it.
|
|
66
|
+
* The real `@opentelemetry/api` Tracer has both methods, so it satisfies this and
|
|
67
|
+
* `OTelActiveSpanTracer` below.
|
|
40
68
|
*/
|
|
41
69
|
export interface OTelTracer {
|
|
42
70
|
startSpan(
|
|
@@ -47,182 +75,110 @@ export interface OTelTracer {
|
|
|
47
75
|
): OTelSpan;
|
|
48
76
|
}
|
|
49
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Minimal Tracer interface for the PHASE-SPAN adapter (createOTelTracing): only
|
|
80
|
+
* `startActiveSpan` is used (callback-bound, so the span is active for the work
|
|
81
|
+
* and child spans nest). Declared separately from `OTelTracer` so each factory
|
|
82
|
+
* requires exactly the method it calls.
|
|
83
|
+
*/
|
|
84
|
+
export interface OTelActiveSpanTracer {
|
|
85
|
+
startActiveSpan<T>(name: string, fn: (span: OTelSpan) => T): T;
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
// OTel SpanStatusCode constants (mirrors @opentelemetry/api values)
|
|
51
|
-
const STATUS_OK = 1;
|
|
52
89
|
const STATUS_ERROR = 2;
|
|
53
90
|
|
|
54
91
|
// ---------------------------------------------------------------------------
|
|
55
|
-
//
|
|
92
|
+
// Tracing adapter: phase spans via startActiveSpan (the `tracing` slot)
|
|
56
93
|
// ---------------------------------------------------------------------------
|
|
57
94
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
requestId?: string;
|
|
65
|
-
pathname: string;
|
|
66
|
-
transaction: string;
|
|
67
|
-
}): string {
|
|
68
|
-
return `${event.requestId ?? ""}:${event.pathname}:${event.transaction}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function loaderKey(event: {
|
|
72
|
-
requestId?: string;
|
|
73
|
-
segmentId: string;
|
|
74
|
-
loaderName: string;
|
|
75
|
-
pathname: string;
|
|
76
|
-
}): string {
|
|
77
|
-
return `${event.requestId ?? ""}:${event.segmentId}:${event.loaderName}:${event.pathname}`;
|
|
95
|
+
/** Options for createOTelTracing. */
|
|
96
|
+
export interface OTelTracingOptions {
|
|
97
|
+
/** Master switch. Defaults to true. */
|
|
98
|
+
enabled?: boolean;
|
|
99
|
+
/** Per-phase span toggles. Omitted phases default to enabled. */
|
|
100
|
+
spans?: TracePhaseToggles;
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Create the tracing config that maps the router's phases onto OTel spans via
|
|
105
|
+
* `tracer.startActiveSpan`, which runs the work inside the span's active context
|
|
106
|
+
* (so child spans nest) and returns the work's value unchanged. The span ends
|
|
107
|
+
* when that value — or, for async work, the returned promise — settles, matching
|
|
108
|
+
* observePhase's contract. When the work throws or rejects, the exception is
|
|
109
|
+
* recorded and the span status is set to ERROR before it ends, so a failed phase
|
|
110
|
+
* stays visible in the trace (the old createOTelSink error path is preserved here
|
|
111
|
+
* now that phase spans live in this adapter). Pass the result to
|
|
112
|
+
* `createRouter({ tracing })`.
|
|
113
|
+
*
|
|
114
|
+
* @see createCloudflareTracing (`@rangojs/router/cloudflare`) for the same slot
|
|
115
|
+
* using Cloudflare Workers native custom spans.
|
|
116
|
+
*/
|
|
117
|
+
export function createOTelTracing(
|
|
118
|
+
tracer: OTelActiveSpanTracer,
|
|
119
|
+
options: OTelTracingOptions = {},
|
|
120
|
+
): RouterTracingConfig {
|
|
121
|
+
const runner: SpanRunner = <T>(name: string, fn: (span: OTelSpan) => T): T =>
|
|
122
|
+
tracer.startActiveSpan(
|
|
123
|
+
name,
|
|
124
|
+
(span): T =>
|
|
125
|
+
runThenSettle(
|
|
126
|
+
() => fn(span),
|
|
127
|
+
(error) => {
|
|
128
|
+
// On failure record the exception + ERROR status before ending, so a
|
|
129
|
+
// failed phase stays visible in the trace.
|
|
130
|
+
if (error !== undefined) {
|
|
131
|
+
if (error instanceof Error) {
|
|
132
|
+
span.recordException(error);
|
|
133
|
+
span.setStatus({ code: STATUS_ERROR, message: error.message });
|
|
134
|
+
} else {
|
|
135
|
+
span.setStatus({ code: STATUS_ERROR });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
span.end();
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
);
|
|
92
142
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (!stack || stack.length === 0) return undefined;
|
|
99
|
-
const span = stack.pop()!;
|
|
100
|
-
if (stack.length === 0) map.delete(key);
|
|
101
|
-
return span;
|
|
143
|
+
return {
|
|
144
|
+
runner,
|
|
145
|
+
enabled: options.enabled ?? true,
|
|
146
|
+
spans: options.spans,
|
|
147
|
+
};
|
|
102
148
|
}
|
|
103
149
|
|
|
104
150
|
// ---------------------------------------------------------------------------
|
|
105
|
-
//
|
|
151
|
+
// Telemetry sink: discrete-fact instant spans (the `telemetry` slot)
|
|
106
152
|
// ---------------------------------------------------------------------------
|
|
107
153
|
|
|
108
154
|
/**
|
|
109
|
-
* Create a TelemetrySink that maps router
|
|
155
|
+
* Create a TelemetrySink that maps the router's discrete-fact events to instant
|
|
156
|
+
* OTel spans. One span per fact; no duration spans.
|
|
157
|
+
*
|
|
158
|
+
* Fact mapping:
|
|
159
|
+
* - handler.error -> "rango.handler.error" (error)
|
|
160
|
+
* - cache.decision -> "rango.cache.decision"
|
|
161
|
+
* - revalidation.decision -> "rango.revalidation.decision"
|
|
162
|
+
* - request.timeout -> "rango.request.timeout" (error)
|
|
163
|
+
* - request.origin-rejected -> "rango.request.origin-rejected" (error)
|
|
110
164
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* - cache.decision → "rango.cache.decision" instant span
|
|
116
|
-
* - revalidation.decision → "rango.revalidation.decision" instant span
|
|
165
|
+
* Request and loader PHASE spans are intentionally NOT emitted here — they are
|
|
166
|
+
* owned by the tracing slot (createOTelTracing) so the two layers cannot produce
|
|
167
|
+
* duplicate rango.request / rango.loader spans. request.start/end and
|
|
168
|
+
* loader.start/end events are no-ops for this sink.
|
|
117
169
|
*
|
|
118
170
|
* Attributes use the `rango.*` namespace for router-specific data and
|
|
119
171
|
* `http.method` / `http.route` for HTTP semantics.
|
|
120
172
|
*/
|
|
121
173
|
export function createOTelSink(tracer: OTelTracer): TelemetrySink {
|
|
122
|
-
const
|
|
123
|
-
|
|
174
|
+
const instant = (
|
|
175
|
+
name: string,
|
|
176
|
+
attributes: Record<string, string | number | boolean>,
|
|
177
|
+
): OTelSpan => tracer.startSpan(name, { attributes });
|
|
124
178
|
|
|
125
179
|
return {
|
|
126
180
|
emit(event: TelemetryEvent): void {
|
|
127
181
|
switch (event.type) {
|
|
128
|
-
case "request.start": {
|
|
129
|
-
const span = tracer.startSpan("rango.request", {
|
|
130
|
-
attributes: {
|
|
131
|
-
"http.method": event.method,
|
|
132
|
-
"http.route": event.pathname,
|
|
133
|
-
"rango.transaction": event.transaction,
|
|
134
|
-
"rango.is_partial": event.isPartial,
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
pushSpan(requestSpans, requestKey(event), span);
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
case "request.end": {
|
|
142
|
-
const span = popSpan(requestSpans, requestKey(event));
|
|
143
|
-
if (span) {
|
|
144
|
-
span.setAttribute("rango.duration_ms", event.durationMs);
|
|
145
|
-
span.setAttribute("rango.segment_count", event.segmentCount);
|
|
146
|
-
span.setAttribute("rango.cache.hit", event.cacheHit);
|
|
147
|
-
span.setStatus({ code: STATUS_OK });
|
|
148
|
-
span.end();
|
|
149
|
-
}
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
case "request.error": {
|
|
154
|
-
const span = popSpan(requestSpans, requestKey(event));
|
|
155
|
-
if (span) {
|
|
156
|
-
span.setAttribute("rango.duration_ms", event.durationMs);
|
|
157
|
-
span.setAttribute("rango.phase", event.phase);
|
|
158
|
-
span.recordException(event.error);
|
|
159
|
-
span.setStatus({
|
|
160
|
-
code: STATUS_ERROR,
|
|
161
|
-
message: event.error.message,
|
|
162
|
-
});
|
|
163
|
-
span.end();
|
|
164
|
-
}
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
case "loader.start": {
|
|
169
|
-
const span = tracer.startSpan("rango.loader", {
|
|
170
|
-
attributes: {
|
|
171
|
-
"rango.segment_id": event.segmentId,
|
|
172
|
-
"rango.loader_name": event.loaderName,
|
|
173
|
-
"http.route": event.pathname,
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
pushSpan(loaderSpans, loaderKey(event), span);
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
case "loader.end": {
|
|
181
|
-
const key = loaderKey(event);
|
|
182
|
-
const span = popSpan(loaderSpans, key);
|
|
183
|
-
if (span) {
|
|
184
|
-
span.setAttribute("rango.duration_ms", event.durationMs);
|
|
185
|
-
span.setAttribute("rango.loader.ok", event.ok);
|
|
186
|
-
span.setStatus({ code: event.ok ? STATUS_OK : STATUS_ERROR });
|
|
187
|
-
span.end();
|
|
188
|
-
}
|
|
189
|
-
break;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
case "loader.error": {
|
|
193
|
-
const key = loaderKey(event);
|
|
194
|
-
const span = popSpan(loaderSpans, key);
|
|
195
|
-
if (span) {
|
|
196
|
-
span.setAttribute(
|
|
197
|
-
"rango.handled_by_boundary",
|
|
198
|
-
event.handledByBoundary,
|
|
199
|
-
);
|
|
200
|
-
span.recordException(event.error);
|
|
201
|
-
span.setStatus({
|
|
202
|
-
code: STATUS_ERROR,
|
|
203
|
-
message: event.error.message,
|
|
204
|
-
});
|
|
205
|
-
span.end();
|
|
206
|
-
} else {
|
|
207
|
-
// No matching start — create a standalone error span
|
|
208
|
-
const errorSpan = tracer.startSpan("rango.loader", {
|
|
209
|
-
attributes: {
|
|
210
|
-
"rango.segment_id": event.segmentId,
|
|
211
|
-
"rango.loader_name": event.loaderName,
|
|
212
|
-
"http.route": event.pathname,
|
|
213
|
-
"rango.handled_by_boundary": event.handledByBoundary,
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
errorSpan.recordException(event.error);
|
|
217
|
-
errorSpan.setStatus({
|
|
218
|
-
code: STATUS_ERROR,
|
|
219
|
-
message: event.error.message,
|
|
220
|
-
});
|
|
221
|
-
errorSpan.end();
|
|
222
|
-
}
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
182
|
case "handler.error": {
|
|
227
183
|
const attrs: Record<string, string | number | boolean> = {
|
|
228
184
|
"rango.handled_by_boundary": event.handledByBoundary,
|
|
@@ -232,13 +188,10 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
|
|
|
232
188
|
attrs["rango.segment_type"] = event.segmentType;
|
|
233
189
|
if (event.pathname) attrs["http.route"] = event.pathname;
|
|
234
190
|
if (event.routeKey) attrs["rango.route_key"] = event.routeKey;
|
|
235
|
-
if (event.params)
|
|
191
|
+
if (event.params)
|
|
236
192
|
attrs["rango.params"] = JSON.stringify(event.params);
|
|
237
|
-
}
|
|
238
193
|
|
|
239
|
-
const span =
|
|
240
|
-
attributes: attrs,
|
|
241
|
-
});
|
|
194
|
+
const span = instant("rango.handler.error", attrs);
|
|
242
195
|
span.recordException(event.error);
|
|
243
196
|
span.setStatus({ code: STATUS_ERROR, message: event.error.message });
|
|
244
197
|
span.end();
|
|
@@ -253,26 +206,55 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
|
|
|
253
206
|
"rango.cache.should_revalidate": event.shouldRevalidate,
|
|
254
207
|
};
|
|
255
208
|
if (event.source) attrs["rango.cache.source"] = event.source;
|
|
209
|
+
instant("rango.cache.decision", attrs).end();
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
256
212
|
|
|
257
|
-
|
|
258
|
-
|
|
213
|
+
case "revalidation.decision": {
|
|
214
|
+
instant("rango.revalidation.decision", {
|
|
215
|
+
"rango.segment_id": event.segmentId,
|
|
216
|
+
"http.route": event.pathname,
|
|
217
|
+
"rango.route_key": event.routeKey,
|
|
218
|
+
"rango.revalidate": event.shouldRevalidate,
|
|
219
|
+
}).end();
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case "request.timeout": {
|
|
224
|
+
const attrs: Record<string, string | number | boolean> = {
|
|
225
|
+
"rango.phase": event.phase,
|
|
226
|
+
"http.route": event.pathname,
|
|
227
|
+
"rango.duration_ms": event.durationMs,
|
|
228
|
+
"rango.timeout.custom_handler": event.customHandler,
|
|
229
|
+
};
|
|
230
|
+
if (event.routeKey) attrs["rango.route_key"] = event.routeKey;
|
|
231
|
+
if (event.actionId) attrs["rango.action_id"] = event.actionId;
|
|
232
|
+
const span = instant("rango.request.timeout", attrs);
|
|
233
|
+
span.setStatus({
|
|
234
|
+
code: STATUS_ERROR,
|
|
235
|
+
message: `timeout: ${event.phase}`,
|
|
259
236
|
});
|
|
260
237
|
span.end();
|
|
261
238
|
break;
|
|
262
239
|
}
|
|
263
240
|
|
|
264
|
-
case "
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
241
|
+
case "request.origin-rejected": {
|
|
242
|
+
const attrs: Record<string, string | number | boolean> = {
|
|
243
|
+
"http.method": event.method,
|
|
244
|
+
"http.route": event.pathname,
|
|
245
|
+
"rango.phase": event.phase,
|
|
246
|
+
};
|
|
247
|
+
if (event.origin) attrs["rango.origin"] = event.origin;
|
|
248
|
+
if (event.host) attrs["http.host"] = event.host;
|
|
249
|
+
const span = instant("rango.request.origin-rejected", attrs);
|
|
250
|
+
span.setStatus({ code: STATUS_ERROR, message: "origin rejected" });
|
|
273
251
|
span.end();
|
|
274
252
|
break;
|
|
275
253
|
}
|
|
254
|
+
|
|
255
|
+
// request.start/end/error and loader.start/end/error are phase events;
|
|
256
|
+
// their spans are owned by the tracing slot (createOTelTracing), so this
|
|
257
|
+
// sink ignores them to avoid duplicate rango.request / rango.loader spans.
|
|
276
258
|
}
|
|
277
259
|
},
|
|
278
260
|
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span tracing hook (platform-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* The core router emits its existing performance phases (request, middleware, action,
|
|
5
|
+
* loaders, render, ssr) as spans by calling traceSpan() at a small set of
|
|
6
|
+
* execution boundaries. When no tracing is configured the call is a direct
|
|
7
|
+
* pass-through: fn is invoked with a no-op span, with no wrapper and no
|
|
8
|
+
* allocation, so a non-traced request behaves exactly as before.
|
|
9
|
+
*
|
|
10
|
+
* A platform integration supplies a SpanRunner that wraps fn in a real span.
|
|
11
|
+
* Two runners ship: the Cloudflare one (createCloudflareTracing in
|
|
12
|
+
* src/cloudflare/tracing.ts), which bridges onto executionContext.tracing.
|
|
13
|
+
* enterSpan, and the OTel one (createOTelTracing in router/telemetry-otel.ts),
|
|
14
|
+
* which bridges onto tracer.startActiveSpan. Both wrap the actual work — not a
|
|
15
|
+
* post-hoc event — so spans nest by async context and the platform's automatic
|
|
16
|
+
* spans (KV/D1/fetch) nest under the right phase.
|
|
17
|
+
*
|
|
18
|
+
* traceSpan() below is the low-level wrap primitive. It is INTERNAL: the only
|
|
19
|
+
* caller is observePhase() (instrument.ts), the single phase-instrumentation
|
|
20
|
+
* API, which co-emits the span AND the debugPerformance perf metric from one
|
|
21
|
+
* wrap site (or just the span, for metric:false phases) so the two surfaces
|
|
22
|
+
* can't drift. Every router phase routes through observePhase via the PHASES
|
|
23
|
+
* registry; do not call traceSpan directly from new code.
|
|
24
|
+
*
|
|
25
|
+
* Phase coverage (all via observePhase): rango.request (span-only; handler:total
|
|
26
|
+
* metered directly), rango.middleware (span-only incl. intercept middleware;
|
|
27
|
+
* pre/post metered directly), rango.action (action:<id>; server-action
|
|
28
|
+
* execution, JS + no-JS/PE), rango.loader (loader:<id>; single metering site at
|
|
29
|
+
* useLoader, plus the fetchable path), rango.render (render:total:<route>; normal AND
|
|
30
|
+
* action-revalidation renders), rango.ssr (ssr:render-html).
|
|
31
|
+
*
|
|
32
|
+
* 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.
|
|
44
|
+
*
|
|
45
|
+
* Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
|
|
46
|
+
* platform-specific bridge lives at the edge behind the SpanRunner contract.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Minimal span handle passed to traced work. Structurally compatible with both
|
|
51
|
+
* Cloudflare's `Span` and OTel's `Span` (only setAttribute is used here).
|
|
52
|
+
*/
|
|
53
|
+
export interface TraceSpan {
|
|
54
|
+
setAttribute(key: string, value: string | number | boolean): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wraps a unit of work in a span. A runner MUST invoke fn exactly once, pass it
|
|
59
|
+
* a span, return fn's result unchanged, and propagate thrown errors / rejected
|
|
60
|
+
* promises unchanged. When fn returns a promise the span ends once it settles.
|
|
61
|
+
*/
|
|
62
|
+
export type SpanRunner = <T>(name: string, fn: (span: TraceSpan) => T) => T;
|
|
63
|
+
|
|
64
|
+
/** The router phases that can be wrapped in a span. */
|
|
65
|
+
export type TracePhase =
|
|
66
|
+
| "request"
|
|
67
|
+
| "middleware"
|
|
68
|
+
| "action"
|
|
69
|
+
| "loader"
|
|
70
|
+
| "render"
|
|
71
|
+
| "ssr";
|
|
72
|
+
|
|
73
|
+
/** Per-phase span toggles. Omitted phases default to enabled. */
|
|
74
|
+
export interface TracePhaseToggles {
|
|
75
|
+
request?: boolean;
|
|
76
|
+
middleware?: boolean;
|
|
77
|
+
action?: boolean;
|
|
78
|
+
loader?: boolean;
|
|
79
|
+
render?: boolean;
|
|
80
|
+
ssr?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Value passed to `createRouter({ tracing })`. Produced by a platform factory
|
|
85
|
+
* such as `createCloudflareTracing()`.
|
|
86
|
+
*/
|
|
87
|
+
export interface RouterTracingConfig {
|
|
88
|
+
/** Platform span runner. */
|
|
89
|
+
runner: SpanRunner;
|
|
90
|
+
/** Master switch. Defaults to true when a config object is provided. */
|
|
91
|
+
enabled?: boolean;
|
|
92
|
+
/** Per-phase span toggles. */
|
|
93
|
+
spans?: TracePhaseToggles;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolved tracing state stored on the router/request context. `undefined`
|
|
98
|
+
* means tracing is fully disabled and every traceSpan() call is a pass-through.
|
|
99
|
+
*/
|
|
100
|
+
export interface ResolvedTracing {
|
|
101
|
+
runner: SpanRunner;
|
|
102
|
+
phases: Record<TracePhase, boolean>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Shared no-op span. setAttribute is a no-op so disabled call sites stay free. */
|
|
106
|
+
export const NOOP_TRACE_SPAN: TraceSpan = {
|
|
107
|
+
setAttribute() {},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const ALL_PHASES_ON: Record<TracePhase, boolean> = {
|
|
111
|
+
request: true,
|
|
112
|
+
middleware: true,
|
|
113
|
+
action: true,
|
|
114
|
+
loader: true,
|
|
115
|
+
render: true,
|
|
116
|
+
ssr: true,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a user-supplied tracing config into the fast internal form, or
|
|
121
|
+
* `undefined` when tracing is off (no config, `enabled: false`, or no runner).
|
|
122
|
+
*/
|
|
123
|
+
export function resolveTracing(
|
|
124
|
+
config: RouterTracingConfig | undefined,
|
|
125
|
+
): ResolvedTracing | undefined {
|
|
126
|
+
if (
|
|
127
|
+
!config ||
|
|
128
|
+
config.enabled === false ||
|
|
129
|
+
typeof config.runner !== "function"
|
|
130
|
+
) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const spans = config.spans;
|
|
134
|
+
return {
|
|
135
|
+
runner: config.runner,
|
|
136
|
+
phases: spans
|
|
137
|
+
? {
|
|
138
|
+
request: spans.request ?? true,
|
|
139
|
+
middleware: spans.middleware ?? true,
|
|
140
|
+
action: spans.action ?? true,
|
|
141
|
+
loader: spans.loader ?? true,
|
|
142
|
+
render: spans.render ?? true,
|
|
143
|
+
ssr: spans.ssr ?? true,
|
|
144
|
+
}
|
|
145
|
+
: ALL_PHASES_ON,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Wrap `fn` in a span for `phase`. When tracing is off (or the phase is
|
|
151
|
+
* disabled) fn runs directly with a no-op span — identical to the untraced
|
|
152
|
+
* path. Otherwise the platform runner wraps fn so the span covers the real
|
|
153
|
+
* work and nests by async context.
|
|
154
|
+
*/
|
|
155
|
+
export function traceSpan<T>(
|
|
156
|
+
tracing: ResolvedTracing | undefined,
|
|
157
|
+
phase: TracePhase,
|
|
158
|
+
name: string,
|
|
159
|
+
fn: (span: TraceSpan) => T,
|
|
160
|
+
): T {
|
|
161
|
+
if (tracing === undefined || tracing.phases[phase] === false) {
|
|
162
|
+
return fn(NOOP_TRACE_SPAN);
|
|
163
|
+
}
|
|
164
|
+
return tracing.runner(name, fn);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run `fn` once and invoke `onSettle` exactly once when it terminates — on a
|
|
169
|
+
* synchronous return, a synchronous throw, an async resolution, or an async
|
|
170
|
+
* rejection. `onSettle` receives the error (or `undefined` on success). fn's
|
|
171
|
+
* value is returned and errors propagate unchanged.
|
|
172
|
+
*
|
|
173
|
+
* Centralizes the run-once-then-settle control flow shared by the two span
|
|
174
|
+
* surfaces: observePhase records the perf metric on settle, and the OTel runner
|
|
175
|
+
* ends (or error-marks) the span on settle. The Cloudflare runner delegates
|
|
176
|
+
* settling to enterSpan, so it does not use this.
|
|
177
|
+
*/
|
|
178
|
+
export function runThenSettle<T>(
|
|
179
|
+
fn: () => T,
|
|
180
|
+
onSettle: (error: unknown) => void,
|
|
181
|
+
): T {
|
|
182
|
+
let out: T;
|
|
183
|
+
try {
|
|
184
|
+
out = fn();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
onSettle(error);
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
if (out instanceof Promise) {
|
|
190
|
+
return out.then(
|
|
191
|
+
(value) => {
|
|
192
|
+
onSettle(undefined);
|
|
193
|
+
return value;
|
|
194
|
+
},
|
|
195
|
+
(error) => {
|
|
196
|
+
onSettle(error);
|
|
197
|
+
throw error;
|
|
198
|
+
},
|
|
199
|
+
) as unknown as T;
|
|
200
|
+
}
|
|
201
|
+
onSettle(undefined);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
traverseBack,
|
|
74
74
|
} from "./router/pattern-matching.js";
|
|
75
75
|
import { resolveSink, safeEmit, getRequestId } from "./router/telemetry.js";
|
|
76
|
+
import { resolveTracing } from "./router/tracing.js";
|
|
76
77
|
import { evaluateRevalidation } from "./router/revalidation.js";
|
|
77
78
|
import {
|
|
78
79
|
type RouterContext,
|
|
@@ -152,6 +153,7 @@ export function createRouter<TEnv = any>(
|
|
|
152
153
|
warmup: warmupOption,
|
|
153
154
|
allowDebugManifest: allowDebugManifestOption = false,
|
|
154
155
|
telemetry: telemetrySink,
|
|
156
|
+
tracing: tracingOption,
|
|
155
157
|
ssr: ssrOption,
|
|
156
158
|
timeout: timeoutShorthand,
|
|
157
159
|
timeouts: timeoutsOption,
|
|
@@ -178,6 +180,10 @@ export function createRouter<TEnv = any>(
|
|
|
178
180
|
// Resolve telemetry sink (no-op when not configured)
|
|
179
181
|
const telemetry = resolveSink(telemetrySink);
|
|
180
182
|
|
|
183
|
+
// Resolve span tracing (undefined when not configured; every traceSpan() call
|
|
184
|
+
// is then a direct pass-through with zero behavior change).
|
|
185
|
+
const resolvedTracing = resolveTracing(tracingOption);
|
|
186
|
+
|
|
181
187
|
// Resolve cache profiles: merge user config with the guaranteed default
|
|
182
188
|
// profile. This resolved map is threaded onto each request context; the
|
|
183
189
|
// "use cache: <profile>" runtime path reads it request-scoped.
|
|
@@ -968,6 +974,9 @@ export function createRouter<TEnv = any>(
|
|
|
968
974
|
// Expose router-wide performance debugging for request-level metrics setup
|
|
969
975
|
debugPerformance,
|
|
970
976
|
|
|
977
|
+
// Expose resolved span tracing for the handler (Cloudflare custom spans)
|
|
978
|
+
tracing: resolvedTracing,
|
|
979
|
+
|
|
971
980
|
// Expose debug manifest flag for handler
|
|
972
981
|
allowDebugManifest: allowDebugManifestOption,
|
|
973
982
|
|