@alwaysmeticulous/backend-recorder-workerd 2.325.0 → 2.327.0
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/README.md +94 -4
- package/dist/binding-patch.js +17 -8
- package/dist/binding-patch.js.map +1 -1
- package/dist/body-capture.js +2 -2
- package/dist/body-capture.js.map +1 -1
- package/dist/capture-buffer.js +114 -0
- package/dist/capture-buffer.js.map +1 -0
- package/dist/context.js +6 -3
- package/dist/context.js.map +1 -1
- package/dist/error-capture.d.ts +44 -0
- package/dist/error-capture.js +65 -0
- package/dist/error-capture.js.map +1 -0
- package/dist/fetch-patch.js +5 -5
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.d.ts +62 -39
- package/dist/index.js +65 -179
- package/dist/index.js.map +1 -1
- package/dist/kv-capture.js +2 -2
- package/dist/kv-capture.js.map +1 -1
- package/dist/kv-patch.js +9 -9
- package/dist/kv-patch.js.map +1 -1
- package/dist/log.js +2 -2
- package/dist/log.js.map +1 -1
- package/dist/original-fetch.js +2 -2
- package/dist/original-fetch.js.map +1 -1
- package/dist/outbound-capture.js +20 -28
- package/dist/outbound-capture.js.map +1 -1
- package/dist/pages-function.d.ts +33 -0
- package/dist/pages-function.js +28 -0
- package/dist/pages-function.js.map +1 -0
- package/dist/postgres/capture.d.ts +108 -0
- package/dist/postgres/capture.js +136 -0
- package/dist/postgres/capture.js.map +1 -0
- package/dist/postgres/with-meticulous-postgres.d.ts +32 -0
- package/dist/postgres/with-meticulous-postgres.js +152 -0
- package/dist/postgres/with-meticulous-postgres.js.map +1 -0
- package/dist/protocol.d.ts +88 -28
- package/dist/protocol.js +15 -10
- package/dist/protocol.js.map +1 -1
- package/dist/provisional-session-id.d.ts +58 -0
- package/dist/provisional-session-id.js +75 -0
- package/dist/provisional-session-id.js.map +1 -0
- package/dist/publish-session-id.js +41 -0
- package/dist/publish-session-id.js.map +1 -0
- package/dist/redact-body.js +2 -2
- package/dist/redact-body.js.map +1 -1
- package/dist/replay-fetch.js +17 -13
- package/dist/replay-fetch.js.map +1 -1
- package/dist/replay-sidecar-url.js +2 -2
- package/dist/replay-sidecar-url.js.map +1 -1
- package/dist/sidecar-client.js +31 -20
- package/dist/sidecar-client.js.map +1 -1
- package/dist/sidecar-transport.d.ts +38 -0
- package/dist/sidecar-transport.js +52 -0
- package/dist/sidecar-transport.js.map +1 -0
- package/dist/spans/attributes.d.ts +84 -0
- package/dist/spans/attributes.js +88 -0
- package/dist/spans/attributes.js.map +1 -0
- package/dist/spans/readable-span.d.ts +78 -0
- package/dist/spans/readable-span.js +35 -0
- package/dist/spans/readable-span.js.map +1 -0
- package/dist/spans/serialize-span.d.ts +14 -0
- package/dist/spans/serialize-span.js +38 -0
- package/dist/spans/serialize-span.js.map +1 -0
- package/dist/spans/span-builder.d.ts +61 -0
- package/dist/spans/span-builder.js +250 -0
- package/dist/spans/span-builder.js.map +1 -0
- package/dist/spans/url-redaction.d.ts +14 -0
- package/dist/spans/url-redaction.js +40 -0
- package/dist/spans/url-redaction.js.map +1 -0
- package/dist/virtual-clock.js +2 -2
- package/dist/virtual-clock.js.map +1 -1
- package/dist/virtual-random.js +47 -20
- package/dist/virtual-random.js.map +1 -1
- package/dist/with-meticulous.d.ts +32 -0
- package/dist/with-meticulous.js +0 -0
- package/dist/with-meticulous.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="680329f2-970d-5fc7-89d6-78ab2702fa27")}catch(e){}}();
|
|
3
|
+
import { CAPTURED_HEADERS } from "../protocol.js";
|
|
4
|
+
import { MAX_POSTGRES_JS_RESULT_SIZE, POSTGRES_JS_ERROR_ATTR, POSTGRES_JS_QUERY_PARAMS_ATTR, POSTGRES_JS_QUERY_TEXT_ATTR, POSTGRES_JS_RESULT_ATTR, POSTGRES_JS_RESULT_TRUNCATED_ATTR, POSTGRES_JS_ROW_MODE_ATTR, postgresJsCommandOf } from "../postgres/capture.js";
|
|
5
|
+
import { CLIENT_TECHNOLOGY_ATTR, CLIENT_TECHNOLOGY_POSTGRES_JS, CLIENT_TECHNOLOGY_WORKERD_BINDING, CLIENT_TECHNOLOGY_WORKERD_FETCH, CLIENT_TECHNOLOGY_WORKERD_KV, FRONTEND_SESSION_ID_ATTR, SESSION_ID_ORIGIN_ATTR, WORKERD_BINDING_NAME_ATTR, WORKERD_KV_ARGS_ATTR, WORKERD_KV_ARGS_TRUNCATED_ATTR, WORKERD_KV_KEY_ATTR, WORKERD_KV_OMITTED_ATTR, WORKERD_KV_OPERATION_ATTR, WORKERD_KV_RESULT_ATTR, WORKERD_KV_RESULT_TRUNCATED_ATTR, WORKERD_KV_VALUE_ATTR, WORKERD_KV_VALUE_TRUNCATED_ATTR } from "./attributes.js";
|
|
6
|
+
import { msToHrTime } from "./readable-span.js";
|
|
7
|
+
import { redactUrlCredentials } from "./url-redaction.js";
|
|
8
|
+
//#region src/spans/span-builder.ts
|
|
9
|
+
/**
|
|
10
|
+
* Builds spans from shim capture events.
|
|
11
|
+
*
|
|
12
|
+
* Each inbound request becomes a SERVER span and the root of its own trace; every other event of
|
|
13
|
+
* that request becomes a CLIENT span parented under it. The shim supplies the trace and
|
|
14
|
+
* server-span ids (see `CorrelatedEvent`), so building a span needs no memory of earlier events
|
|
15
|
+
* — which is what lets a Worker-hosted sidecar be evicted between two batches of one request
|
|
16
|
+
* without splitting its trace.
|
|
17
|
+
*
|
|
18
|
+
* The `requestId → ids` fallback below exists only for a shim that predates those fields. It is
|
|
19
|
+
* the reason this is a class rather than a function.
|
|
20
|
+
*
|
|
21
|
+
* Shared with the Node recorder's sidecar (`packages/backend-recorder-js/src/sidecar/`), which
|
|
22
|
+
* re-exports it: both sidecars must produce identical spans or a recording made through one
|
|
23
|
+
* cannot be replayed by the machinery built for the other.
|
|
24
|
+
*/
|
|
25
|
+
var SpanBuilder = class {
|
|
26
|
+
/**
|
|
27
|
+
* Legacy fallback ids, keyed by `requestId`. Outbound events usually arrive BEFORE their
|
|
28
|
+
* inbound event (the inbound is only reported once the handler completes), so an entry is
|
|
29
|
+
* created lazily on first sight from either event kind.
|
|
30
|
+
*/
|
|
31
|
+
traceContexts = /* @__PURE__ */ new Map();
|
|
32
|
+
build(event) {
|
|
33
|
+
switch (event.kind) {
|
|
34
|
+
case "inbound": return this.buildInboundSpan(event);
|
|
35
|
+
case "outbound": return this.buildOutboundSpan(event);
|
|
36
|
+
case "binding": return this.buildBindingSpan(event);
|
|
37
|
+
case "kv": return this.buildKvSpan(event);
|
|
38
|
+
case "postgres": return this.buildPostgresSpan(event);
|
|
39
|
+
default: return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
buildInboundSpan(event) {
|
|
43
|
+
const ids = this.resolveIds(event);
|
|
44
|
+
const url = parseUrl(event.url);
|
|
45
|
+
const attributes = buildHttpAttributes(event, url);
|
|
46
|
+
if (event.sessionIdOrigin !== void 0) attributes[SESSION_ID_ORIGIN_ATTR] = event.sessionIdOrigin;
|
|
47
|
+
return {
|
|
48
|
+
name: `${event.method} ${url.path}`,
|
|
49
|
+
kind: 1,
|
|
50
|
+
status: buildStatus(event, 1),
|
|
51
|
+
startTime: msToHrTime(event.startTimeMs),
|
|
52
|
+
endTime: msToHrTime(event.endTimeMs),
|
|
53
|
+
duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),
|
|
54
|
+
attributes,
|
|
55
|
+
spanContext: () => ({
|
|
56
|
+
traceId: ids.traceId,
|
|
57
|
+
spanId: ids.serverSpanId
|
|
58
|
+
})
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
buildOutboundSpan(event) {
|
|
62
|
+
return this.buildHttpClientSpan(event, CLIENT_TECHNOLOGY_WORKERD_FETCH, event.method);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A binding call. Named after the binding rather than the bare method, since the `env` key is
|
|
66
|
+
* what identifies the call — the URL is whatever the app invented.
|
|
67
|
+
*/
|
|
68
|
+
buildBindingSpan(event) {
|
|
69
|
+
return this.buildHttpClientSpan(event, CLIENT_TECHNOLOGY_WORKERD_BINDING, `${event.method} ${event.bindingName ?? "$unknown"}`, event.bindingName !== void 0 ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName } : void 0);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A KV operation. Named `kv.<binding>.<operation>` after the Prisma convention
|
|
73
|
+
* (`prisma.<model>.<operation>`) rather than the `METHOD TARGET` shape the HTTP-like spans use
|
|
74
|
+
* — nothing about a KV call is an HTTP method, and the dotted name keeps a `get` on a
|
|
75
|
+
* namespace distinguishable from a `GET` through a service binding at a glance.
|
|
76
|
+
*/
|
|
77
|
+
buildKvSpan(event) {
|
|
78
|
+
const attributes = {
|
|
79
|
+
[CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_WORKERD_KV,
|
|
80
|
+
[WORKERD_KV_OPERATION_ATTR]: event.operation,
|
|
81
|
+
...event.bindingName !== void 0 ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName } : {},
|
|
82
|
+
...event.key !== void 0 ? { [WORKERD_KV_KEY_ATTR]: event.key } : {},
|
|
83
|
+
...event.omitted !== void 0 ? { [WORKERD_KV_OMITTED_ATTR]: event.omitted } : {},
|
|
84
|
+
...event.error !== void 0 ? { "error.type": event.error } : {},
|
|
85
|
+
...frontendSessionAttribute(event)
|
|
86
|
+
};
|
|
87
|
+
if (event.args !== void 0) {
|
|
88
|
+
attributes[WORKERD_KV_ARGS_ATTR] = event.args.body;
|
|
89
|
+
attributes[WORKERD_KV_ARGS_TRUNCATED_ATTR] = event.args.truncated;
|
|
90
|
+
}
|
|
91
|
+
if (event.value !== void 0) {
|
|
92
|
+
attributes[WORKERD_KV_VALUE_ATTR] = event.value.body;
|
|
93
|
+
attributes[WORKERD_KV_VALUE_TRUNCATED_ATTR] = event.value.truncated;
|
|
94
|
+
}
|
|
95
|
+
if (event.result !== void 0) {
|
|
96
|
+
attributes[WORKERD_KV_RESULT_ATTR] = event.result.body;
|
|
97
|
+
attributes[WORKERD_KV_RESULT_TRUNCATED_ATTR] = event.result.truncated;
|
|
98
|
+
}
|
|
99
|
+
return this.buildClientSpan(event, `kv.${event.bindingName ?? "$unknown"}.${event.operation}`, attributes, event.error !== void 0 ? {
|
|
100
|
+
code: 2,
|
|
101
|
+
message: event.error
|
|
102
|
+
} : { code: 0 });
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A postgres.js query. Name and attributes match what the Node instrumentation emits
|
|
106
|
+
* (`postgresjs.<command>`), because the same store loads both.
|
|
107
|
+
*/
|
|
108
|
+
buildPostgresSpan(event) {
|
|
109
|
+
const attributes = {
|
|
110
|
+
[CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_POSTGRES_JS,
|
|
111
|
+
[POSTGRES_JS_QUERY_TEXT_ATTR]: event.queryText,
|
|
112
|
+
[POSTGRES_JS_QUERY_PARAMS_ATTR]: event.params,
|
|
113
|
+
[POSTGRES_JS_ROW_MODE_ATTR]: event.rowMode,
|
|
114
|
+
...frontendSessionAttribute(event)
|
|
115
|
+
};
|
|
116
|
+
if (event.result !== void 0) {
|
|
117
|
+
const truncated = event.result.truncated || event.result.body.length > 262144;
|
|
118
|
+
attributes[POSTGRES_JS_RESULT_ATTR] = truncated ? event.result.body.slice(0, MAX_POSTGRES_JS_RESULT_SIZE) : event.result.body;
|
|
119
|
+
attributes[POSTGRES_JS_RESULT_TRUNCATED_ATTR] = truncated;
|
|
120
|
+
}
|
|
121
|
+
if (event.errorJson !== void 0) attributes[POSTGRES_JS_ERROR_ATTR] = event.errorJson;
|
|
122
|
+
return this.buildClientSpan(event, `postgresjs.${postgresJsCommandOf(event.queryText)}`, attributes, event.errorJson !== void 0 ? { code: 2 } : { code: 0 });
|
|
123
|
+
}
|
|
124
|
+
buildHttpClientSpan(event, technology, name, extraAttributes) {
|
|
125
|
+
const attributes = buildHttpAttributes(event, parseUrl(event.url));
|
|
126
|
+
attributes[CLIENT_TECHNOLOGY_ATTR] = technology;
|
|
127
|
+
Object.assign(attributes, extraAttributes);
|
|
128
|
+
if (event.requestBody !== void 0) {
|
|
129
|
+
attributes["http.request.body"] = event.requestBody.body;
|
|
130
|
+
attributes["http.request.body.truncated"] = event.requestBody.truncated;
|
|
131
|
+
}
|
|
132
|
+
if (event.responseBody !== void 0) {
|
|
133
|
+
attributes["http.response.body"] = event.responseBody.body;
|
|
134
|
+
attributes["http.response.body.truncated"] = event.responseBody.truncated;
|
|
135
|
+
}
|
|
136
|
+
return this.buildClientSpan(event, name, attributes, buildStatus(event, 2));
|
|
137
|
+
}
|
|
138
|
+
buildClientSpan(event, name, attributes, status) {
|
|
139
|
+
const ids = this.resolveIds(event);
|
|
140
|
+
const spanId = randomHex(8);
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
parentSpanId: ids.serverSpanId,
|
|
144
|
+
kind: 2,
|
|
145
|
+
status,
|
|
146
|
+
startTime: msToHrTime(event.startTimeMs),
|
|
147
|
+
endTime: msToHrTime(event.endTimeMs),
|
|
148
|
+
duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),
|
|
149
|
+
attributes,
|
|
150
|
+
spanContext: () => ({
|
|
151
|
+
traceId: ids.traceId,
|
|
152
|
+
spanId
|
|
153
|
+
})
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The trace and server-span ids for an event: the shim's, when it sent them, and otherwise a
|
|
158
|
+
* pair invented per `requestId` and remembered — the pre-`traceId` behaviour.
|
|
159
|
+
*/
|
|
160
|
+
resolveIds(event) {
|
|
161
|
+
if (event.traceId !== void 0 && event.serverSpanId !== void 0) return {
|
|
162
|
+
traceId: event.traceId,
|
|
163
|
+
serverSpanId: event.serverSpanId,
|
|
164
|
+
lastSeenMs: event.endTimeMs
|
|
165
|
+
};
|
|
166
|
+
const existing = this.traceContexts.get(event.requestId);
|
|
167
|
+
if (existing) {
|
|
168
|
+
existing.lastSeenMs = event.endTimeMs;
|
|
169
|
+
return existing;
|
|
170
|
+
}
|
|
171
|
+
this.pruneTraceContexts(event.endTimeMs);
|
|
172
|
+
const entry = {
|
|
173
|
+
traceId: randomHex(16),
|
|
174
|
+
serverSpanId: randomHex(8),
|
|
175
|
+
lastSeenMs: event.endTimeMs
|
|
176
|
+
};
|
|
177
|
+
this.traceContexts.set(event.requestId, entry);
|
|
178
|
+
return entry;
|
|
179
|
+
}
|
|
180
|
+
pruneTraceContexts(nowMs) {
|
|
181
|
+
if (this.traceContexts.size < TRACE_CONTEXT_PRUNE_THRESHOLD) return;
|
|
182
|
+
for (const [requestId, entry] of this.traceContexts) if (nowMs - entry.lastSeenMs > TRACE_CONTEXT_TTL_MS) this.traceContexts.delete(requestId);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const TRACE_CONTEXT_TTL_MS = 600 * 1e3;
|
|
186
|
+
const TRACE_CONTEXT_PRUNE_THRESHOLD = 1e3;
|
|
187
|
+
const HEX = "0123456789abcdef";
|
|
188
|
+
/** WebCrypto rather than `node:crypto`, so this runs in a Worker as well as in Node. */
|
|
189
|
+
const randomHex = (bytes) => {
|
|
190
|
+
const buffer = crypto.getRandomValues(new Uint8Array(bytes));
|
|
191
|
+
let hex = "";
|
|
192
|
+
for (const byte of buffer) hex += HEX[byte >> 4] + HEX[byte & 15];
|
|
193
|
+
return hex;
|
|
194
|
+
};
|
|
195
|
+
const capturedHeaders = new Set(CAPTURED_HEADERS);
|
|
196
|
+
const parseUrl = (raw) => {
|
|
197
|
+
try {
|
|
198
|
+
const url = redactUrlCredentials(new URL(raw));
|
|
199
|
+
const defaultPort = url.protocol === "https:" ? 443 : 80;
|
|
200
|
+
return {
|
|
201
|
+
full: url.toString(),
|
|
202
|
+
path: url.pathname,
|
|
203
|
+
query: url.search ? url.search.slice(1) : void 0,
|
|
204
|
+
scheme: url.protocol.replace(/:$/, ""),
|
|
205
|
+
host: url.hostname,
|
|
206
|
+
port: url.port ? parseInt(url.port, 10) : defaultPort
|
|
207
|
+
};
|
|
208
|
+
} catch {
|
|
209
|
+
return {
|
|
210
|
+
full: raw,
|
|
211
|
+
path: raw.split("?")[0] || "/",
|
|
212
|
+
query: void 0,
|
|
213
|
+
scheme: void 0,
|
|
214
|
+
host: void 0,
|
|
215
|
+
port: void 0
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const frontendSessionAttribute = (event) => event.frontendSessionId !== void 0 ? { [FRONTEND_SESSION_ID_ATTR]: event.frontendSessionId } : {};
|
|
220
|
+
const buildHttpAttributes = (event, url) => {
|
|
221
|
+
const attributes = {
|
|
222
|
+
"http.request.method": event.method,
|
|
223
|
+
"url.full": url.full,
|
|
224
|
+
"url.path": url.path,
|
|
225
|
+
...url.query !== void 0 ? { "url.query": url.query } : {},
|
|
226
|
+
...url.scheme !== void 0 ? { "url.scheme": url.scheme } : {},
|
|
227
|
+
...url.host !== void 0 ? { "server.address": url.host } : {},
|
|
228
|
+
...url.port !== void 0 ? { "server.port": url.port } : {},
|
|
229
|
+
...event.statusCode !== void 0 ? { "http.response.status_code": event.statusCode } : {},
|
|
230
|
+
...event.error !== void 0 ? { "error.type": event.error } : {},
|
|
231
|
+
...frontendSessionAttribute(event)
|
|
232
|
+
};
|
|
233
|
+
for (const [name, values] of Object.entries(event.requestHeaders)) if (capturedHeaders.has(name.toLowerCase())) attributes[`http.request.header.${name}`] = values;
|
|
234
|
+
for (const [name, values] of Object.entries(event.responseHeaders ?? {})) if (capturedHeaders.has(name.toLowerCase())) attributes[`http.response.header.${name}`] = values;
|
|
235
|
+
return attributes;
|
|
236
|
+
};
|
|
237
|
+
const buildStatus = (event, kind) => {
|
|
238
|
+
if (event.error !== void 0) return {
|
|
239
|
+
code: 2,
|
|
240
|
+
message: event.error
|
|
241
|
+
};
|
|
242
|
+
const errorThreshold = kind === 2 ? 400 : 500;
|
|
243
|
+
if (event.statusCode !== void 0 && event.statusCode >= errorThreshold) return { code: 2 };
|
|
244
|
+
return { code: 0 };
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
export { SpanBuilder, randomHex };
|
|
248
|
+
|
|
249
|
+
//# sourceMappingURL=span-builder.js.map
|
|
250
|
+
//# debugId=680329f2-970d-5fc7-89d6-78ab2702fa27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"span-builder.js","sources":["../../src/spans/span-builder.ts"],"sourcesContent":["import {\n type BindingRequestEvent,\n CAPTURED_HEADERS,\n type CaptureEvent,\n type InboundRequestEvent,\n type KvOperationEvent,\n type OutboundRequestEvent,\n type PostgresQueryEvent,\n} from \"../protocol\";\nimport {\n MAX_POSTGRES_JS_RESULT_SIZE,\n POSTGRES_JS_ERROR_ATTR,\n POSTGRES_JS_QUERY_PARAMS_ATTR,\n POSTGRES_JS_QUERY_TEXT_ATTR,\n POSTGRES_JS_RESULT_ATTR,\n POSTGRES_JS_RESULT_TRUNCATED_ATTR,\n POSTGRES_JS_ROW_MODE_ATTR,\n postgresJsCommandOf,\n} from \"../postgres/capture\";\nimport {\n CLIENT_TECHNOLOGY_ATTR,\n CLIENT_TECHNOLOGY_POSTGRES_JS,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n CLIENT_TECHNOLOGY_WORKERD_KV,\n FRONTEND_SESSION_ID_ATTR,\n SESSION_ID_ORIGIN_ATTR,\n WORKERD_BINDING_NAME_ATTR,\n WORKERD_KV_ARGS_ATTR,\n WORKERD_KV_ARGS_TRUNCATED_ATTR,\n WORKERD_KV_KEY_ATTR,\n WORKERD_KV_OMITTED_ATTR,\n WORKERD_KV_OPERATION_ATTR,\n WORKERD_KV_RESULT_ATTR,\n WORKERD_KV_RESULT_TRUNCATED_ATTR,\n WORKERD_KV_VALUE_ATTR,\n WORKERD_KV_VALUE_TRUNCATED_ATTR,\n} from \"./attributes\";\nimport {\n msToHrTime,\n type ReadableSpan,\n SPAN_KIND_CLIENT,\n SPAN_KIND_SERVER,\n SPAN_STATUS_ERROR,\n SPAN_STATUS_UNSET,\n type SpanStatus,\n} from \"./readable-span\";\nimport { redactUrlCredentials } from \"./url-redaction\";\n\n/**\n * Builds spans from shim capture events.\n *\n * Each inbound request becomes a SERVER span and the root of its own trace; every other event of\n * that request becomes a CLIENT span parented under it. The shim supplies the trace and\n * server-span ids (see `CorrelatedEvent`), so building a span needs no memory of earlier events\n * — which is what lets a Worker-hosted sidecar be evicted between two batches of one request\n * without splitting its trace.\n *\n * The `requestId → ids` fallback below exists only for a shim that predates those fields. It is\n * the reason this is a class rather than a function.\n *\n * Shared with the Node recorder's sidecar (`packages/backend-recorder-js/src/sidecar/`), which\n * re-exports it: both sidecars must produce identical spans or a recording made through one\n * cannot be replayed by the machinery built for the other.\n */\nexport class SpanBuilder {\n /**\n * Legacy fallback ids, keyed by `requestId`. Outbound events usually arrive BEFORE their\n * inbound event (the inbound is only reported once the handler completes), so an entry is\n * created lazily on first sight from either event kind.\n */\n private readonly traceContexts = new Map<string, TraceContextEntry>();\n\n build(event: CaptureEvent): ReadableSpan | null {\n switch (event.kind) {\n case \"inbound\":\n return this.buildInboundSpan(event);\n case \"outbound\":\n return this.buildOutboundSpan(event);\n case \"binding\":\n return this.buildBindingSpan(event);\n case \"kv\":\n return this.buildKvSpan(event);\n case \"postgres\":\n return this.buildPostgresSpan(event);\n default:\n // Wire input, possibly from a newer shim — skip rather than crash.\n return null;\n }\n }\n\n private buildInboundSpan(event: InboundRequestEvent): ReadableSpan {\n const ids = this.resolveIds(event);\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n\n // Only the SERVER span carries it, matching MetHttpInstrumentation on the Node path: the\n // marker describes where this request's session id came from, and the CLIENT spans under\n // it share the same trace.\n if (event.sessionIdOrigin !== undefined) {\n attributes[SESSION_ID_ORIGIN_ATTR] = event.sessionIdOrigin;\n }\n\n return {\n name: `${event.method} ${url.path}`,\n kind: SPAN_KIND_SERVER,\n status: buildStatus(event, SPAN_KIND_SERVER),\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({\n traceId: ids.traceId,\n spanId: ids.serverSpanId,\n }),\n };\n }\n\n private buildOutboundSpan(event: OutboundRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n event.method,\n );\n }\n\n /**\n * A binding call. Named after the binding rather than the bare method, since the `env` key is\n * what identifies the call — the URL is whatever the app invented.\n */\n private buildBindingSpan(event: BindingRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n `${event.method} ${event.bindingName ?? \"$unknown\"}`,\n event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : undefined,\n );\n }\n\n /**\n * A KV operation. Named `kv.<binding>.<operation>` after the Prisma convention\n * (`prisma.<model>.<operation>`) rather than the `METHOD TARGET` shape the HTTP-like spans use\n * — nothing about a KV call is an HTTP method, and the dotted name keeps a `get` on a\n * namespace distinguishable from a `GET` through a service binding at a glance.\n */\n private buildKvSpan(event: KvOperationEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_WORKERD_KV,\n [WORKERD_KV_OPERATION_ATTR]: event.operation,\n ...(event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : {}),\n ...(event.key !== undefined ? { [WORKERD_KV_KEY_ATTR]: event.key } : {}),\n ...(event.omitted !== undefined\n ? { [WORKERD_KV_OMITTED_ATTR]: event.omitted }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n if (event.args !== undefined) {\n attributes[WORKERD_KV_ARGS_ATTR] = event.args.body;\n attributes[WORKERD_KV_ARGS_TRUNCATED_ATTR] = event.args.truncated;\n }\n if (event.value !== undefined) {\n attributes[WORKERD_KV_VALUE_ATTR] = event.value.body;\n attributes[WORKERD_KV_VALUE_TRUNCATED_ATTR] = event.value.truncated;\n }\n if (event.result !== undefined) {\n attributes[WORKERD_KV_RESULT_ATTR] = event.result.body;\n attributes[WORKERD_KV_RESULT_TRUNCATED_ATTR] = event.result.truncated;\n }\n\n return this.buildClientSpan(\n event,\n `kv.${event.bindingName ?? \"$unknown\"}.${event.operation}`,\n attributes,\n event.error !== undefined\n ? { code: SPAN_STATUS_ERROR, message: event.error }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n /**\n * A postgres.js query. Name and attributes match what the Node instrumentation emits\n * (`postgresjs.<command>`), because the same store loads both.\n */\n private buildPostgresSpan(event: PostgresQueryEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_POSTGRES_JS,\n [POSTGRES_JS_QUERY_TEXT_ATTR]: event.queryText,\n [POSTGRES_JS_QUERY_PARAMS_ATTR]: event.params,\n [POSTGRES_JS_ROW_MODE_ATTR]: event.rowMode,\n ...frontendSessionAttribute(event),\n };\n if (event.result !== undefined) {\n // Re-cap here as well as at capture time: the attribute contract is what the store\n // reads, and a shim that capped differently must not widen it.\n const truncated =\n event.result.truncated ||\n event.result.body.length > MAX_POSTGRES_JS_RESULT_SIZE;\n attributes[POSTGRES_JS_RESULT_ATTR] = truncated\n ? event.result.body.slice(0, MAX_POSTGRES_JS_RESULT_SIZE)\n : event.result.body;\n attributes[POSTGRES_JS_RESULT_TRUNCATED_ATTR] = truncated;\n }\n if (event.errorJson !== undefined) {\n attributes[POSTGRES_JS_ERROR_ATTR] = event.errorJson;\n }\n\n return this.buildClientSpan(\n event,\n `postgresjs.${postgresJsCommandOf(event.queryText)}`,\n attributes,\n event.errorJson !== undefined\n ? { code: SPAN_STATUS_ERROR }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n private buildHttpClientSpan(\n event: OutboundRequestEvent | BindingRequestEvent,\n technology: string,\n name: string,\n extraAttributes?: Record<string, unknown>,\n ): ReadableSpan {\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n attributes[CLIENT_TECHNOLOGY_ATTR] = technology;\n Object.assign(attributes, extraAttributes);\n if (event.requestBody !== undefined) {\n attributes[\"http.request.body\"] = event.requestBody.body;\n attributes[\"http.request.body.truncated\"] = event.requestBody.truncated;\n }\n if (event.responseBody !== undefined) {\n attributes[\"http.response.body\"] = event.responseBody.body;\n attributes[\"http.response.body.truncated\"] = event.responseBody.truncated;\n }\n\n return this.buildClientSpan(\n event,\n name,\n attributes,\n buildStatus(event, SPAN_KIND_CLIENT),\n );\n }\n\n private buildClientSpan(\n event: CaptureEvent,\n name: string,\n attributes: Record<string, unknown>,\n status: SpanStatus,\n ): ReadableSpan {\n const ids = this.resolveIds(event);\n const spanId = randomHex(8);\n return {\n name,\n parentSpanId: ids.serverSpanId,\n kind: SPAN_KIND_CLIENT,\n status,\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({ traceId: ids.traceId, spanId }),\n };\n }\n\n /**\n * The trace and server-span ids for an event: the shim's, when it sent them, and otherwise a\n * pair invented per `requestId` and remembered — the pre-`traceId` behaviour.\n */\n private resolveIds(event: CaptureEvent): TraceContextEntry {\n if (event.traceId !== undefined && event.serverSpanId !== undefined) {\n return {\n traceId: event.traceId,\n serverSpanId: event.serverSpanId,\n lastSeenMs: event.endTimeMs,\n };\n }\n const existing = this.traceContexts.get(event.requestId);\n if (existing) {\n existing.lastSeenMs = event.endTimeMs;\n return existing;\n }\n this.pruneTraceContexts(event.endTimeMs);\n const entry: TraceContextEntry = {\n traceId: randomHex(16),\n serverSpanId: randomHex(8),\n lastSeenMs: event.endTimeMs,\n };\n this.traceContexts.set(event.requestId, entry);\n return entry;\n }\n\n private pruneTraceContexts(nowMs: number): void {\n if (this.traceContexts.size < TRACE_CONTEXT_PRUNE_THRESHOLD) {\n return;\n }\n for (const [requestId, entry] of this.traceContexts) {\n if (nowMs - entry.lastSeenMs > TRACE_CONTEXT_TTL_MS) {\n this.traceContexts.delete(requestId);\n }\n }\n }\n}\n\ninterface TraceContextEntry {\n traceId: string;\n serverSpanId: string;\n lastSeenMs: number;\n}\n\nconst TRACE_CONTEXT_TTL_MS = 10 * 60 * 1000;\nconst TRACE_CONTEXT_PRUNE_THRESHOLD = 1_000;\n\nconst HEX = \"0123456789abcdef\";\n\n/** WebCrypto rather than `node:crypto`, so this runs in a Worker as well as in Node. */\nexport const randomHex = (bytes: number): string => {\n const buffer = crypto.getRandomValues(new Uint8Array(bytes));\n let hex = \"\";\n for (const byte of buffer) {\n hex += HEX[byte >> 4] + HEX[byte & 0x0f];\n }\n return hex;\n};\n\nconst capturedHeaders = new Set<string>(CAPTURED_HEADERS);\n\ninterface ParsedUrl {\n full: string;\n path: string;\n query: string | undefined;\n scheme: string | undefined;\n host: string | undefined;\n port: number | undefined;\n}\n\nconst parseUrl = (raw: string): ParsedUrl => {\n try {\n const url = redactUrlCredentials(new URL(raw));\n const defaultPort = url.protocol === \"https:\" ? 443 : 80;\n return {\n full: url.toString(),\n path: url.pathname,\n query: url.search ? url.search.slice(1) : undefined,\n scheme: url.protocol.replace(/:$/, \"\"),\n host: url.hostname,\n port: url.port ? parseInt(url.port, 10) : defaultPort,\n };\n } catch {\n return {\n full: raw,\n path: raw.split(\"?\")[0] || \"/\",\n query: undefined,\n scheme: undefined,\n host: undefined,\n port: undefined,\n };\n }\n};\n\nconst frontendSessionAttribute = (\n event: CaptureEvent,\n): Record<string, string> =>\n event.frontendSessionId !== undefined\n ? { [FRONTEND_SESSION_ID_ATTR]: event.frontendSessionId }\n : {};\n\nconst buildHttpAttributes = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n url: ParsedUrl,\n): Record<string, unknown> => {\n const attributes: Record<string, unknown> = {\n \"http.request.method\": event.method,\n \"url.full\": url.full,\n \"url.path\": url.path,\n ...(url.query !== undefined ? { \"url.query\": url.query } : {}),\n ...(url.scheme !== undefined ? { \"url.scheme\": url.scheme } : {}),\n ...(url.host !== undefined ? { \"server.address\": url.host } : {}),\n ...(url.port !== undefined ? { \"server.port\": url.port } : {}),\n ...(event.statusCode !== undefined\n ? { \"http.response.status_code\": event.statusCode }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n // The shim already filters to CAPTURED_HEADERS; re-filter here so a stale or third-party\n // shim can't persist sensitive headers via the sidecar.\n for (const [name, values] of Object.entries(event.requestHeaders)) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.request.header.${name}`] = values;\n }\n }\n for (const [name, values] of Object.entries(event.responseHeaders ?? {})) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.response.header.${name}`] = values;\n }\n }\n return attributes;\n};\n\nconst buildStatus = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n kind: number,\n): SpanStatus => {\n if (event.error !== undefined) {\n return { code: SPAN_STATUS_ERROR, message: event.error };\n }\n // Standard OTel HTTP semantics: client spans error on 4xx/5xx, server spans only on 5xx.\n const errorThreshold = kind === SPAN_KIND_CLIENT ? 400 : 500;\n if (event.statusCode !== undefined && event.statusCode >= errorThreshold) {\n return { code: SPAN_STATUS_ERROR };\n }\n return { code: SPAN_STATUS_UNSET };\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAiEA,IAAa,cAAb,MAAyB;;;;;;CAMvB,gCAAiC,IAAI,KAAgC;CAErE,MAAM,OAA0C;AAC9C,UAAQ,MAAM,MAAd;GACE,KAAK,UACH,QAAO,KAAK,iBAAiB,MAAM;GACrC,KAAK,WACH,QAAO,KAAK,kBAAkB,MAAM;GACtC,KAAK,UACH,QAAO,KAAK,iBAAiB,MAAM;GACrC,KAAK,KACH,QAAO,KAAK,YAAY,MAAM;GAChC,KAAK,WACH,QAAO,KAAK,kBAAkB,MAAM;GACtC,QAEE,QAAO;;;CAIb,iBAAyB,OAA0C;EACjE,MAAM,MAAM,KAAK,WAAW,MAAM;EAClC,MAAM,MAAM,SAAS,MAAM,IAAI;EAC/B,MAAM,aAAa,oBAAoB,OAAO,IAAI;AAKlD,MAAI,MAAM,oBAAoB,KAAA,EAC5B,YAAW,0BAA0B,MAAM;AAG7C,SAAO;GACL,MAAM,GAAG,MAAM,OAAO,GAAG,IAAI;GAC7B,MAAA;GACA,QAAQ,YAAY,OAAA,EAAwB;GAC5C,WAAW,WAAW,MAAM,YAAY;GACxC,SAAS,WAAW,MAAM,UAAU;GACpC,UAAU,WAAW,KAAK,IAAI,GAAG,MAAM,YAAY,MAAM,YAAY,CAAC;GACtE;GACA,oBAAoB;IAClB,SAAS,IAAI;IACb,QAAQ,IAAI;IACb;GACF;;CAGH,kBAA0B,OAA2C;AACnE,SAAO,KAAK,oBACV,OACA,iCACA,MAAM,OACP;;;;;;CAOH,iBAAyB,OAA0C;AACjE,SAAO,KAAK,oBACV,OACA,mCACA,GAAG,MAAM,OAAO,GAAG,MAAM,eAAe,cACxC,MAAM,gBAAgB,KAAA,IAClB,GAAG,4BAA4B,MAAM,aAAa,GAClD,KAAA,EACL;;;;;;;;CASH,YAAoB,OAAuC;EACzD,MAAM,aAAsC;IACzC,yBAAyB;IACzB,4BAA4B,MAAM;GACnC,GAAI,MAAM,gBAAgB,KAAA,IACtB,GAAG,4BAA4B,MAAM,aAAa,GAClD,EAAE;GACN,GAAI,MAAM,QAAQ,KAAA,IAAY,GAAG,sBAAsB,MAAM,KAAK,GAAG,EAAE;GACvE,GAAI,MAAM,YAAY,KAAA,IAClB,GAAG,0BAA0B,MAAM,SAAS,GAC5C,EAAE;GACN,GAAI,MAAM,UAAU,KAAA,IAAY,EAAE,cAAc,MAAM,OAAO,GAAG,EAAE;GAClE,GAAG,yBAAyB,MAAM;GACnC;AACD,MAAI,MAAM,SAAS,KAAA,GAAW;AAC5B,cAAW,wBAAwB,MAAM,KAAK;AAC9C,cAAW,kCAAkC,MAAM,KAAK;;AAE1D,MAAI,MAAM,UAAU,KAAA,GAAW;AAC7B,cAAW,yBAAyB,MAAM,MAAM;AAChD,cAAW,mCAAmC,MAAM,MAAM;;AAE5D,MAAI,MAAM,WAAW,KAAA,GAAW;AAC9B,cAAW,0BAA0B,MAAM,OAAO;AAClD,cAAW,oCAAoC,MAAM,OAAO;;AAG9D,SAAO,KAAK,gBACV,OACA,MAAM,MAAM,eAAe,WAAW,GAAG,MAAM,aAC/C,YACA,MAAM,UAAU,KAAA,IACZ;GAAE,MAAA;GAAyB,SAAS,MAAM;GAAO,GACjD,EAAE,MAAA,GAAyB,CAChC;;;;;;CAOH,kBAA0B,OAAyC;EACjE,MAAM,aAAsC;IACzC,yBAAyB;IACzB,8BAA8B,MAAM;IACpC,gCAAgC,MAAM;IACtC,4BAA4B,MAAM;GACnC,GAAG,yBAAyB,MAAM;GACnC;AACD,MAAI,MAAM,WAAW,KAAA,GAAW;GAG9B,MAAM,YACJ,MAAM,OAAO,aACb,MAAM,OAAO,KAAK,SAAA;AACpB,cAAW,2BAA2B,YAClC,MAAM,OAAO,KAAK,MAAM,GAAG,4BAA4B,GACvD,MAAM,OAAO;AACjB,cAAW,qCAAqC;;AAElD,MAAI,MAAM,cAAc,KAAA,EACtB,YAAW,0BAA0B,MAAM;AAG7C,SAAO,KAAK,gBACV,OACA,cAAc,oBAAoB,MAAM,UAAU,IAClD,YACA,MAAM,cAAc,KAAA,IAChB,EAAE,MAAA,GAAyB,GAC3B,EAAE,MAAA,GAAyB,CAChC;;CAGH,oBACE,OACA,YACA,MACA,iBACc;EAEd,MAAM,aAAa,oBAAoB,OAD3B,SAAS,MAAM,IACsB,CAAC;AAClD,aAAW,0BAA0B;AACrC,SAAO,OAAO,YAAY,gBAAgB;AAC1C,MAAI,MAAM,gBAAgB,KAAA,GAAW;AACnC,cAAW,uBAAuB,MAAM,YAAY;AACpD,cAAW,iCAAiC,MAAM,YAAY;;AAEhE,MAAI,MAAM,iBAAiB,KAAA,GAAW;AACpC,cAAW,wBAAwB,MAAM,aAAa;AACtD,cAAW,kCAAkC,MAAM,aAAa;;AAGlE,SAAO,KAAK,gBACV,OACA,MACA,YACA,YAAY,OAAA,EAAwB,CACrC;;CAGH,gBACE,OACA,MACA,YACA,QACc;EACd,MAAM,MAAM,KAAK,WAAW,MAAM;EAClC,MAAM,SAAS,UAAU,EAAE;AAC3B,SAAO;GACL;GACA,cAAc,IAAI;GAClB,MAAA;GACA;GACA,WAAW,WAAW,MAAM,YAAY;GACxC,SAAS,WAAW,MAAM,UAAU;GACpC,UAAU,WAAW,KAAK,IAAI,GAAG,MAAM,YAAY,MAAM,YAAY,CAAC;GACtE;GACA,oBAAoB;IAAE,SAAS,IAAI;IAAS;IAAQ;GACrD;;;;;;CAOH,WAAmB,OAAwC;AACzD,MAAI,MAAM,YAAY,KAAA,KAAa,MAAM,iBAAiB,KAAA,EACxD,QAAO;GACL,SAAS,MAAM;GACf,cAAc,MAAM;GACpB,YAAY,MAAM;GACnB;EAEH,MAAM,WAAW,KAAK,cAAc,IAAI,MAAM,UAAU;AACxD,MAAI,UAAU;AACZ,YAAS,aAAa,MAAM;AAC5B,UAAO;;AAET,OAAK,mBAAmB,MAAM,UAAU;EACxC,MAAM,QAA2B;GAC/B,SAAS,UAAU,GAAG;GACtB,cAAc,UAAU,EAAE;GAC1B,YAAY,MAAM;GACnB;AACD,OAAK,cAAc,IAAI,MAAM,WAAW,MAAM;AAC9C,SAAO;;CAGT,mBAA2B,OAAqB;AAC9C,MAAI,KAAK,cAAc,OAAO,8BAC5B;AAEF,OAAK,MAAM,CAAC,WAAW,UAAU,KAAK,cACpC,KAAI,QAAQ,MAAM,aAAa,qBAC7B,MAAK,cAAc,OAAO,UAAU;;;AAY5C,MAAM,uBAAuB,MAAU;AACvC,MAAM,gCAAgC;AAEtC,MAAM,MAAM;;AAGZ,MAAa,aAAa,UAA0B;CAClD,MAAM,SAAS,OAAO,gBAAgB,IAAI,WAAW,MAAM,CAAC;CAC5D,IAAI,MAAM;AACV,MAAK,MAAM,QAAQ,OACjB,QAAO,IAAI,QAAQ,KAAK,IAAI,OAAO;AAErC,QAAO;;AAGT,MAAM,kBAAkB,IAAI,IAAY,iBAAiB;AAWzD,MAAM,YAAY,QAA2B;AAC3C,KAAI;EACF,MAAM,MAAM,qBAAqB,IAAI,IAAI,IAAI,CAAC;EAC9C,MAAM,cAAc,IAAI,aAAa,WAAW,MAAM;AACtD,SAAO;GACL,MAAM,IAAI,UAAU;GACpB,MAAM,IAAI;GACV,OAAO,IAAI,SAAS,IAAI,OAAO,MAAM,EAAE,GAAG,KAAA;GAC1C,QAAQ,IAAI,SAAS,QAAQ,MAAM,GAAG;GACtC,MAAM,IAAI;GACV,MAAM,IAAI,OAAO,SAAS,IAAI,MAAM,GAAG,GAAG;GAC3C;SACK;AACN,SAAO;GACL,MAAM;GACN,MAAM,IAAI,MAAM,IAAI,CAAC,MAAM;GAC3B,OAAO,KAAA;GACP,QAAQ,KAAA;GACR,MAAM,KAAA;GACN,MAAM,KAAA;GACP;;;AAIL,MAAM,4BACJ,UAEA,MAAM,sBAAsB,KAAA,IACxB,GAAG,2BAA2B,MAAM,mBAAmB,GACvD,EAAE;AAER,MAAM,uBACJ,OACA,QAC4B;CAC5B,MAAM,aAAsC;EAC1C,uBAAuB,MAAM;EAC7B,YAAY,IAAI;EAChB,YAAY,IAAI;EAChB,GAAI,IAAI,UAAU,KAAA,IAAY,EAAE,aAAa,IAAI,OAAO,GAAG,EAAE;EAC7D,GAAI,IAAI,WAAW,KAAA,IAAY,EAAE,cAAc,IAAI,QAAQ,GAAG,EAAE;EAChE,GAAI,IAAI,SAAS,KAAA,IAAY,EAAE,kBAAkB,IAAI,MAAM,GAAG,EAAE;EAChE,GAAI,IAAI,SAAS,KAAA,IAAY,EAAE,eAAe,IAAI,MAAM,GAAG,EAAE;EAC7D,GAAI,MAAM,eAAe,KAAA,IACrB,EAAE,6BAA6B,MAAM,YAAY,GACjD,EAAE;EACN,GAAI,MAAM,UAAU,KAAA,IAAY,EAAE,cAAc,MAAM,OAAO,GAAG,EAAE;EAClE,GAAG,yBAAyB,MAAM;EACnC;AAGD,MAAK,MAAM,CAAC,MAAM,WAAW,OAAO,QAAQ,MAAM,eAAe,CAC/D,KAAI,gBAAgB,IAAI,KAAK,aAAa,CAAC,CACzC,YAAW,uBAAuB,UAAU;AAGhD,MAAK,MAAM,CAAC,MAAM,WAAW,OAAO,QAAQ,MAAM,mBAAmB,EAAE,CAAC,CACtE,KAAI,gBAAgB,IAAI,KAAK,aAAa,CAAC,CACzC,YAAW,wBAAwB,UAAU;AAGjD,QAAO;;AAGT,MAAM,eACJ,OACA,SACe;AACf,KAAI,MAAM,UAAU,KAAA,EAClB,QAAO;EAAE,MAAA;EAAyB,SAAS,MAAM;EAAO;CAG1D,MAAM,iBAAiB,SAAA,IAA4B,MAAM;AACzD,KAAI,MAAM,eAAe,KAAA,KAAa,MAAM,cAAc,eACxD,QAAO,EAAE,MAAA,GAAyB;AAEpC,QAAO,EAAE,MAAA,GAAyB","debugId":"680329f2-970d-5fc7-89d6-78ab2702fa27"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/spans/url-redaction.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Returns a copy of the URL with credential-bearing parts replaced: the signature-style query
|
|
4
|
+
* parameters above, and any `user:password@` userinfo.
|
|
5
|
+
*
|
|
6
|
+
* Record side only. A replay lookup passes the live URL through unredacted, exactly as the
|
|
7
|
+
* http, undici and sidecar lookup paths do, so a redacted parameter misses the exact tier and
|
|
8
|
+
* is served by the drop-query tier instead — the right outcome, since a signature could not
|
|
9
|
+
* have matched exactly across a record/replay boundary anyway.
|
|
10
|
+
*/
|
|
11
|
+
declare const redactUrlCredentials: (url: URL) => URL;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { redactUrlCredentials };
|
|
14
|
+
//# sourceMappingURL=url-redaction.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="8b13fac8-683e-5692-90a7-5cdfd2b8035d")}catch(e){}}();
|
|
3
|
+
import { STR_REDACTED } from "../redact-body.js";
|
|
4
|
+
//#region src/spans/url-redaction.ts
|
|
5
|
+
/**
|
|
6
|
+
* URL query keys that carry a credential rather than identify a resource.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `DEFAULT_QUERY_STRINGS_TO_REDACT` in the Node recorder's forked http
|
|
9
|
+
* instrumentation. Both surfaces write URLs onto the same spans, so the two lists have to stay
|
|
10
|
+
* identical or the same request records differently depending on where it was captured.
|
|
11
|
+
*/
|
|
12
|
+
const QUERY_STRINGS_TO_REDACT = [
|
|
13
|
+
"sig",
|
|
14
|
+
"Signature",
|
|
15
|
+
"AWSAccessKeyId",
|
|
16
|
+
"X-Goog-Signature"
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Returns a copy of the URL with credential-bearing parts replaced: the signature-style query
|
|
20
|
+
* parameters above, and any `user:password@` userinfo.
|
|
21
|
+
*
|
|
22
|
+
* Record side only. A replay lookup passes the live URL through unredacted, exactly as the
|
|
23
|
+
* http, undici and sidecar lookup paths do, so a redacted parameter misses the exact tier and
|
|
24
|
+
* is served by the drop-query tier instead — the right outcome, since a signature could not
|
|
25
|
+
* have matched exactly across a record/replay boundary anyway.
|
|
26
|
+
*/
|
|
27
|
+
const redactUrlCredentials = (url) => {
|
|
28
|
+
const redacted = new URL(url.href);
|
|
29
|
+
for (const sensitiveParam of QUERY_STRINGS_TO_REDACT) if (redacted.searchParams.get(sensitiveParam)) redacted.searchParams.set(sensitiveParam, STR_REDACTED);
|
|
30
|
+
if (redacted.username || redacted.password) {
|
|
31
|
+
redacted.username = STR_REDACTED;
|
|
32
|
+
redacted.password = STR_REDACTED;
|
|
33
|
+
}
|
|
34
|
+
return redacted;
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { redactUrlCredentials };
|
|
38
|
+
|
|
39
|
+
//# sourceMappingURL=url-redaction.js.map
|
|
40
|
+
//# debugId=8b13fac8-683e-5692-90a7-5cdfd2b8035d
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url-redaction.js","sources":["../../src/spans/url-redaction.ts"],"sourcesContent":["import { STR_REDACTED } from \"../redact-body\";\n\n/**\n * URL query keys that carry a credential rather than identify a resource.\n *\n * Mirrors `DEFAULT_QUERY_STRINGS_TO_REDACT` in the Node recorder's forked http\n * instrumentation. Both surfaces write URLs onto the same spans, so the two lists have to stay\n * identical or the same request records differently depending on where it was captured.\n */\nconst QUERY_STRINGS_TO_REDACT = [\n \"sig\",\n \"Signature\",\n \"AWSAccessKeyId\",\n \"X-Goog-Signature\",\n] as const;\n\n/**\n * Returns a copy of the URL with credential-bearing parts replaced: the signature-style query\n * parameters above, and any `user:password@` userinfo.\n *\n * Record side only. A replay lookup passes the live URL through unredacted, exactly as the\n * http, undici and sidecar lookup paths do, so a redacted parameter misses the exact tier and\n * is served by the drop-query tier instead — the right outcome, since a signature could not\n * have matched exactly across a record/replay boundary anyway.\n */\nexport const redactUrlCredentials = (url: URL): URL => {\n const redacted = new URL(url.href);\n for (const sensitiveParam of QUERY_STRINGS_TO_REDACT) {\n if (redacted.searchParams.get(sensitiveParam)) {\n redacted.searchParams.set(sensitiveParam, STR_REDACTED);\n }\n }\n if (redacted.username || redacted.password) {\n redacted.username = STR_REDACTED;\n redacted.password = STR_REDACTED;\n }\n return redacted;\n};\n"],"names":[],"mappings":";;;;;;;;;;;AASA,MAAM,0BAA0B;CAC9B;CACA;CACA;CACA;CACD;;;;;;;;;;AAWD,MAAa,wBAAwB,QAAkB;CACrD,MAAM,WAAW,IAAI,IAAI,IAAI,KAAK;AAClC,MAAK,MAAM,kBAAkB,wBAC3B,KAAI,SAAS,aAAa,IAAI,eAAe,CAC3C,UAAS,aAAa,IAAI,gBAAgB,aAAa;AAG3D,KAAI,SAAS,YAAY,SAAS,UAAU;AAC1C,WAAS,WAAW;AACpB,WAAS,WAAW;;AAEtB,QAAO","debugId":"8b13fac8-683e-5692-90a7-5cdfd2b8035d"}
|
package/dist/virtual-clock.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="39c427c3-1a1e-58aa-9021-0bd113903895")}catch(e){}}();
|
|
3
3
|
import { requestCaptureContext } from "./context.js";
|
|
4
4
|
import { warnOnce } from "./log.js";
|
|
5
5
|
//#region src/virtual-clock.ts
|
|
@@ -133,4 +133,4 @@ const installVirtualClock = () => {
|
|
|
133
133
|
export { installVirtualClock };
|
|
134
134
|
|
|
135
135
|
//# sourceMappingURL=virtual-clock.js.map
|
|
136
|
-
//# debugId=
|
|
136
|
+
//# debugId=39c427c3-1a1e-58aa-9021-0bd113903895
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"virtual-clock.js","sources":["../src/virtual-clock.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\n\n/**\n * Freezes the worker's wall clock at the replayed session's last recorded activity.\n *\n * Without this, replay fails even when every outbound call is mocked correctly: credentials\n * minted during the recording (Firebase session cookies, OAuth access tokens) carry\n * short expiries, so by replay time the app rejects its own recorded credentials as stale.\n * Anchoring at the *end* of the recording means everything minted mid-session is already\n * issued but not yet expired.\n *\n * Ported from the Node backend recorder's replay clock, with two differences:\n * - the anchor comes from the request context rather than a session→anchor map, because\n * workerd's AsyncLocalStorage propagates reliably from the inbound handler. The Node\n * version needs a process-wide \"most recently seen session\" fallback, which can\n * resolve the wrong session on a replica serving several at once;\n * - the busy-wait breaker schedules on `setTimeout` — workerd has no `setImmediate`.\n *\n * Only `Date` is patched, matching the Node implementation's scope (no `Intl`, no\n * `Temporal`, no timer virtualisation).\n */\n\nconst OriginalDate = globalThis.Date;\n\n/** Real wall clock, immune to the patch below. */\nexport const nativeDateNow = (): number => OriginalDate.now();\n\n/**\n * The frozen time a replayed request with no anchor gets: 13 May 2026, World Cocktail Day.\n * A replay whose anchor is missing is already degraded, and live time there is the exact\n * non-determinism this file exists to remove — two replays of that session would render\n * different timestamps. A fixed recent date rather than the epoch, since app code routinely\n * renders or rejects an implausibly old timestamp differently. Matches the Node recorder's\n * unattributed fallback (`replay/virtual-clock.ts`).\n *\n * Exported for the tests: this is the time such a replay renders, so changing it changes\n * that replay's output.\n */\nexport const UNANCHORED_BASE_TIME_MS = OriginalDate.UTC(2026, 4, 13);\n\n// ---------------------------------------------------------------------------\n// Busy-wait loop breaker\n// ---------------------------------------------------------------------------\n\n// A frozen clock makes `while (Date.now() < deadline) {}` spin forever. Count reads that\n// return the same frozen value and, past the threshold, nudge the result forward 1ms per\n// extra read until the loop's deadline is met. The nudge is a pure function of the repeat\n// count, so a base and head replay break an identical loop identically.\nconst BREAK_AFTER = 20_000;\n\nlet lastLoopBreakBaseMs = 0;\nlet sameKeyCount = 0;\nlet loopBreakResetScheduled = false;\n\nconst applyLoopBreak = (baseMs: number): number => {\n // Reset at the end of the current macrotask. A synchronous busy-wait blocks the loop so\n // this cannot fire, letting the counter accumulate until it breaks the spin; once the\n // turn yields, the reset runs and the next turn starts fresh.\n if (!loopBreakResetScheduled) {\n loopBreakResetScheduled = true;\n try {\n setTimeout(() => {\n sameKeyCount = 0;\n loopBreakResetScheduled = false;\n }, 0);\n } catch {\n // Timers throw outside a request context in workerd. Nothing to reset in that case,\n // since clock reads there fall through to the real clock anyway.\n loopBreakResetScheduled = false;\n }\n }\n\n if (baseMs === lastLoopBreakBaseMs) {\n sameKeyCount++;\n } else {\n lastLoopBreakBaseMs = baseMs;\n sameKeyCount = 0;\n }\n return sameKeyCount <= BREAK_AFTER\n ? baseMs\n : baseMs + (sameKeyCount - BREAK_AFTER);\n};\n\n// ---------------------------------------------------------------------------\n// Resolver\n// ---------------------------------------------------------------------------\n\n/**\n * The virtual \"now\" for the request currently executing, or null when this is not a replayed\n * request — a recording, a deployed production worker, or a read outside any request (module\n * evaluation, background work) — in which case callers use the real clock. A replayed request\n * always gets a frozen time: its own anchor, or {@link UNANCHORED_BASE_TIME_MS} if it carries\n * none.\n */\nconst resolveVirtualNow = (): number | null => {\n const ctx = requestCaptureContext.getStore();\n if (ctx === undefined || ctx.mode !== \"replay\") {\n return null;\n }\n return applyLoopBreak(ctx.clockAnchorMs ?? UNANCHORED_BASE_TIME_MS);\n};\n\n// ---------------------------------------------------------------------------\n// Date shim\n// ---------------------------------------------------------------------------\n\n/**\n * What `Date.now()` reports. A standalone function rather than only a class static, so the\n * callable shim below can reuse it without referencing an unbound method — it touches no\n * `this`, so there is nothing to lose.\n */\nconst virtualDateNow = (): number => {\n const virtualNow = resolveVirtualNow();\n return virtualNow === null ? OriginalDate.now() : virtualNow;\n};\n\n// `Date` is heavily overloaded; capture args loosely and forward every explicit form\n// unchanged. Only the zero-arg form reads \"now\" and is virtualized.\nclass VirtualDateClass extends OriginalDate {\n constructor(...args: unknown[]) {\n if (args.length > 0) {\n super(...(args as ConstructorParameters<typeof OriginalDate>));\n return;\n }\n const virtualNow = resolveVirtualNow();\n if (virtualNow === null) {\n super();\n return;\n }\n super(virtualNow);\n }\n\n static override now(): number {\n return virtualDateNow();\n }\n}\n\n// Classes cannot be called without `new`, but native `Date()` can be (returning a string),\n// so wrap the class in a plain function supporting both.\nfunction VirtualDate(this: unknown, ...args: unknown[]): Date | string {\n if (!(this instanceof VirtualDate)) {\n return new VirtualDateClass().toString();\n }\n return new VirtualDateClass(...args);\n}\n\nVirtualDate.now = virtualDateNow;\nVirtualDate.parse = OriginalDate.parse;\nVirtualDate.UTC = OriginalDate.UTC;\n\n// Share the prototype so `new Date() instanceof Date` holds and every Date.prototype\n// method resolves on instances.\nObject.defineProperty(VirtualDate, \"prototype\", {\n value: VirtualDateClass.prototype,\n writable: false,\n configurable: false,\n});\n(VirtualDateClass.prototype as { constructor: unknown }).constructor =\n VirtualDate;\n\n// ---------------------------------------------------------------------------\n// Install\n// ---------------------------------------------------------------------------\n\nlet installed = false;\n\n/**\n * Replaces the global `Date` with the per-request virtual clock. Idempotent, and a no-op\n * failure: a worker that will not let its `Date` be replaced still replays, just without\n * clock virtualisation.\n *\n * Called lazily from the replay branch of the request wrapper rather than at module scope:\n * record mode and deployed production workers then pay nothing, and the package declares no\n * `sideEffects`, so a module-scope side effect could legitimately be tree-shaken away by a\n * customer's bundler.\n */\nexport const installVirtualClock = (): void => {\n if (installed) {\n return;\n }\n installed = true;\n try {\n globalThis.Date = VirtualDate as unknown as DateConstructor;\n } catch (error) {\n warnOnce(\n \"virtual-clock\",\n \"Could not install the Meticulous replay clock — recorded credentials may be treated as expired.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAM,eAAe,WAAW;;;;;;;;;;;;AAgBhC,MAAa,0BAA0B,aAAa,IAAI,MAAM,GAAG,GAAG;AAUpE,MAAM,cAAc;AAEpB,IAAI,sBAAsB;AAC1B,IAAI,eAAe;AACnB,IAAI,0BAA0B;AAE9B,MAAM,kBAAkB,WAA2B;AAIjD,KAAI,CAAC,yBAAyB;AAC5B,4BAA0B;AAC1B,MAAI;AACF,oBAAiB;AACf,mBAAe;AACf,8BAA0B;MACzB,EAAE;UACC;AAGN,6BAA0B;;;AAI9B,KAAI,WAAW,oBACb;MACK;AACL,wBAAsB;AACtB,iBAAe;;AAEjB,QAAO,gBAAgB,cACnB,SACA,UAAU,eAAe;;;;;;;;;AAc/B,MAAM,0BAAyC;CAC7C,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,QAAQ,KAAA,KAAa,IAAI,SAAS,SACpC,QAAO;AAET,QAAO,eAAe,IAAI,iBAAiB,wBAAwB;;;;;;;AAYrE,MAAM,uBAA+B;CACnC,MAAM,aAAa,mBAAmB;AACtC,QAAO,eAAe,OAAO,aAAa,KAAK,GAAG;;AAKpD,IAAM,mBAAN,cAA+B,aAAa;CAC1C,YAAY,GAAG,MAAiB;AAC9B,MAAI,KAAK,SAAS,GAAG;AACnB,SAAM,GAAI,KAAoD;AAC9D;;EAEF,MAAM,aAAa,mBAAmB;AACtC,MAAI,eAAe,MAAM;AACvB,UAAO;AACP;;AAEF,QAAM,WAAW;;CAGnB,OAAgB,MAAc;AAC5B,SAAO,gBAAgB;;;AAM3B,SAAS,YAA2B,GAAG,MAAgC;AACrE,KAAI,EAAE,gBAAgB,aACpB,QAAO,IAAI,kBAAkB,CAAC,UAAU;AAE1C,QAAO,IAAI,iBAAiB,GAAG,KAAK;;AAGtC,YAAY,MAAM;AAClB,YAAY,QAAQ,aAAa;AACjC,YAAY,MAAM,aAAa;AAI/B,OAAO,eAAe,aAAa,aAAa;CAC9C,OAAO,iBAAiB;CACxB,UAAU;CACV,cAAc;CACf,CAAC;AACF,iBAAkB,UAAuC,cACvD;AAMF,IAAI,YAAY;;;;;;;;;;;AAYhB,MAAa,4BAAkC;AAC7C,KAAI,UACF;AAEF,aAAY;AACZ,KAAI;AACF,aAAW,OAAO;UACX,OAAO;AACd,WACE,iBACA,mGACA,MACD","debugId":"
|
|
1
|
+
{"version":3,"file":"virtual-clock.js","sources":["../src/virtual-clock.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\n\n/**\n * Freezes the worker's wall clock at the replayed session's last recorded activity.\n *\n * Without this, replay fails even when every outbound call is mocked correctly: credentials\n * minted during the recording (Firebase session cookies, OAuth access tokens) carry\n * short expiries, so by replay time the app rejects its own recorded credentials as stale.\n * Anchoring at the *end* of the recording means everything minted mid-session is already\n * issued but not yet expired.\n *\n * Ported from the Node backend recorder's replay clock, with two differences:\n * - the anchor comes from the request context rather than a session→anchor map, because\n * workerd's AsyncLocalStorage propagates reliably from the inbound handler. The Node\n * version needs a process-wide \"most recently seen session\" fallback, which can\n * resolve the wrong session on a replica serving several at once;\n * - the busy-wait breaker schedules on `setTimeout` — workerd has no `setImmediate`.\n *\n * Only `Date` is patched, matching the Node implementation's scope (no `Intl`, no\n * `Temporal`, no timer virtualisation).\n */\n\nconst OriginalDate = globalThis.Date;\n\n/** Real wall clock, immune to the patch below. */\nexport const nativeDateNow = (): number => OriginalDate.now();\n\n/**\n * The frozen time a replayed request with no anchor gets: 13 May 2026, World Cocktail Day.\n * A replay whose anchor is missing is already degraded, and live time there is the exact\n * non-determinism this file exists to remove — two replays of that session would render\n * different timestamps. A fixed recent date rather than the epoch, since app code routinely\n * renders or rejects an implausibly old timestamp differently. Matches the Node recorder's\n * unattributed fallback (`replay/virtual-clock.ts`).\n *\n * Exported for the tests: this is the time such a replay renders, so changing it changes\n * that replay's output.\n */\nexport const UNANCHORED_BASE_TIME_MS = OriginalDate.UTC(2026, 4, 13);\n\n// ---------------------------------------------------------------------------\n// Busy-wait loop breaker\n// ---------------------------------------------------------------------------\n\n// A frozen clock makes `while (Date.now() < deadline) {}` spin forever. Count reads that\n// return the same frozen value and, past the threshold, nudge the result forward 1ms per\n// extra read until the loop's deadline is met. The nudge is a pure function of the repeat\n// count, so a base and head replay break an identical loop identically.\nconst BREAK_AFTER = 20_000;\n\nlet lastLoopBreakBaseMs = 0;\nlet sameKeyCount = 0;\nlet loopBreakResetScheduled = false;\n\nconst applyLoopBreak = (baseMs: number): number => {\n // Reset at the end of the current macrotask. A synchronous busy-wait blocks the loop so\n // this cannot fire, letting the counter accumulate until it breaks the spin; once the\n // turn yields, the reset runs and the next turn starts fresh.\n if (!loopBreakResetScheduled) {\n loopBreakResetScheduled = true;\n try {\n setTimeout(() => {\n sameKeyCount = 0;\n loopBreakResetScheduled = false;\n }, 0);\n } catch {\n // Timers throw outside a request context in workerd. Nothing to reset in that case,\n // since clock reads there fall through to the real clock anyway.\n loopBreakResetScheduled = false;\n }\n }\n\n if (baseMs === lastLoopBreakBaseMs) {\n sameKeyCount++;\n } else {\n lastLoopBreakBaseMs = baseMs;\n sameKeyCount = 0;\n }\n return sameKeyCount <= BREAK_AFTER\n ? baseMs\n : baseMs + (sameKeyCount - BREAK_AFTER);\n};\n\n// ---------------------------------------------------------------------------\n// Resolver\n// ---------------------------------------------------------------------------\n\n/**\n * The virtual \"now\" for the request currently executing, or null when this is not a replayed\n * request — a recording, a deployed production worker, or a read outside any request (module\n * evaluation, background work) — in which case callers use the real clock. A replayed request\n * always gets a frozen time: its own anchor, or {@link UNANCHORED_BASE_TIME_MS} if it carries\n * none.\n */\nconst resolveVirtualNow = (): number | null => {\n const ctx = requestCaptureContext.getStore();\n if (ctx === undefined || ctx.mode !== \"replay\") {\n return null;\n }\n return applyLoopBreak(ctx.clockAnchorMs ?? UNANCHORED_BASE_TIME_MS);\n};\n\n// ---------------------------------------------------------------------------\n// Date shim\n// ---------------------------------------------------------------------------\n\n/**\n * What `Date.now()` reports. A standalone function rather than only a class static, so the\n * callable shim below can reuse it without referencing an unbound method — it touches no\n * `this`, so there is nothing to lose.\n */\nconst virtualDateNow = (): number => {\n const virtualNow = resolveVirtualNow();\n return virtualNow === null ? OriginalDate.now() : virtualNow;\n};\n\n// `Date` is heavily overloaded; capture args loosely and forward every explicit form\n// unchanged. Only the zero-arg form reads \"now\" and is virtualized.\nclass VirtualDateClass extends OriginalDate {\n constructor(...args: unknown[]) {\n if (args.length > 0) {\n super(...(args as ConstructorParameters<typeof OriginalDate>));\n return;\n }\n const virtualNow = resolveVirtualNow();\n if (virtualNow === null) {\n super();\n return;\n }\n super(virtualNow);\n }\n\n static override now(): number {\n return virtualDateNow();\n }\n}\n\n// Classes cannot be called without `new`, but native `Date()` can be (returning a string),\n// so wrap the class in a plain function supporting both.\nfunction VirtualDate(this: unknown, ...args: unknown[]): Date | string {\n if (!(this instanceof VirtualDate)) {\n return new VirtualDateClass().toString();\n }\n return new VirtualDateClass(...args);\n}\n\nVirtualDate.now = virtualDateNow;\nVirtualDate.parse = OriginalDate.parse;\nVirtualDate.UTC = OriginalDate.UTC;\n\n// Share the prototype so `new Date() instanceof Date` holds and every Date.prototype\n// method resolves on instances.\nObject.defineProperty(VirtualDate, \"prototype\", {\n value: VirtualDateClass.prototype,\n writable: false,\n configurable: false,\n});\n(VirtualDateClass.prototype as { constructor: unknown }).constructor =\n VirtualDate;\n\n// ---------------------------------------------------------------------------\n// Install\n// ---------------------------------------------------------------------------\n\nlet installed = false;\n\n/**\n * Replaces the global `Date` with the per-request virtual clock. Idempotent, and a no-op\n * failure: a worker that will not let its `Date` be replaced still replays, just without\n * clock virtualisation.\n *\n * Called lazily from the replay branch of the request wrapper rather than at module scope:\n * record mode and deployed production workers then pay nothing, and the package declares no\n * `sideEffects`, so a module-scope side effect could legitimately be tree-shaken away by a\n * customer's bundler.\n */\nexport const installVirtualClock = (): void => {\n if (installed) {\n return;\n }\n installed = true;\n try {\n globalThis.Date = VirtualDate as unknown as DateConstructor;\n } catch (error) {\n warnOnce(\n \"virtual-clock\",\n \"Could not install the Meticulous replay clock — recorded credentials may be treated as expired.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAM,eAAe,WAAW;;;;;;;;;;;;AAgBhC,MAAa,0BAA0B,aAAa,IAAI,MAAM,GAAG,GAAG;AAUpE,MAAM,cAAc;AAEpB,IAAI,sBAAsB;AAC1B,IAAI,eAAe;AACnB,IAAI,0BAA0B;AAE9B,MAAM,kBAAkB,WAA2B;AAIjD,KAAI,CAAC,yBAAyB;AAC5B,4BAA0B;AAC1B,MAAI;AACF,oBAAiB;AACf,mBAAe;AACf,8BAA0B;MACzB,EAAE;UACC;AAGN,6BAA0B;;;AAI9B,KAAI,WAAW,oBACb;MACK;AACL,wBAAsB;AACtB,iBAAe;;AAEjB,QAAO,gBAAgB,cACnB,SACA,UAAU,eAAe;;;;;;;;;AAc/B,MAAM,0BAAyC;CAC7C,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,QAAQ,KAAA,KAAa,IAAI,SAAS,SACpC,QAAO;AAET,QAAO,eAAe,IAAI,iBAAiB,wBAAwB;;;;;;;AAYrE,MAAM,uBAA+B;CACnC,MAAM,aAAa,mBAAmB;AACtC,QAAO,eAAe,OAAO,aAAa,KAAK,GAAG;;AAKpD,IAAM,mBAAN,cAA+B,aAAa;CAC1C,YAAY,GAAG,MAAiB;AAC9B,MAAI,KAAK,SAAS,GAAG;AACnB,SAAM,GAAI,KAAoD;AAC9D;;EAEF,MAAM,aAAa,mBAAmB;AACtC,MAAI,eAAe,MAAM;AACvB,UAAO;AACP;;AAEF,QAAM,WAAW;;CAGnB,OAAgB,MAAc;AAC5B,SAAO,gBAAgB;;;AAM3B,SAAS,YAA2B,GAAG,MAAgC;AACrE,KAAI,EAAE,gBAAgB,aACpB,QAAO,IAAI,kBAAkB,CAAC,UAAU;AAE1C,QAAO,IAAI,iBAAiB,GAAG,KAAK;;AAGtC,YAAY,MAAM;AAClB,YAAY,QAAQ,aAAa;AACjC,YAAY,MAAM,aAAa;AAI/B,OAAO,eAAe,aAAa,aAAa;CAC9C,OAAO,iBAAiB;CACxB,UAAU;CACV,cAAc;CACf,CAAC;AACF,iBAAkB,UAAuC,cACvD;AAMF,IAAI,YAAY;;;;;;;;;;;AAYhB,MAAa,4BAAkC;AAC7C,KAAI,UACF;AAEF,aAAY;AACZ,KAAI;AACF,aAAW,OAAO;UACX,OAAO;AACd,WACE,iBACA,mGACA,MACD","debugId":"39c427c3-1a1e-58aa-9021-0bd113903895"}
|
package/dist/virtual-random.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="0b356a44-c057-5a04-b105-f150b4aab2af")}catch(e){}}();
|
|
3
3
|
import { requestCaptureContext } from "./context.js";
|
|
4
4
|
import { warnOnce } from "./log.js";
|
|
5
5
|
//#region src/virtual-random.ts
|
|
6
6
|
/**
|
|
7
|
-
* Per-
|
|
7
|
+
* Per-request randomness virtualization for replay mode.
|
|
8
8
|
*
|
|
9
9
|
* Mocking every outbound `fetch` is not enough to make a replay deterministic: a Worker that
|
|
10
10
|
* mints an id (`crypto.randomUUID()` for a guest id, `getRandomValues` for a token) produces
|
|
@@ -21,8 +21,16 @@ import { warnOnce } from "./log.js";
|
|
|
21
21
|
* the browser replayer's `mockOutRandomNumberGeneration`, including its butterfly-effect
|
|
22
22
|
* containment: a separate sequence per call stack, seeded from a per-call-site counter, so a
|
|
23
23
|
* change in one part of the app does not shift the numbers another part gets, and a shared
|
|
24
|
-
* helper called from two places does not return the same value twice.
|
|
25
|
-
* the
|
|
24
|
+
* helper called from two places does not return the same value twice. Sequences are scoped
|
|
25
|
+
* to the inbound request, not the whole replay: two concurrent requests of the same session
|
|
26
|
+
* that race to the same call site would otherwise consume one shared stream in arrival
|
|
27
|
+
* order, and a Fisher-Yates shuffle (or any other draw) would then differ between replays.
|
|
28
|
+
* Each request starts its sequences over from the same session-scoped seed. Each call site
|
|
29
|
+
* on a request returns {@link UNATTRIBUTED_DRAW} for the first {@link FIXED_DRAWS_BEFORE_PRNG}
|
|
30
|
+
* draws and only then starts the PRNG, so a shuffle is identical across concurrent requests
|
|
31
|
+
* and a `while (Math.random() < 0.5)` loop still terminates.
|
|
32
|
+
*
|
|
33
|
+
* Two differences from the Node version, both forced by the runtime:
|
|
26
34
|
*
|
|
27
35
|
* - the session comes from the request context rather than a session resolver with a
|
|
28
36
|
* process-wide fallback, because workerd's AsyncLocalStorage propagates reliably from the
|
|
@@ -47,12 +55,28 @@ const nativeGetRandomValues = globalThis.crypto?.getRandomValues?.bind(globalThi
|
|
|
47
55
|
const FRAMES_TO_APP = 3;
|
|
48
56
|
/** How much of the stack identifies a sequence. Ten frames tells call paths apart cheaply. */
|
|
49
57
|
const STACK_FRAMES = 10;
|
|
50
|
-
/** Stack captures per
|
|
58
|
+
/** Stack captures per request, after which sequences are keyed by call site alone. */
|
|
51
59
|
const MAX_STACK_CAPTURES = 1e4;
|
|
52
|
-
/** Bounds the state a stream of unrecognised session ids can accumulate in one isolate. */
|
|
53
|
-
const MAX_TRACKED_SESSIONS = 50;
|
|
54
60
|
const UNKNOWN_CALL_SITE = "unknown";
|
|
55
|
-
|
|
61
|
+
/**
|
|
62
|
+
* What a draw returns before its call site's PRNG starts, and what unattributed draws
|
|
63
|
+
* return. Leading digits of pi, so the value is recognisably ours in a diff or a log.
|
|
64
|
+
*/
|
|
65
|
+
const UNATTRIBUTED_DRAW = .3142;
|
|
66
|
+
/**
|
|
67
|
+
* Draws at one call site on one request that return {@link UNATTRIBUTED_DRAW} before the
|
|
68
|
+
* seeded PRNG starts. Large enough that a shuffle stays constant, small enough that
|
|
69
|
+
* `while (Math.random() < 0.5)` still exits.
|
|
70
|
+
*/
|
|
71
|
+
const FIXED_DRAWS_BEFORE_PRNG = 1e3;
|
|
72
|
+
const drawUnattributed = () => UNATTRIBUTED_DRAW;
|
|
73
|
+
/**
|
|
74
|
+
* Held on the inbound request object, so two concurrent requests of one replay cannot share
|
|
75
|
+
* a generator. The seed below stays session-scoped: base and head are different replays of
|
|
76
|
+
* the same recording and must draw identical numbers, and two requests of that recording
|
|
77
|
+
* must draw the same first values rather than racing to consume one stream.
|
|
78
|
+
*/
|
|
79
|
+
const stateByRequest = /* @__PURE__ */ new WeakMap();
|
|
56
80
|
/**
|
|
57
81
|
* Alea, the same algorithm the browser replayer gets from `seedrandom`. Inlined because this
|
|
58
82
|
* package is published to npm and ships no runtime dependencies — a customer's Worker bundle
|
|
@@ -92,10 +116,10 @@ const createSeededRandom = (seed) => {
|
|
|
92
116
|
return s2;
|
|
93
117
|
};
|
|
94
118
|
};
|
|
95
|
-
const getCallStack = () => {
|
|
119
|
+
const getCallStack = (frameCount = STACK_FRAMES) => {
|
|
96
120
|
const originalLimit = Error.stackTraceLimit;
|
|
97
121
|
try {
|
|
98
|
-
Error.stackTraceLimit = FRAMES_TO_APP +
|
|
122
|
+
Error.stackTraceLimit = FRAMES_TO_APP + frameCount;
|
|
99
123
|
const stack = (/* @__PURE__ */ new Error()).stack;
|
|
100
124
|
if (stack == null) return {
|
|
101
125
|
stack: UNKNOWN_CALL_SITE,
|
|
@@ -115,22 +139,24 @@ const getCallStack = () => {
|
|
|
115
139
|
Error.stackTraceLimit = originalLimit;
|
|
116
140
|
}
|
|
117
141
|
};
|
|
118
|
-
const
|
|
142
|
+
const stillInFixedDrawPhase = (state, callSite) => {
|
|
143
|
+
const n = (state.drawsByCallSite.get(callSite) ?? 0) + 1;
|
|
144
|
+
state.drawsByCallSite.set(callSite, n);
|
|
145
|
+
return n <= FIXED_DRAWS_BEFORE_PRNG;
|
|
146
|
+
};
|
|
147
|
+
const getRequestState = () => {
|
|
119
148
|
const ctx = requestCaptureContext.getStore();
|
|
120
149
|
if (ctx === void 0 || ctx.mode !== "replay") return;
|
|
121
|
-
const existing =
|
|
150
|
+
const existing = stateByRequest.get(ctx);
|
|
122
151
|
if (existing !== void 0) return existing;
|
|
123
|
-
if (stateBySession.size >= MAX_TRACKED_SESSIONS) {
|
|
124
|
-
const oldest = stateBySession.keys().next();
|
|
125
|
-
if (!oldest.done) stateBySession.delete(oldest.value);
|
|
126
|
-
}
|
|
127
152
|
const created = {
|
|
128
153
|
frontendSessionId: ctx.frontendSessionId,
|
|
129
154
|
generatorBySequenceKey: /* @__PURE__ */ new Map(),
|
|
130
155
|
nextCallerIdByCallSite: /* @__PURE__ */ new Map(),
|
|
156
|
+
drawsByCallSite: /* @__PURE__ */ new Map(),
|
|
131
157
|
stackCaptures: 0
|
|
132
158
|
};
|
|
133
|
-
|
|
159
|
+
stateByRequest.set(ctx, created);
|
|
134
160
|
return created;
|
|
135
161
|
};
|
|
136
162
|
/**
|
|
@@ -153,8 +179,9 @@ const getSessionState = () => {
|
|
|
153
179
|
* avoid.
|
|
154
180
|
*/
|
|
155
181
|
const openSequence = () => {
|
|
156
|
-
const state =
|
|
182
|
+
const state = getRequestState();
|
|
157
183
|
if (state === void 0) return;
|
|
184
|
+
if (stillInFixedDrawPhase(state, getCallStack(1).site)) return drawUnattributed;
|
|
158
185
|
let sequenceKey = UNKNOWN_CALL_SITE;
|
|
159
186
|
let callSite = UNKNOWN_CALL_SITE;
|
|
160
187
|
if (state.stackCaptures < MAX_STACK_CAPTURES) {
|
|
@@ -192,7 +219,7 @@ const fillBytes = (view, draw) => {
|
|
|
192
219
|
};
|
|
193
220
|
let installed = false;
|
|
194
221
|
/**
|
|
195
|
-
* Replaces the worker's sources of randomness with per-
|
|
222
|
+
* Replaces the worker's sources of randomness with per-request seeded generators. Idempotent,
|
|
196
223
|
* and a no-op failure: a worker that will not let them be replaced still replays, just
|
|
197
224
|
* without randomness virtualisation.
|
|
198
225
|
*
|
|
@@ -235,4 +262,4 @@ const installVirtualRandom = () => {
|
|
|
235
262
|
export { installVirtualRandom };
|
|
236
263
|
|
|
237
264
|
//# sourceMappingURL=virtual-random.js.map
|
|
238
|
-
//# debugId=
|
|
265
|
+
//# debugId=0b356a44-c057-5a04-b105-f150b4aab2af
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"virtual-random.js","sources":["../src/virtual-random.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\n\n/**\n * Per-session randomness virtualization for replay mode.\n *\n * Mocking every outbound `fetch` is not enough to make a replay deterministic: a Worker that\n * mints an id (`crypto.randomUUID()` for a guest id, `getRandomValues` for a token) produces\n * a different value in the base replay than in the head replay, so the rendered HTML differs\n * and any screenshot showing that value diffs forever. This replaces the worker's sources of\n * randomness with seeded generators while serving a replayed request, so two replays of the\n * same recorded session render identically.\n *\n * Deliberately a *generator*, not a recording: like the browser replayer we do not capture\n * the numbers the original session produced, we produce fresh ones from a fixed seed. The\n * app still sees plausible random values, and two replays see the same ones.\n *\n * Ported from the Node backend recorder's `replay/virtual-random.ts`, which in turn mirrors\n * the browser replayer's `mockOutRandomNumberGeneration`, including its butterfly-effect\n * containment: a separate sequence per call stack, seeded from a per-call-site counter, so a\n * change in one part of the app does not shift the numbers another part gets, and a shared\n * helper called from two places does not return the same value twice. Two differences from\n * the Node version, both forced by the runtime:\n *\n * - the session comes from the request context rather than a session resolver with a\n * process-wide fallback, because workerd's AsyncLocalStorage propagates reliably from the\n * inbound handler;\n * - only the globals workerd actually has are patched — there is no `node:crypto` module to\n * mutate.\n *\n * Reads outside a replayed request — module evaluation, a recording, a deployed production\n * worker — fall through to the native generators untouched.\n */\n\nconst nativeMathRandom = Math.random.bind(Math);\nconst nativeRandomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);\nconst nativeGetRandomValues = globalThis.crypto?.getRandomValues?.bind(\n globalThis.crypto,\n);\n\n/**\n * Frames of the stack that belong to this module and must be skipped to reach the app's call\n * site, counted from the `new Error()` inside {@link getCallStack}.\n *\n * Getting this wrong degrades gracefully rather than breaking: a call site that resolves to\n * one of our own frames merges sequences that should have been separate, which costs\n * butterfly-effect isolation but is still perfectly deterministic.\n */\nconst FRAMES_TO_APP = 3; // getCallStack ← openSequence ← patched global ← app\n\n/** How much of the stack identifies a sequence. Ten frames tells call paths apart cheaply. */\nconst STACK_FRAMES = 10;\n\n/** Stack captures per session, after which sequences are keyed by call site alone. */\nconst MAX_STACK_CAPTURES = 10_000;\n\n/** Bounds the state a stream of unrecognised session ids can accumulate in one isolate. */\nconst MAX_TRACKED_SESSIONS = 50;\n\nconst UNKNOWN_CALL_SITE = \"unknown\";\n\ninterface SessionRandomState {\n frontendSessionId: string;\n generatorBySequenceKey: Map<string, () => number>;\n nextCallerIdByCallSite: Map<string, number>;\n stackCaptures: number;\n}\n\nconst stateBySession = new Map<string, SessionRandomState>();\n\n// ---------------------------------------------------------------------------\n// Seeded PRNG (Alea)\n// ---------------------------------------------------------------------------\n\n/**\n * Alea, the same algorithm the browser replayer gets from `seedrandom`. Inlined because this\n * package is published to npm and ships no runtime dependencies — a customer's Worker bundle\n * should not grow for this.\n */\nexport const createSeededRandom = (seed: string): (() => number) => {\n let n = 0xefc8249d;\n const mash = (data: string): number => {\n for (let i = 0; i < data.length; i++) {\n n += data.charCodeAt(i);\n let h = 0.02519603282416938 * n;\n n = h >>> 0;\n h -= n;\n h *= n;\n n = h >>> 0;\n h -= n;\n n += h * 0x100000000;\n }\n return (n >>> 0) * 2.3283064365386963e-10;\n };\n\n let s0 = mash(\" \");\n let s1 = mash(\" \");\n let s2 = mash(\" \");\n let c = 1;\n s0 -= mash(seed);\n if (s0 < 0) {\n s0 += 1;\n }\n s1 -= mash(seed);\n if (s1 < 0) {\n s1 += 1;\n }\n s2 -= mash(seed);\n if (s2 < 0) {\n s2 += 1;\n }\n\n return () => {\n const t = 2091639 * s0 + c * 2.3283064365386963e-10;\n s0 = s1;\n s1 = s2;\n c = t | 0;\n s2 = t - c;\n return s2;\n };\n};\n\n// ---------------------------------------------------------------------------\n// Sequence resolution\n// ---------------------------------------------------------------------------\n\nconst getCallStack = (): { stack: string; site: string } => {\n const originalLimit = Error.stackTraceLimit;\n try {\n Error.stackTraceLimit = FRAMES_TO_APP + STACK_FRAMES;\n const stack = new Error().stack;\n if (stack == null) {\n return { stack: UNKNOWN_CALL_SITE, site: UNKNOWN_CALL_SITE };\n }\n // Line 0 is the \"Error\" header; frame lines follow, innermost first, and the first\n // FRAMES_TO_APP of them are ours.\n const lines = stack.split(\"\\n\").slice(FRAMES_TO_APP + 1);\n return {\n stack: lines.join(\"\\n\") || UNKNOWN_CALL_SITE,\n site: lines[0] ?? UNKNOWN_CALL_SITE,\n };\n } catch {\n return { stack: UNKNOWN_CALL_SITE, site: UNKNOWN_CALL_SITE };\n } finally {\n Error.stackTraceLimit = originalLimit;\n }\n};\n\nconst getSessionState = (): SessionRandomState | undefined => {\n const ctx = requestCaptureContext.getStore();\n if (ctx === undefined || ctx.mode !== \"replay\") {\n return undefined;\n }\n const existing = stateBySession.get(ctx.frontendSessionId);\n if (existing !== undefined) {\n return existing;\n }\n if (stateBySession.size >= MAX_TRACKED_SESSIONS) {\n const oldest = stateBySession.keys().next();\n if (!oldest.done) {\n stateBySession.delete(oldest.value);\n }\n }\n const created: SessionRandomState = {\n frontendSessionId: ctx.frontendSessionId,\n generatorBySequenceKey: new Map(),\n nextCallerIdByCallSite: new Map(),\n stackCaptures: 0,\n };\n stateBySession.set(ctx.frontendSessionId, created);\n return created;\n};\n\n/**\n * The generator for the sequence the current call belongs to, or undefined outside a\n * replayed request.\n *\n * Sequences are keyed by the whole call stack, so a second call down the same path continues\n * the same sequence and gets a different value. The *seed* comes from a counter kept per\n * immediate call site, which is what keeps a shared id helper honest: two call paths through\n * the same `generateId()` share a call site, so they are seeded 0 and 1 and return different\n * ids.\n *\n * Note what the seed deliberately does NOT contain: any part of the stack. Base and head are\n * different builds of the app, so the same logical call site has a different string in each —\n * a seed built from it would change on every rebuild. The consequence, which the browser\n * replayer shares, is that two *distinct* call sites each seen for the first time both seed at\n * 0 and hand out the same first value. That is the accepted trade: an alternative that\n * separated them (say, an ordinal per call site) would shift every later site's values as soon\n * as a PR added one randomness call, which is the butterfly effect this whole scheme exists to\n * avoid.\n */\nconst openSequence = (): (() => number) | undefined => {\n const state = getSessionState();\n if (state === undefined) {\n return undefined;\n }\n\n let sequenceKey = UNKNOWN_CALL_SITE;\n let callSite = UNKNOWN_CALL_SITE;\n if (state.stackCaptures < MAX_STACK_CAPTURES) {\n state.stackCaptures++;\n const captured = getCallStack();\n sequenceKey = captured.stack;\n callSite = captured.site;\n }\n\n const existing = state.generatorBySequenceKey.get(sequenceKey);\n if (existing !== undefined) {\n return existing;\n }\n const callerId = state.nextCallerIdByCallSite.get(callSite) ?? 0;\n // Seeded from the counter only — never from the call-site or stack string itself. Those embed\n // bundle filenames and line numbers, which differ between the base build and the head build\n // of the very same code, so putting one in the seed would reseed every sequence on any\n // rebuild and bring back exactly the diffs this exists to remove. The strings are grouping\n // keys, nothing more.\n const generator = createSeededRandom(\n `meticulous-random-${state.frontendSessionId}-${callerId}`,\n );\n state.generatorBySequenceKey.set(sequenceKey, generator);\n state.nextCallerIdByCallSite.set(callSite, callerId + 1);\n return generator;\n};\n\n// ---------------------------------------------------------------------------\n// Derived primitives\n// ---------------------------------------------------------------------------\n\nconst HEX = \"0123456789abcdef\";\n/** The two bits of a v4 UUID's variant field are fixed, leaving these four values. */\nconst VARIANT = \"89ab\";\n\nconst buildUuid = (draw: () => number): string => {\n let uuid = \"\";\n for (let i = 0; i < 36; i++) {\n if (i === 8 || i === 13 || i === 18 || i === 23) {\n uuid += \"-\";\n } else if (i === 14) {\n uuid += \"4\";\n } else if (i === 19) {\n uuid += VARIANT[Math.floor(draw() * VARIANT.length)];\n } else {\n uuid += HEX[Math.floor(draw() * HEX.length)];\n }\n }\n return uuid;\n};\n\n/**\n * Fills the view's bytes rather than its elements, so every typed array — including the\n * BigInt ones, whose elements cannot be assigned a number — is handled by one path.\n */\nconst fillBytes = (view: ArrayBufferView, draw: () => number): void => {\n const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);\n for (let i = 0; i < bytes.length; i++) {\n bytes[i] = Math.floor(draw() * 256);\n }\n};\n\n// ---------------------------------------------------------------------------\n// Install\n// ---------------------------------------------------------------------------\n\nlet installed = false;\n\n/**\n * Replaces the worker's sources of randomness with per-session seeded generators. Idempotent,\n * and a no-op failure: a worker that will not let them be replaced still replays, just\n * without randomness virtualisation.\n *\n * Called lazily from the replay branch of the request wrapper rather than at module scope,\n * for the same reasons as the virtual clock: record mode and deployed production workers then\n * pay nothing, and the package declares no `sideEffects`, so a module-scope side effect could\n * legitimately be tree-shaken away by a customer's bundler.\n */\nexport const installVirtualRandom = (): void => {\n if (installed) {\n return;\n }\n installed = true;\n try {\n Math.random = () => openSequence()?.() ?? nativeMathRandom();\n\n if (globalThis.crypto != null) {\n Object.defineProperty(globalThis.crypto, \"randomUUID\", {\n value: (): string => {\n const draw = openSequence();\n if (draw !== undefined) {\n return buildUuid(draw);\n }\n return nativeRandomUUID?.() ?? buildUuid(nativeMathRandom);\n },\n configurable: true,\n writable: true,\n });\n\n Object.defineProperty(globalThis.crypto, \"getRandomValues\", {\n value: <T extends ArrayBufferView>(array: T): T => {\n const draw = openSequence();\n if (draw === undefined) {\n return nativeGetRandomValues?.(array) ?? array;\n }\n fillBytes(array, draw);\n return array;\n },\n configurable: true,\n writable: true,\n });\n }\n } catch (error) {\n warnOnce(\n \"virtual-random\",\n \"Could not install the Meticulous replay random-number generators — ids minted by the app will differ between replays.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":"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","debugId":"3821aa29-b3ba-551f-957d-45a05bdc02bf"}
|
|
1
|
+
{"version":3,"file":"virtual-random.js","sources":["../src/virtual-random.ts"],"sourcesContent":["import { requestCaptureContext, type RequestReplayContext } from \"./context\";\nimport { warnOnce } from \"./log\";\n\n/**\n * Per-request randomness virtualization for replay mode.\n *\n * Mocking every outbound `fetch` is not enough to make a replay deterministic: a Worker that\n * mints an id (`crypto.randomUUID()` for a guest id, `getRandomValues` for a token) produces\n * a different value in the base replay than in the head replay, so the rendered HTML differs\n * and any screenshot showing that value diffs forever. This replaces the worker's sources of\n * randomness with seeded generators while serving a replayed request, so two replays of the\n * same recorded session render identically.\n *\n * Deliberately a *generator*, not a recording: like the browser replayer we do not capture\n * the numbers the original session produced, we produce fresh ones from a fixed seed. The\n * app still sees plausible random values, and two replays see the same ones.\n *\n * Ported from the Node backend recorder's `replay/virtual-random.ts`, which in turn mirrors\n * the browser replayer's `mockOutRandomNumberGeneration`, including its butterfly-effect\n * containment: a separate sequence per call stack, seeded from a per-call-site counter, so a\n * change in one part of the app does not shift the numbers another part gets, and a shared\n * helper called from two places does not return the same value twice. Sequences are scoped\n * to the inbound request, not the whole replay: two concurrent requests of the same session\n * that race to the same call site would otherwise consume one shared stream in arrival\n * order, and a Fisher-Yates shuffle (or any other draw) would then differ between replays.\n * Each request starts its sequences over from the same session-scoped seed. Each call site\n * on a request returns {@link UNATTRIBUTED_DRAW} for the first {@link FIXED_DRAWS_BEFORE_PRNG}\n * draws and only then starts the PRNG, so a shuffle is identical across concurrent requests\n * and a `while (Math.random() < 0.5)` loop still terminates.\n *\n * Two differences from the Node version, both forced by the runtime:\n *\n * - the session comes from the request context rather than a session resolver with a\n * process-wide fallback, because workerd's AsyncLocalStorage propagates reliably from the\n * inbound handler;\n * - only the globals workerd actually has are patched — there is no `node:crypto` module to\n * mutate.\n *\n * Reads outside a replayed request — module evaluation, a recording, a deployed production\n * worker — fall through to the native generators untouched.\n */\n\nconst nativeMathRandom = Math.random.bind(Math);\nconst nativeRandomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);\nconst nativeGetRandomValues = globalThis.crypto?.getRandomValues?.bind(\n globalThis.crypto,\n);\n\n/**\n * Frames of the stack that belong to this module and must be skipped to reach the app's call\n * site, counted from the `new Error()` inside {@link getCallStack}.\n *\n * Getting this wrong degrades gracefully rather than breaking: a call site that resolves to\n * one of our own frames merges sequences that should have been separate, which costs\n * butterfly-effect isolation but is still perfectly deterministic.\n */\nconst FRAMES_TO_APP = 3; // getCallStack ← openSequence ← patched global ← app\n\n/** How much of the stack identifies a sequence. Ten frames tells call paths apart cheaply. */\nconst STACK_FRAMES = 10;\n\n/** Stack captures per request, after which sequences are keyed by call site alone. */\nconst MAX_STACK_CAPTURES = 10_000;\n\nconst UNKNOWN_CALL_SITE = \"unknown\";\n\n/**\n * What a draw returns before its call site's PRNG starts, and what unattributed draws\n * return. Leading digits of pi, so the value is recognisably ours in a diff or a log.\n */\nexport const UNATTRIBUTED_DRAW = 0.3142;\n\n/**\n * Draws at one call site on one request that return {@link UNATTRIBUTED_DRAW} before the\n * seeded PRNG starts. Large enough that a shuffle stays constant, small enough that\n * `while (Math.random() < 0.5)` still exits.\n */\nexport const FIXED_DRAWS_BEFORE_PRNG = 1_000;\n\nconst drawUnattributed = (): number => UNATTRIBUTED_DRAW;\n\ninterface RequestRandomState {\n frontendSessionId: string;\n generatorBySequenceKey: Map<string, () => number>;\n nextCallerIdByCallSite: Map<string, number>;\n drawsByCallSite: Map<string, number>;\n stackCaptures: number;\n}\n\n/**\n * Held on the inbound request object, so two concurrent requests of one replay cannot share\n * a generator. The seed below stays session-scoped: base and head are different replays of\n * the same recording and must draw identical numbers, and two requests of that recording\n * must draw the same first values rather than racing to consume one stream.\n */\nconst stateByRequest = new WeakMap<RequestReplayContext, RequestRandomState>();\n\n// ---------------------------------------------------------------------------\n// Seeded PRNG (Alea)\n// ---------------------------------------------------------------------------\n\n/**\n * Alea, the same algorithm the browser replayer gets from `seedrandom`. Inlined because this\n * package is published to npm and ships no runtime dependencies — a customer's Worker bundle\n * should not grow for this.\n */\nexport const createSeededRandom = (seed: string): (() => number) => {\n let n = 0xefc8249d;\n const mash = (data: string): number => {\n for (let i = 0; i < data.length; i++) {\n n += data.charCodeAt(i);\n let h = 0.02519603282416938 * n;\n n = h >>> 0;\n h -= n;\n h *= n;\n n = h >>> 0;\n h -= n;\n n += h * 0x100000000;\n }\n return (n >>> 0) * 2.3283064365386963e-10;\n };\n\n let s0 = mash(\" \");\n let s1 = mash(\" \");\n let s2 = mash(\" \");\n let c = 1;\n s0 -= mash(seed);\n if (s0 < 0) {\n s0 += 1;\n }\n s1 -= mash(seed);\n if (s1 < 0) {\n s1 += 1;\n }\n s2 -= mash(seed);\n if (s2 < 0) {\n s2 += 1;\n }\n\n return () => {\n const t = 2091639 * s0 + c * 2.3283064365386963e-10;\n s0 = s1;\n s1 = s2;\n c = t | 0;\n s2 = t - c;\n return s2;\n };\n};\n\n// ---------------------------------------------------------------------------\n// Sequence resolution\n// ---------------------------------------------------------------------------\n\nconst getCallStack = (\n frameCount: number = STACK_FRAMES,\n): { stack: string; site: string } => {\n const originalLimit = Error.stackTraceLimit;\n try {\n Error.stackTraceLimit = FRAMES_TO_APP + frameCount;\n const stack = new Error().stack;\n if (stack == null) {\n return { stack: UNKNOWN_CALL_SITE, site: UNKNOWN_CALL_SITE };\n }\n // Line 0 is the \"Error\" header; frame lines follow, innermost first, and the first\n // FRAMES_TO_APP of them are ours.\n const lines = stack.split(\"\\n\").slice(FRAMES_TO_APP + 1);\n return {\n stack: lines.join(\"\\n\") || UNKNOWN_CALL_SITE,\n site: lines[0] ?? UNKNOWN_CALL_SITE,\n };\n } catch {\n return { stack: UNKNOWN_CALL_SITE, site: UNKNOWN_CALL_SITE };\n } finally {\n Error.stackTraceLimit = originalLimit;\n }\n};\n\nconst stillInFixedDrawPhase = (\n state: RequestRandomState,\n callSite: string,\n): boolean => {\n const n = (state.drawsByCallSite.get(callSite) ?? 0) + 1;\n state.drawsByCallSite.set(callSite, n);\n return n <= FIXED_DRAWS_BEFORE_PRNG;\n};\n\nconst getRequestState = (): RequestRandomState | undefined => {\n const ctx = requestCaptureContext.getStore();\n if (ctx === undefined || ctx.mode !== \"replay\") {\n return undefined;\n }\n const existing = stateByRequest.get(ctx);\n if (existing !== undefined) {\n return existing;\n }\n const created: RequestRandomState = {\n frontendSessionId: ctx.frontendSessionId,\n generatorBySequenceKey: new Map(),\n nextCallerIdByCallSite: new Map(),\n drawsByCallSite: new Map(),\n stackCaptures: 0,\n };\n stateByRequest.set(ctx, created);\n return created;\n};\n\n/**\n * The generator for the sequence the current call belongs to, or undefined outside a\n * replayed request.\n *\n * Sequences are keyed by the whole call stack, so a second call down the same path continues\n * the same sequence and gets a different value. The *seed* comes from a counter kept per\n * immediate call site, which is what keeps a shared id helper honest: two call paths through\n * the same `generateId()` share a call site, so they are seeded 0 and 1 and return different\n * ids.\n *\n * Note what the seed deliberately does NOT contain: any part of the stack. Base and head are\n * different builds of the app, so the same logical call site has a different string in each —\n * a seed built from it would change on every rebuild. The consequence, which the browser\n * replayer shares, is that two *distinct* call sites each seen for the first time both seed at\n * 0 and hand out the same first value. That is the accepted trade: an alternative that\n * separated them (say, an ordinal per call site) would shift every later site's values as soon\n * as a PR added one randomness call, which is the butterfly effect this whole scheme exists to\n * avoid.\n */\nconst openSequence = (): (() => number) | undefined => {\n const state = getRequestState();\n if (state === undefined) {\n return undefined;\n }\n\n // Called directly so FRAMES_TO_APP still lands on the app. A wrapper here would make\n // every Math.random share one call site and one fixed-draw budget.\n if (stillInFixedDrawPhase(state, getCallStack(1).site)) {\n return drawUnattributed;\n }\n\n let sequenceKey = UNKNOWN_CALL_SITE;\n let callSite = UNKNOWN_CALL_SITE;\n if (state.stackCaptures < MAX_STACK_CAPTURES) {\n state.stackCaptures++;\n const captured = getCallStack();\n sequenceKey = captured.stack;\n callSite = captured.site;\n }\n\n const existing = state.generatorBySequenceKey.get(sequenceKey);\n if (existing !== undefined) {\n return existing;\n }\n const callerId = state.nextCallerIdByCallSite.get(callSite) ?? 0;\n // Seeded from the counter only — never from the call-site or stack string itself. Those embed\n // bundle filenames and line numbers, which differ between the base build and the head build\n // of the very same code, so putting one in the seed would reseed every sequence on any\n // rebuild and bring back exactly the diffs this exists to remove. The strings are grouping\n // keys, nothing more.\n const generator = createSeededRandom(\n `meticulous-random-${state.frontendSessionId}-${callerId}`,\n );\n state.generatorBySequenceKey.set(sequenceKey, generator);\n state.nextCallerIdByCallSite.set(callSite, callerId + 1);\n return generator;\n};\n\n// ---------------------------------------------------------------------------\n// Derived primitives\n// ---------------------------------------------------------------------------\n\nconst HEX = \"0123456789abcdef\";\n/** The two bits of a v4 UUID's variant field are fixed, leaving these four values. */\nconst VARIANT = \"89ab\";\n\nconst buildUuid = (draw: () => number): string => {\n let uuid = \"\";\n for (let i = 0; i < 36; i++) {\n if (i === 8 || i === 13 || i === 18 || i === 23) {\n uuid += \"-\";\n } else if (i === 14) {\n uuid += \"4\";\n } else if (i === 19) {\n uuid += VARIANT[Math.floor(draw() * VARIANT.length)];\n } else {\n uuid += HEX[Math.floor(draw() * HEX.length)];\n }\n }\n return uuid;\n};\n\n/**\n * Fills the view's bytes rather than its elements, so every typed array — including the\n * BigInt ones, whose elements cannot be assigned a number — is handled by one path.\n */\nconst fillBytes = (view: ArrayBufferView, draw: () => number): void => {\n const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);\n for (let i = 0; i < bytes.length; i++) {\n bytes[i] = Math.floor(draw() * 256);\n }\n};\n\n// ---------------------------------------------------------------------------\n// Install\n// ---------------------------------------------------------------------------\n\nlet installed = false;\n\n/**\n * Replaces the worker's sources of randomness with per-request seeded generators. Idempotent,\n * and a no-op failure: a worker that will not let them be replaced still replays, just\n * without randomness virtualisation.\n *\n * Called lazily from the replay branch of the request wrapper rather than at module scope,\n * for the same reasons as the virtual clock: record mode and deployed production workers then\n * pay nothing, and the package declares no `sideEffects`, so a module-scope side effect could\n * legitimately be tree-shaken away by a customer's bundler.\n */\nexport const installVirtualRandom = (): void => {\n if (installed) {\n return;\n }\n installed = true;\n try {\n Math.random = () => openSequence()?.() ?? nativeMathRandom();\n\n if (globalThis.crypto != null) {\n Object.defineProperty(globalThis.crypto, \"randomUUID\", {\n value: (): string => {\n const draw = openSequence();\n if (draw !== undefined) {\n return buildUuid(draw);\n }\n return nativeRandomUUID?.() ?? buildUuid(nativeMathRandom);\n },\n configurable: true,\n writable: true,\n });\n\n Object.defineProperty(globalThis.crypto, \"getRandomValues\", {\n value: <T extends ArrayBufferView>(array: T): T => {\n const draw = openSequence();\n if (draw === undefined) {\n return nativeGetRandomValues?.(array) ?? array;\n }\n fillBytes(array, draw);\n return array;\n },\n configurable: true,\n writable: true,\n });\n }\n } catch (error) {\n warnOnce(\n \"virtual-random\",\n \"Could not install the Meticulous replay random-number generators — ids minted by the app will differ between replays.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":"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","debugId":"0b356a44-c057-5a04-b105-f150b4aab2af"}
|