@alwaysmeticulous/backend-recorder-workerd 2.319.0 → 2.321.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 CHANGED
@@ -51,12 +51,14 @@ Without `METICULOUS_SIDECAR_URL` (or `options.sidecarUrl`) the wrapper is a comp
51
51
  - **Inbound requests** (method, URL, headers, status; no bodies), correlated to the frontend session via the `x-meticulous-session-id` header stamped by the Meticulous frontend recorder.
52
52
  - **Outgoing `fetch` calls** made while handling an inbound request: method, URL, headers, status, and request/response bodies (UTF-8, capped at 256 KB; long-lived streams such as SSE are captured truncated).
53
53
  - **Calls through `fetch`-shaped bindings** — service bindings and Durable Object stubs — recorded like outgoing fetches, plus the `env` key the call went through. No code change is needed: the binding's `fetch` is instrumented wherever the app reads it from, including via `cloudflare:workers` rather than the handler's `env` argument.
54
+ - **KV namespace operations** — `get`, `getWithMetadata`, `put`, `delete` and `list` on a namespace found on `env`, recorded with the binding name, the key, the call's arguments and the value as JSON (so one `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for). Also no code change needed. A value read as a stream is never read by the recorder — that would take the bytes from the app — and binary values are skipped; both cases are recorded with the reason instead of the value.
54
55
 
55
- Request bodies have secret-looking JSON fields (`clientSecret`, `apiKey`, `token`, `password`, …) replaced with `REDACTED` before they leave the worker, since plenty of APIs carry a credential in the body rather than a header. Response bodies are stored verbatim.
56
+ Request bodies have secret-looking JSON fields (`clientSecret`, `apiKey`, `token`, `password`, …) replaced with `REDACTED` before they leave the worker, since plenty of APIs carry a credential in the body rather than a header. Response bodies are stored verbatim. A KV `put` value is redacted the same way, for the same reason; a value read back from KV is stored verbatim, like a response body.
56
57
 
57
58
  ## Limitations
58
59
 
59
- - Bindings other than `fetch`-shaped ones are not captured: KV, D1, R2, Queues, and RPC method calls on a named entrypoint (`env.SVC.someMethod()`) — an RPC method is not a patchable property, so it cannot be intercepted this way.
60
+ - Bindings other than `fetch`-shaped ones and KV namespaces are not captured: D1, R2, Queues, and RPC method calls on a named entrypoint (`env.SVC.someMethod()`) — an RPC method is not a patchable property, so it cannot be intercepted this way.
61
+ - A KV value that is not text is recorded without its value: reading a `stream` would take the bytes away from the app, and binary values are skipped because KV blobs are large and not UTF-8. The operation, key and arguments are still recorded, with `omitted` saying which case it was.
60
62
  - Assets bindings are skipped by default (`ASSETS`, `__STATIC_CONTENT`): asset bytes are large and often binary, and asset serving is usually a worker's highest-volume call. Use `options.skipBindings` to skip others.
61
63
  - A Durable Object stub is recorded without a binding name, since the stub comes from `namespace.get()` rather than being a binding itself.
62
64
  - A binding reference bound before the first instrumented request (`const f = env.SVC.fetch.bind(env.SVC)` at module scope) escapes instrumentation. Holding the binding _object_ and calling `.fetch()` on it per request — what SDKs normally do — is captured.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundFetchLookupRequest, OutboundFetchLookupResponse, OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
1
+ import { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, KvOmittedReason, KvOperation, KvOperationEvent, OutboundFetchLookupRequest, OutboundFetchLookupResponse, OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
2
2
  import { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap } from "./body-capture.js";
3
3
  import { headersToRecord } from "./outbound-capture.js";
4
4
  import { STR_REDACTED, redactRequestBody } from "./redact-body.js";
@@ -18,6 +18,7 @@ interface WithMeticulousOptions {
18
18
  /**
19
19
  * Binding names to leave unrecorded, on top of the defaults (`ASSETS`,
20
20
  * `__STATIC_CONTENT`). Use this for a binding whose traffic is high-volume or binary.
21
+ * Applies to KV namespaces as well as `fetch`-shaped bindings.
21
22
  */
22
23
  skipBindings?: readonly string[];
23
24
  }
@@ -30,13 +31,16 @@ interface WithMeticulousOptions {
30
31
  * });
31
32
  *
32
33
  * **Recording** activates on a `METICULOUS_SIDECAR_URL` var/binding (e.g. via `.dev.vars`
33
- * or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests and outgoing `fetch`
34
- * calls are reported to the sidecar as capture events, without affecting the app.
34
+ * or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests, outgoing `fetch`
35
+ * calls, calls through `fetch`-shaped bindings and KV namespace operations are reported to
36
+ * the sidecar as capture events, without affecting the app.
35
37
  *
36
38
  * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by
37
39
  * the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are
38
- * then served from the recording instead of reaching the real service, and the clock is
39
- * frozen at the recorded session's end so recorded credentials are still valid. Workerd
40
+ * then served from the recording instead of reaching the real service, the clock is frozen at
41
+ * the recorded session's end so recorded credentials are still valid, and `Math.random` /
42
+ * `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are the same
43
+ * in every replay of that session. Workerd
40
44
  * cannot read container environment variables, which is why per-replay config arrives as a
41
45
  * request header; the shim validates it and only honours a loopback / docker-gateway /
42
46
  * private-network `http:` origin.
@@ -52,5 +56,5 @@ interface WithMeticulousOptions {
52
56
  */
53
57
  declare const withMeticulous: <H extends MeticulousWorkerHandler<never>>(handler: H, options?: WithMeticulousOptions) => H;
54
58
  //#endregion
55
- export { type BindingRequestEvent, CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, MAX_BODY_CAPTURE_SIZE, MeticulousExecutionContext, MeticulousWorkerHandler, type OutboundFetchLookupRequest, type OutboundFetchLookupResponse, type OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, type ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH, STR_REDACTED, WithMeticulousOptions, headersToRecord, readBodyWithCap, readRequestBodyWithCap, redactRequestBody, withMeticulous };
59
+ export { type BindingRequestEvent, CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, type KvOmittedReason, type KvOperation, type KvOperationEvent, MAX_BODY_CAPTURE_SIZE, MeticulousExecutionContext, MeticulousWorkerHandler, type OutboundFetchLookupRequest, type OutboundFetchLookupResponse, type OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, type ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH, STR_REDACTED, WithMeticulousOptions, headersToRecord, readBodyWithCap, readRequestBodyWithCap, redactRequestBody, withMeticulous };
56
60
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
Binary file
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { installBindingPatch } from \"./binding-patch\";\nimport {\n type RequestCaptureContext,\n type RequestReplayContext,\n requestCaptureContext,\n} from \"./context\";\nimport { installFetchPatch } from \"./fetch-patch\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport { headersToRecord } from \"./outbound-capture\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n REPLAY_SIDECAR_URL_HEADER,\n} from \"./protocol\";\nimport { parseReplaySidecarUrl } from \"./replay-sidecar-url\";\nimport { getReplaySessionInfo, postCaptureEvents } from \"./sidecar-client\";\nimport { installVirtualClock } from \"./virtual-clock\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\nexport {\n MAX_BODY_CAPTURE_SIZE,\n readBodyWithCap,\n readRequestBodyWithCap,\n} from \"./body-capture\";\nexport { headersToRecord } from \"./outbound-capture\";\nexport { redactRequestBody, STR_REDACTED } from \"./redact-body\";\n\n/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */\nexport interface MeticulousExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\nexport interface WithMeticulousOptions {\n /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */\n sidecarUrl?: string;\n /**\n * Binding names to leave unrecorded, on top of the defaults (`ASSETS`,\n * `__STATIC_CONTENT`). Use this for a binding whose traffic is high-volume or binary.\n */\n skipBindings?: readonly string[];\n}\n\n/**\n * Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP\n * behaviour:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * **Recording** activates on a `METICULOUS_SIDECAR_URL` var/binding (e.g. via `.dev.vars`\n * or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests and outgoing `fetch`\n * calls are reported to the sidecar as capture events, without affecting the app.\n *\n * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by\n * the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are\n * then served from the recording instead of reaching the real service, and the clock is\n * frozen at the recorded session's end so recorded credentials are still valid. Workerd\n * cannot read container environment variables, which is why per-replay config arrives as a\n * request header; the shim validates it and only honours a loopback / docker-gateway /\n * private-network `http:` origin.\n *\n * Replay takes precedence over recording when both are configured. A stale\n * `METICULOUS_SIDECAR_URL` baked into an image's `.dev.vars` is far more likely than a\n * spurious replay header (nothing but the replay runner emits it), and letting the env win\n * would silently record a replay instead of mocking it.\n *\n * With neither configured the wrapper is a complete pass-through, so it is safe to keep in\n * deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A\n * capture failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = async (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> => {\n const invokeHandler = () => handler.fetch(request, env as never, ctx);\n\n let sidecarUrl: string | undefined;\n let replayContext: RequestReplayContext | undefined;\n try {\n replayContext = await resolveReplayContext(request, ctx);\n sidecarUrl =\n replayContext === undefined\n ? resolveSidecarUrl(options, env)\n : undefined;\n if (replayContext !== undefined || sidecarUrl !== undefined) {\n installFetchPatch();\n }\n if (sidecarUrl !== undefined) {\n // Lazily, inside the request path, and never at module scope: the package declares\n // no `sideEffects` and is built with `platform: \"neutral\"`, so a module-scope side\n // effect can legitimately be tree-shaken by a customer's bundler. Re-run per\n // request because `env` is the only handle on the binding instances.\n //\n // Record mode only. Binding calls are recorded as their own technology\n // (`workerd-binding`) and the replay sidecar's store holds only `workerd-fetch`\n // spans, so there is nothing to serve them from — and patching would only route\n // them into the capture tee, which POSTs to an events route a replay sidecar 404s.\n installBindingPatch(env, { skipBindings: options?.skipBindings });\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n replayContext = undefined;\n sidecarUrl = undefined;\n }\n\n if (replayContext !== undefined) {\n // Install the clock before any app code runs, so a module that reads Date.now() at\n // call time (the firebase auth libraries do) sees the session's frozen instant.\n installVirtualClock();\n // No inbound reporting in replay mode: there is no exporter behind the replay\n // sidecar, and the events route 404s there.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n if (sidecarUrl === undefined) {\n return invokeHandler();\n }\n\n const captureCtx = buildCaptureContext(request, sidecarUrl, ctx);\n const startTimeMs = Date.now();\n let requestHeaders: Record<string, string[]> = {};\n let method = \"GET\";\n let url = \"\";\n try {\n requestHeaders = headersToRecord(request.headers);\n method = request.method;\n url = request.url;\n } catch (error) {\n warnOnce(\n \"inbound-capture\",\n \"Failed to capture the inbound request.\",\n error,\n );\n }\n\n const reportInbound = (\n outcome:\n | { statusCode: number; responseHeaders: Record<string, string[]> }\n | { error: string },\n ): void => {\n try {\n const event: InboundRequestEvent = {\n kind: \"inbound\",\n requestId: captureCtx.requestId,\n ...(captureCtx.frontendSessionId !== undefined\n ? { frontendSessionId: captureCtx.frontendSessionId }\n : {}),\n method,\n url,\n requestHeaders,\n startTimeMs,\n endTimeMs: Date.now(),\n ...outcome,\n };\n captureCtx.waitUntil(\n postCaptureEvents(getOriginalFetch(), sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n };\n\n let response: Response;\n try {\n response = await requestCaptureContext.run(captureCtx, invokeHandler);\n } catch (error) {\n reportInbound({ error: String(error) });\n throw error;\n }\n\n try {\n reportInbound({\n statusCode: response.status,\n responseHeaders: headersToRecord(response.headers),\n });\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n return response;\n };\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript\n // cannot verify a spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\n/**\n * Whether this request should be replayed, and the context to serve it under.\n *\n * Both the session id and a usable sidecar URL must be present: mocks are indexed per\n * session, so replay without a session id could only miss. The sidecar is consulted once\n * per (sidecar, session) per isolate to confirm it holds mocks and to learn the clock\n * anchor — a sidecar that does not recognise the session, or the route, means replay is\n * unavailable and the request runs normally.\n */\nconst resolveReplayContext = async (\n request: Request,\n ctx: MeticulousExecutionContext,\n): Promise<RequestReplayContext | undefined> => {\n let sidecarUrl: string | undefined;\n let frontendSessionId: string | undefined;\n try {\n sidecarUrl = parseReplaySidecarUrl(\n request.headers.get(REPLAY_SIDECAR_URL_HEADER),\n );\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n return undefined;\n }\n if (sidecarUrl === undefined || frontendSessionId === undefined) {\n return undefined;\n }\n\n const info = await getCachedReplaySessionInfo(sidecarUrl, frontendSessionId);\n if (info === null) {\n return undefined;\n }\n\n return {\n mode: \"replay\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n clockAnchorMs: info.clockAnchorMs,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\n// Caches the promise, not the result, so concurrent first requests share one round trip.\n// Bounded so a stream of unrecognised session ids cannot grow it without limit.\nconst MAX_CACHED_SESSIONS = 50;\nconst replaySessionInfoCache = new Map<\n string,\n Promise<{ clockAnchorMs: number | undefined } | null>\n>();\n\nconst getCachedReplaySessionInfo = (\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<{ clockAnchorMs: number | undefined } | null> => {\n const key = `${sidecarUrl}\u0000${frontendSessionId}`;\n const cached = replaySessionInfoCache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const pending = getReplaySessionInfo(\n getOriginalFetch(),\n sidecarUrl,\n frontendSessionId,\n ).then((result) => {\n if (result.outcome === \"unreachable\") {\n // Only settled answers are worth keeping. Caching a timeout or transport blip would\n // disable mocking for this session for the life of the isolate, quietly sending every\n // later outbound call to the real service — the failure this path exists to prevent.\n // Dropping the entry costs one extra handshake and lets the next request recover.\n replaySessionInfoCache.delete(key);\n return null;\n }\n return result.outcome === \"found\"\n ? { clockAnchorMs: result.clockAnchorMs }\n : null;\n });\n if (replaySessionInfoCache.size >= MAX_CACHED_SESSIONS) {\n const oldest = replaySessionInfoCache.keys().next();\n if (!oldest.done) {\n replaySessionInfoCache.delete(oldest.value);\n }\n }\n replaySessionInfoCache.set(key, pending);\n return pending;\n};\n\nconst resolveSidecarUrl = (\n options: WithMeticulousOptions | undefined,\n env: unknown,\n): string | undefined => {\n const fromEnv =\n env !== null && typeof env === \"object\"\n ? (env as Record<string, unknown>)[\"METICULOUS_SIDECAR_URL\"]\n : undefined;\n const raw = options?.sidecarUrl ?? fromEnv;\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n return raw.replace(/\\/+$/, \"\");\n};\n\nconst buildCaptureContext = (\n request: Request,\n sidecarUrl: string,\n ctx: MeticulousExecutionContext,\n): RequestCaptureContext => {\n let frontendSessionId: string | undefined;\n try {\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n frontendSessionId = undefined;\n }\n return {\n mode: \"record\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\nconst buildWaitUntil =\n (ctx: MeticulousExecutionContext) =>\n (promise: Promise<unknown>): void => {\n try {\n ctx.waitUntil(promise);\n } catch (error) {\n warnOnce(\n \"wait-until\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n };\n"],"names":[],"mappings":"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","debugId":"8791d2a2-34c5-5ddf-81a1-7f74833160ef"}
1
+ {"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { installBindingPatch } from \"./binding-patch\";\nimport {\n type RequestCaptureContext,\n type RequestReplayContext,\n requestCaptureContext,\n} from \"./context\";\nimport { installFetchPatch } from \"./fetch-patch\";\nimport { installKvPatch } from \"./kv-patch\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport { headersToRecord } from \"./outbound-capture\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n REPLAY_SIDECAR_URL_HEADER,\n} from \"./protocol\";\nimport { parseReplaySidecarUrl } from \"./replay-sidecar-url\";\nimport { getReplaySessionInfo, postCaptureEvents } from \"./sidecar-client\";\nimport { installVirtualClock } from \"./virtual-clock\";\nimport { installVirtualRandom } from \"./virtual-random\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\nexport {\n MAX_BODY_CAPTURE_SIZE,\n readBodyWithCap,\n readRequestBodyWithCap,\n} from \"./body-capture\";\nexport { headersToRecord } from \"./outbound-capture\";\nexport { redactRequestBody, STR_REDACTED } from \"./redact-body\";\n\n/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */\nexport interface MeticulousExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\nexport interface WithMeticulousOptions {\n /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */\n sidecarUrl?: string;\n /**\n * Binding names to leave unrecorded, on top of the defaults (`ASSETS`,\n * `__STATIC_CONTENT`). Use this for a binding whose traffic is high-volume or binary.\n * Applies to KV namespaces as well as `fetch`-shaped bindings.\n */\n skipBindings?: readonly string[];\n}\n\n/**\n * Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP\n * behaviour:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * **Recording** activates on a `METICULOUS_SIDECAR_URL` var/binding (e.g. via `.dev.vars`\n * or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests, outgoing `fetch`\n * calls, calls through `fetch`-shaped bindings and KV namespace operations are reported to\n * the sidecar as capture events, without affecting the app.\n *\n * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by\n * the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are\n * then served from the recording instead of reaching the real service, the clock is frozen at\n * the recorded session's end so recorded credentials are still valid, and `Math.random` /\n * `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are the same\n * in every replay of that session. Workerd\n * cannot read container environment variables, which is why per-replay config arrives as a\n * request header; the shim validates it and only honours a loopback / docker-gateway /\n * private-network `http:` origin.\n *\n * Replay takes precedence over recording when both are configured. A stale\n * `METICULOUS_SIDECAR_URL` baked into an image's `.dev.vars` is far more likely than a\n * spurious replay header (nothing but the replay runner emits it), and letting the env win\n * would silently record a replay instead of mocking it.\n *\n * With neither configured the wrapper is a complete pass-through, so it is safe to keep in\n * deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A\n * capture failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = async (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> => {\n const invokeHandler = () => handler.fetch(request, env as never, ctx);\n\n let sidecarUrl: string | undefined;\n let replayContext: RequestReplayContext | undefined;\n try {\n replayContext = await resolveReplayContext(request, ctx);\n sidecarUrl =\n replayContext === undefined\n ? resolveSidecarUrl(options, env)\n : undefined;\n if (replayContext !== undefined || sidecarUrl !== undefined) {\n installFetchPatch();\n }\n if (sidecarUrl !== undefined) {\n // Lazily, inside the request path, and never at module scope: the package declares\n // no `sideEffects` and is built with `platform: \"neutral\"`, so a module-scope side\n // effect can legitimately be tree-shaken by a customer's bundler. Re-run per\n // request because `env` is the only handle on the binding instances.\n //\n // Record mode only. Binding and KV calls are recorded as their own technologies\n // (`workerd-binding`, `workerd-kv`) and the replay sidecar's store holds only\n // `workerd-fetch` spans, so there is nothing to serve them from — and patching would\n // only route them into the capture tee, which POSTs to an events route a replay\n // sidecar 404s.\n installBindingPatch(env, { skipBindings: options?.skipBindings });\n installKvPatch(env, { skipBindings: options?.skipBindings });\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n replayContext = undefined;\n sidecarUrl = undefined;\n }\n\n if (replayContext !== undefined) {\n // Install the clock before any app code runs, so a module that reads Date.now() at\n // call time (the firebase auth libraries do) sees the session's frozen instant.\n installVirtualClock();\n // Likewise the random generators: an id the app mints during SSR (a guest id, a\n // request id) otherwise differs between a base and a head replay, and every rendered\n // page carrying it diffs forever.\n installVirtualRandom();\n // No inbound reporting in replay mode: there is no exporter behind the replay\n // sidecar, and the events route 404s there.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n if (sidecarUrl === undefined) {\n return invokeHandler();\n }\n\n const captureCtx = buildCaptureContext(request, sidecarUrl, ctx);\n const startTimeMs = Date.now();\n let requestHeaders: Record<string, string[]> = {};\n let method = \"GET\";\n let url = \"\";\n try {\n requestHeaders = headersToRecord(request.headers);\n method = request.method;\n url = request.url;\n } catch (error) {\n warnOnce(\n \"inbound-capture\",\n \"Failed to capture the inbound request.\",\n error,\n );\n }\n\n const reportInbound = (\n outcome:\n | { statusCode: number; responseHeaders: Record<string, string[]> }\n | { error: string },\n ): void => {\n try {\n const event: InboundRequestEvent = {\n kind: \"inbound\",\n requestId: captureCtx.requestId,\n ...(captureCtx.frontendSessionId !== undefined\n ? { frontendSessionId: captureCtx.frontendSessionId }\n : {}),\n method,\n url,\n requestHeaders,\n startTimeMs,\n endTimeMs: Date.now(),\n ...outcome,\n };\n captureCtx.waitUntil(\n postCaptureEvents(getOriginalFetch(), sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n };\n\n let response: Response;\n try {\n response = await requestCaptureContext.run(captureCtx, invokeHandler);\n } catch (error) {\n reportInbound({ error: String(error) });\n throw error;\n }\n\n try {\n reportInbound({\n statusCode: response.status,\n responseHeaders: headersToRecord(response.headers),\n });\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n return response;\n };\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript\n // cannot verify a spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\n/**\n * Whether this request should be replayed, and the context to serve it under.\n *\n * Both the session id and a usable sidecar URL must be present: mocks are indexed per\n * session, so replay without a session id could only miss. The sidecar is consulted once\n * per (sidecar, session) per isolate to confirm it holds mocks and to learn the clock\n * anchor — a sidecar that does not recognise the session, or the route, means replay is\n * unavailable and the request runs normally.\n */\nconst resolveReplayContext = async (\n request: Request,\n ctx: MeticulousExecutionContext,\n): Promise<RequestReplayContext | undefined> => {\n let sidecarUrl: string | undefined;\n let frontendSessionId: string | undefined;\n try {\n sidecarUrl = parseReplaySidecarUrl(\n request.headers.get(REPLAY_SIDECAR_URL_HEADER),\n );\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n return undefined;\n }\n if (sidecarUrl === undefined || frontendSessionId === undefined) {\n return undefined;\n }\n\n const info = await getCachedReplaySessionInfo(sidecarUrl, frontendSessionId);\n if (info === null) {\n return undefined;\n }\n\n return {\n mode: \"replay\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n clockAnchorMs: info.clockAnchorMs,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\n// Caches the promise, not the result, so concurrent first requests share one round trip.\n// Bounded so a stream of unrecognised session ids cannot grow it without limit.\nconst MAX_CACHED_SESSIONS = 50;\nconst replaySessionInfoCache = new Map<\n string,\n Promise<{ clockAnchorMs: number | undefined } | null>\n>();\n\nconst getCachedReplaySessionInfo = (\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<{ clockAnchorMs: number | undefined } | null> => {\n const key = `${sidecarUrl}\u0000${frontendSessionId}`;\n const cached = replaySessionInfoCache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const pending = getReplaySessionInfo(\n getOriginalFetch(),\n sidecarUrl,\n frontendSessionId,\n ).then((result) => {\n if (result.outcome === \"unreachable\") {\n // Only settled answers are worth keeping. Caching a timeout or transport blip would\n // disable mocking for this session for the life of the isolate, quietly sending every\n // later outbound call to the real service — the failure this path exists to prevent.\n // Dropping the entry costs one extra handshake and lets the next request recover.\n replaySessionInfoCache.delete(key);\n return null;\n }\n return result.outcome === \"found\"\n ? { clockAnchorMs: result.clockAnchorMs }\n : null;\n });\n if (replaySessionInfoCache.size >= MAX_CACHED_SESSIONS) {\n const oldest = replaySessionInfoCache.keys().next();\n if (!oldest.done) {\n replaySessionInfoCache.delete(oldest.value);\n }\n }\n replaySessionInfoCache.set(key, pending);\n return pending;\n};\n\nconst resolveSidecarUrl = (\n options: WithMeticulousOptions | undefined,\n env: unknown,\n): string | undefined => {\n const fromEnv =\n env !== null && typeof env === \"object\"\n ? (env as Record<string, unknown>)[\"METICULOUS_SIDECAR_URL\"]\n : undefined;\n const raw = options?.sidecarUrl ?? fromEnv;\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n return raw.replace(/\\/+$/, \"\");\n};\n\nconst buildCaptureContext = (\n request: Request,\n sidecarUrl: string,\n ctx: MeticulousExecutionContext,\n): RequestCaptureContext => {\n let frontendSessionId: string | undefined;\n try {\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n frontendSessionId = undefined;\n }\n return {\n mode: \"record\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\nconst buildWaitUntil =\n (ctx: MeticulousExecutionContext) =>\n (promise: Promise<unknown>): void => {\n try {\n ctx.waitUntil(promise);\n } catch (error) {\n warnOnce(\n \"wait-until\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n };\n"],"names":[],"mappings":"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","debugId":"79e05547-78af-52ae-aac6-9c8d71abbba6"}
@@ -0,0 +1,261 @@
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]="93bf09ea-4e78-5ac4-92cd-661f2ce03542")}catch(e){}}();
3
+ import { requestCaptureContext } from "./context.js";
4
+ import { warnOnce } from "./log.js";
5
+ import { redactRequestBody } from "./redact-body.js";
6
+ import { MAX_BODY_CAPTURE_SIZE } from "./body-capture.js";
7
+ import { getOriginalFetch } from "./original-fetch.js";
8
+ import { postCaptureEvents } from "./sidecar-client.js";
9
+ //#region src/kv-patch.ts
10
+ /**
11
+ * Records operations on Cloudflare KV namespace bindings (`env.MY_KV.get(key)`).
12
+ *
13
+ * KV is invisible to both the `globalThis.fetch` patch and the binding patch: a namespace is
14
+ * a host object with no `fetch`, and workerd services its methods internally rather than as
15
+ * a subrequest. So the namespace's *prototype* methods are patched instead — the same move
16
+ * `binding-patch.ts` makes on a Fetcher, and the Node recorder on `Redis.prototype.sendCommand`.
17
+ *
18
+ * The prototype is reached from an instance found on `env`: as with bindings there is no
19
+ * global to name, and going through an instance is what makes this work for an app that reads
20
+ * its bindings from `cloudflare:workers` rather than the handler argument — that module hands
21
+ * out a different `env` object but the same namespace instances, so the prototype is shared
22
+ * either way.
23
+ *
24
+ * Verified against real workerd in `packages/backend-recorder-js/src/__tests__/`:
25
+ * `workerd-binding-patch.spec.ts` pins the property shape this relies on (every recorded
26
+ * method inherited and configurable, one prototype across namespaces), and
27
+ * `workerd-kv-sidecar.spec.ts` drives real KV through the patch end to end.
28
+ */
29
+ const KV_PATCHED = Symbol.for("meticulous.workerd.kvPatched");
30
+ /**
31
+ * The methods recorded, and the shape a KV namespace is recognised by.
32
+ *
33
+ * `getWithMetadata` is what separates KV from R2: an R2 bucket also has `get`, `put`,
34
+ * `delete` and `list`, so without it a bucket would be duck-typed as a namespace and its
35
+ * (large, binary) objects pushed through value capture.
36
+ */
37
+ const RECORDED_KV_METHODS = [
38
+ "get",
39
+ "getWithMetadata",
40
+ "put",
41
+ "delete",
42
+ "list"
43
+ ];
44
+ /**
45
+ * The namespaces being recorded, and the `env` key each was found under.
46
+ *
47
+ * Doubles as the allow-list: only an instance in here is recorded. A skipped namespace is
48
+ * simply never registered, and — since the patch can land on a prototype shared with another
49
+ * binding type — nothing that is not a discovered KV namespace can be recorded by accident.
50
+ */
51
+ const kvBindingNames = /* @__PURE__ */ new WeakMap();
52
+ /**
53
+ * Discovers KV namespace bindings on `env` and patches their shared prototype so their
54
+ * operations are recorded. Safe to call on every request — discovery is cheap and the patch
55
+ * itself is applied once per prototype per isolate.
56
+ *
57
+ * Never throws: a runtime that does not permit the patch simply goes unrecorded.
58
+ */
59
+ const installKvPatch = (env, options) => {
60
+ try {
61
+ for (const { name, value, skip } of kvBindings(env, options?.skipBindings)) {
62
+ patchKvMethods(value);
63
+ if (!skip) kvBindingNames.set(value, name);
64
+ }
65
+ } catch (error) {
66
+ warnOnce("kv-patch-install", "Failed to install the Meticulous KV patch — KV operations will not be recorded.", error);
67
+ }
68
+ };
69
+ const kvBindings = (env, skipBindings) => {
70
+ if (env === null || typeof env !== "object") return [];
71
+ const skipped = new Set(skipBindings ?? []);
72
+ const found = [];
73
+ for (const [name, value] of Object.entries(env)) if (isKvNamespaceLike(value)) found.push({
74
+ name,
75
+ value,
76
+ skip: skipped.has(name)
77
+ });
78
+ return found;
79
+ };
80
+ const isKvNamespaceLike = (value) => value !== null && typeof value === "object" && RECORDED_KV_METHODS.every((method) => typeof value[method] === "function");
81
+ /**
82
+ * Patches every recorded method, or none of them: a half-patched namespace would record a
83
+ * partial view of the app's KV usage, which is worse than recording none of it, because
84
+ * nothing in the recording would say which half is missing. So the whole chain is resolved
85
+ * first, and only then patched.
86
+ *
87
+ * Each method is patched where it actually lives rather than on the instance's immediate
88
+ * prototype — workerd already puts an inherited method further up the chain for Durable
89
+ * Object stubs, so assuming one flat prototype is not safe.
90
+ */
91
+ const patchKvMethods = (binding) => {
92
+ const owners = /* @__PURE__ */ new Map();
93
+ for (const method of RECORDED_KV_METHODS) {
94
+ const owner = findMethodOwner(binding, method);
95
+ if (owner === void 0) {
96
+ warnOnce("kv-patch-unsupported", `This runtime does not expose a patchable KV \`${method}\` — KV operations will not be recorded.`);
97
+ return;
98
+ }
99
+ owners.set(method, owner);
100
+ }
101
+ for (const [method, { owner, original }] of owners) {
102
+ if (original[KV_PATCHED] === true) continue;
103
+ Object.defineProperty(owner, method, {
104
+ value: patchedKvMethod(method, original),
105
+ writable: true,
106
+ enumerable: false,
107
+ configurable: true
108
+ });
109
+ }
110
+ };
111
+ /**
112
+ * The prototype whose `method` a call on `instance` would reach, or undefined if there is none
113
+ * or it cannot be replaced. The search stops at the first prototype that *has* the property,
114
+ * patchable or not: that is the one a call resolves to, so anything further up is shadowed.
115
+ */
116
+ const findMethodOwner = (instance, method) => {
117
+ if (Object.hasOwn(instance, method)) return;
118
+ let current = Object.getPrototypeOf(instance);
119
+ while (current !== null) {
120
+ const descriptor = Object.getOwnPropertyDescriptor(current, method);
121
+ if (descriptor !== void 0) return typeof descriptor.value === "function" && descriptor.configurable === true ? {
122
+ owner: current,
123
+ original: descriptor.value
124
+ } : void 0;
125
+ current = Object.getPrototypeOf(current);
126
+ }
127
+ };
128
+ /**
129
+ * A `function`, not an arrow: `this` is the namespace the call was made on, and it must be
130
+ * forwarded to the original or workerd throws "Illegal invocation".
131
+ */
132
+ const patchedKvMethod = (operation, original) => {
133
+ const patched = function(...args) {
134
+ const passThrough = () => original.apply(this, args);
135
+ const ctx = requestCaptureContext.getStore();
136
+ if (!ctx || ctx.mode !== "record") return passThrough();
137
+ const receiver = typeof this === "object" && this !== null ? this : void 0;
138
+ const bindingName = receiver === void 0 ? void 0 : kvBindingNames.get(receiver);
139
+ if (bindingName === void 0) return passThrough();
140
+ return captureKvOperation(ctx, operation, bindingName, args, passThrough);
141
+ };
142
+ Object.defineProperty(patched, KV_PATCHED, {
143
+ value: true,
144
+ enumerable: false,
145
+ configurable: true
146
+ });
147
+ return patched;
148
+ };
149
+ /**
150
+ * Records an operation without affecting it: the real method runs untouched and its result is
151
+ * returned as-is, with the event reported in the background via `ctx.waitUntil`. Any capture
152
+ * failure is warned about once and swallowed — recording must never break the app.
153
+ */
154
+ const captureKvOperation = async (ctx, operation, bindingName, args, invoke) => {
155
+ const startTimeMs = Date.now();
156
+ let result;
157
+ try {
158
+ result = await invoke();
159
+ } catch (error) {
160
+ report(ctx, operation, bindingName, args, startTimeMs, { error: String(error) });
161
+ throw error;
162
+ }
163
+ report(ctx, operation, bindingName, args, startTimeMs, { result });
164
+ return result;
165
+ };
166
+ const report = (ctx, operation, bindingName, args, startTimeMs, outcome) => {
167
+ try {
168
+ const event = buildKvEvent(ctx, operation, bindingName, args, startTimeMs, outcome);
169
+ ctx.waitUntil(postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]));
170
+ } catch (error) {
171
+ warnOnce("kv-report", "Failed to report a KV operation.", error);
172
+ }
173
+ };
174
+ const buildKvEvent = (ctx, operation, bindingName, args, startTimeMs, outcome) => {
175
+ const [key] = args;
176
+ const written = operation === "put" ? serializeWrittenValue(args[1]) : void 0;
177
+ const read = "result" in outcome ? serializeKvValue(outcome.result) : void 0;
178
+ const omitted = written?.omitted ?? read?.omitted;
179
+ const serializedArgs = serializeKvArgs(operation, args);
180
+ return {
181
+ kind: "kv",
182
+ requestId: ctx.requestId,
183
+ ...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
184
+ bindingName,
185
+ operation,
186
+ ...typeof key === "string" ? { key } : {},
187
+ ...serializedArgs !== void 0 ? { args: serializedArgs } : {},
188
+ ...written?.captured !== void 0 ? { value: written.captured } : {},
189
+ ...read?.captured !== void 0 ? { result: read.captured } : {},
190
+ ...omitted !== void 0 ? { omitted } : {},
191
+ startTimeMs,
192
+ endTimeMs: Date.now(),
193
+ ..."error" in outcome ? { error: outcome.error } : {}
194
+ };
195
+ };
196
+ /**
197
+ * JSON of a value workerd returned, with anything unpersistable replaced by `null` and the
198
+ * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object,
199
+ * which `JSON.stringify` would otherwise flatten to `{}`.
200
+ *
201
+ * Read values are stored verbatim, deliberately: a future replay would serve them straight
202
+ * back to the app, so redacting one would corrupt what the app receives. Only the write path
203
+ * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.
204
+ */
205
+ const serializeKvValue = (value) => {
206
+ let omitted;
207
+ try {
208
+ const json = JSON.stringify(value, (_key, nested) => {
209
+ const reason = omittedReason(nested);
210
+ if (reason !== void 0) {
211
+ omitted ??= reason;
212
+ return null;
213
+ }
214
+ return nested instanceof Map ? Object.fromEntries(nested) : nested;
215
+ });
216
+ return {
217
+ ...json !== void 0 ? { captured: capture(json) } : {},
218
+ ...omitted !== void 0 ? { omitted } : {}
219
+ };
220
+ } catch {
221
+ return {};
222
+ }
223
+ };
224
+ /**
225
+ * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an
226
+ * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as
227
+ * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are
228
+ * caught (redacting the JSON-encoded form would only ever see one opaque string).
229
+ */
230
+ const serializeWrittenValue = (value) => {
231
+ if (typeof value === "string") try {
232
+ return { captured: capture(JSON.stringify(redactRequestBody(value))) };
233
+ } catch {
234
+ return {};
235
+ }
236
+ return serializeKvValue(value);
237
+ };
238
+ /**
239
+ * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so
240
+ * the remaining arguments keep their positions — and so the value is only ever persisted via
241
+ * the redacted {@link serializeWrittenValue} path.
242
+ */
243
+ const serializeKvArgs = (operation, args) => {
244
+ return serializeKvValue(operation === "put" ? args.map((arg, index) => index === 1 ? null : arg) : args).captured;
245
+ };
246
+ const omittedReason = (value) => {
247
+ if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) return "stream";
248
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return "binary";
249
+ };
250
+ const capture = (json) => json.length > 262144 ? {
251
+ body: json.slice(0, MAX_BODY_CAPTURE_SIZE),
252
+ truncated: true
253
+ } : {
254
+ body: json,
255
+ truncated: false
256
+ };
257
+ //#endregion
258
+ export { installKvPatch };
259
+
260
+ //# sourceMappingURL=kv-patch.js.map
261
+ //# debugId=93bf09ea-4e78-5ac4-92cd-661f2ce03542
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kv-patch.js","sources":["../src/kv-patch.ts"],"sourcesContent":["import { MAX_BODY_CAPTURE_SIZE } from \"./body-capture\";\nimport { type RequestCaptureContext, requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport type {\n CapturedBody,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n} from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\n/**\n * Records operations on Cloudflare KV namespace bindings (`env.MY_KV.get(key)`).\n *\n * KV is invisible to both the `globalThis.fetch` patch and the binding patch: a namespace is\n * a host object with no `fetch`, and workerd services its methods internally rather than as\n * a subrequest. So the namespace's *prototype* methods are patched instead — the same move\n * `binding-patch.ts` makes on a Fetcher, and the Node recorder on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`: as with bindings there is no\n * global to name, and going through an instance is what makes this work for an app that reads\n * its bindings from `cloudflare:workers` rather than the handler argument — that module hands\n * out a different `env` object but the same namespace instances, so the prototype is shared\n * either way.\n *\n * Verified against real workerd in `packages/backend-recorder-js/src/__tests__/`:\n * `workerd-binding-patch.spec.ts` pins the property shape this relies on (every recorded\n * method inherited and configurable, one prototype across namespaces), and\n * `workerd-kv-sidecar.spec.ts` drives real KV through the patch end to end.\n */\n\n// Marks a patched method, rather than the prototype holding it: methods can live at different\n// depths of one chain, and a base prototype can be shared with another binding type, so\n// \"already ours\" has to be answerable per function. Symbol.for so a second copy of the shim\n// (e.g. bundled twice) still detects the patch.\nconst KV_PATCHED = Symbol.for(\"meticulous.workerd.kvPatched\");\n\n/**\n * The methods recorded, and the shape a KV namespace is recognised by.\n *\n * `getWithMetadata` is what separates KV from R2: an R2 bucket also has `get`, `put`,\n * `delete` and `list`, so without it a bucket would be duck-typed as a namespace and its\n * (large, binary) objects pushed through value capture.\n */\nconst RECORDED_KV_METHODS = [\n \"get\",\n \"getWithMetadata\",\n \"put\",\n \"delete\",\n \"list\",\n] as const;\n\n/**\n * The namespaces being recorded, and the `env` key each was found under.\n *\n * Doubles as the allow-list: only an instance in here is recorded. A skipped namespace is\n * simply never registered, and — since the patch can land on a prototype shared with another\n * binding type — nothing that is not a discovered KV namespace can be recorded by accident.\n */\nconst kvBindingNames = new WeakMap<object, string>();\n\ntype KvMethod = (...args: unknown[]) => Promise<unknown>;\n\ntype KvNamespaceLike = Record<KvOperation, KvMethod>;\n\nexport interface InstallKvPatchOptions {\n /** Binding names to leave unrecorded. */\n skipBindings?: readonly string[];\n}\n\n/**\n * Discovers KV namespace bindings on `env` and patches their shared prototype so their\n * operations are recorded. Safe to call on every request — discovery is cheap and the patch\n * itself is applied once per prototype per isolate.\n *\n * Never throws: a runtime that does not permit the patch simply goes unrecorded.\n */\nexport const installKvPatch = (\n env: unknown,\n options?: InstallKvPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of kvBindings(\n env,\n options?.skipBindings,\n )) {\n // Patch even a skipped namespace's methods: they are shared with every other namespace,\n // so seeding them is what gives those coverage. Skipping is enforced by leaving the\n // instance off the allow-list below, not by declining to patch.\n patchKvMethods(value);\n if (!skip) {\n kvBindingNames.set(value, name);\n }\n }\n } catch (error) {\n warnOnce(\n \"kv-patch-install\",\n \"Failed to install the Meticulous KV patch — KV operations will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredKvBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst kvBindings = (\n env: unknown,\n skipBindings: readonly string[] | undefined,\n): DiscoveredKvBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>(skipBindings ?? []);\n const found: DiscoveredKvBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isKvNamespaceLike(value)) {\n found.push({ name, value, skip: skipped.has(name) });\n }\n }\n return found;\n};\n\nconst isKvNamespaceLike = (value: unknown): value is object & KvNamespaceLike =>\n value !== null &&\n typeof value === \"object\" &&\n RECORDED_KV_METHODS.every(\n (method) =>\n typeof (value as Record<string, unknown>)[method] === \"function\",\n );\n\n/**\n * Patches every recorded method, or none of them: a half-patched namespace would record a\n * partial view of the app's KV usage, which is worse than recording none of it, because\n * nothing in the recording would say which half is missing. So the whole chain is resolved\n * first, and only then patched.\n *\n * Each method is patched where it actually lives rather than on the instance's immediate\n * prototype — workerd already puts an inherited method further up the chain for Durable\n * Object stubs, so assuming one flat prototype is not safe.\n */\nconst patchKvMethods = (binding: object): void => {\n const owners = new Map<KvOperation, MethodOwner>();\n for (const method of RECORDED_KV_METHODS) {\n const owner = findMethodOwner(binding, method);\n if (owner === undefined) {\n // Either the method is an own per-instance property or the runtime has locked the\n // prototype. Recording KV is not possible; everything else keeps working.\n warnOnce(\n \"kv-patch-unsupported\",\n `This runtime does not expose a patchable KV \\`${method}\\` — KV operations will not be recorded.`,\n );\n return;\n }\n owners.set(method, owner);\n }\n\n for (const [method, { owner, original }] of owners) {\n if ((original as unknown as Record<symbol, unknown>)[KV_PATCHED] === true) {\n continue;\n }\n Object.defineProperty(owner, method, {\n value: patchedKvMethod(method, original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n }\n};\n\ninterface MethodOwner {\n owner: object;\n original: KvMethod;\n}\n\n/**\n * The prototype whose `method` a call on `instance` would reach, or undefined if there is none\n * or it cannot be replaced. The search stops at the first prototype that *has* the property,\n * patchable or not: that is the one a call resolves to, so anything further up is shadowed.\n */\nconst findMethodOwner = (\n instance: object,\n method: KvOperation,\n): MethodOwner | undefined => {\n if (Object.hasOwn(instance, method)) {\n // An own per-instance method would shadow anything we patched on the prototype.\n return undefined;\n }\n let current: object | null = Object.getPrototypeOf(instance) as object | null;\n while (current !== null) {\n const descriptor = Object.getOwnPropertyDescriptor(current, method);\n if (descriptor !== undefined) {\n return typeof descriptor.value === \"function\" &&\n descriptor.configurable === true\n ? { owner: current, original: descriptor.value as KvMethod }\n : undefined;\n }\n current = Object.getPrototypeOf(current) as object | null;\n }\n return undefined;\n};\n\n/**\n * A `function`, not an arrow: `this` is the namespace the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedKvMethod = (\n operation: KvOperation,\n original: KvMethod,\n): KvMethod => {\n const patched = function (\n this: unknown,\n ...args: unknown[]\n ): Promise<unknown> {\n const passThrough = (): Promise<unknown> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installKvPatch` is not called for a replay request, but the patch is\n // per-isolate: a record request could have installed it earlier, and no mock store serves\n // KV, so a replay request must reach the real namespace.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n // Only namespaces discovered on `env` are recorded — see `kvBindingNames`.\n const bindingName =\n receiver === undefined ? undefined : kvBindingNames.get(receiver);\n if (bindingName === undefined) {\n return passThrough();\n }\n\n return captureKvOperation(ctx, operation, bindingName, args, passThrough);\n };\n Object.defineProperty(patched, KV_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n return patched;\n};\n\n/**\n * Records an operation without affecting it: the real method runs untouched and its result is\n * returned as-is, with the event reported in the background via `ctx.waitUntil`. Any capture\n * failure is warned about once and swallowed — recording must never break the app.\n */\nconst captureKvOperation = async (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n invoke: () => Promise<unknown>,\n): Promise<unknown> => {\n const startTimeMs = Date.now();\n let result: unknown;\n try {\n result = await invoke();\n } catch (error) {\n report(ctx, operation, bindingName, args, startTimeMs, {\n error: String(error),\n });\n throw error;\n }\n report(ctx, operation, bindingName, args, startTimeMs, { result });\n return result;\n};\n\nconst report = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: { result: unknown } | { error: string },\n): void => {\n try {\n const event = buildKvEvent(\n ctx,\n operation,\n bindingName,\n args,\n startTimeMs,\n outcome,\n );\n // postCaptureEvents never rejects — it warns once on an unreachable sidecar.\n ctx.waitUntil(\n postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\"kv-report\", \"Failed to report a KV operation.\", error);\n }\n};\n\nconst buildKvEvent = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: { result: unknown } | { error: string },\n): KvOperationEvent => {\n const [key] = args;\n const written =\n operation === \"put\" ? serializeWrittenValue(args[1]) : undefined;\n const read =\n \"result\" in outcome ? serializeKvValue(outcome.result) : undefined;\n const omitted = written?.omitted ?? read?.omitted;\n const serializedArgs = serializeKvArgs(operation, args);\n\n return {\n kind: \"kv\",\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n bindingName,\n operation,\n ...(typeof key === \"string\" ? { key } : {}),\n ...(serializedArgs !== undefined ? { args: serializedArgs } : {}),\n ...(written?.captured !== undefined ? { value: written.captured } : {}),\n ...(read?.captured !== undefined ? { result: read.captured } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n startTimeMs,\n endTimeMs: Date.now(),\n ...(\"error\" in outcome ? { error: outcome.error } : {}),\n };\n};\n\ninterface SerializedKvValue {\n captured?: CapturedBody;\n omitted?: KvOmittedReason;\n}\n\n/**\n * JSON of a value workerd returned, with anything unpersistable replaced by `null` and the\n * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object,\n * which `JSON.stringify` would otherwise flatten to `{}`.\n *\n * Read values are stored verbatim, deliberately: a future replay would serve them straight\n * back to the app, so redacting one would corrupt what the app receives. Only the write path\n * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.\n */\nconst serializeKvValue = (value: unknown): SerializedKvValue => {\n let omitted: KvOmittedReason | undefined;\n try {\n const json = JSON.stringify(value, (_key: string, nested: unknown) => {\n const reason = omittedReason(nested);\n if (reason !== undefined) {\n omitted ??= reason;\n return null;\n }\n return nested instanceof Map\n ? Object.fromEntries(nested as Map<string, unknown>)\n : nested;\n });\n return {\n // `undefined` (what `put`/`delete` resolve to) has no JSON form — nothing to capture.\n ...(json !== undefined ? { captured: capture(json) } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n } catch {\n // Circular, or a getter that throws. The operation itself is still recorded.\n return {};\n }\n};\n\n/**\n * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an\n * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as\n * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are\n * caught (redacting the JSON-encoded form would only ever see one opaque string).\n */\nconst serializeWrittenValue = (value: unknown): SerializedKvValue => {\n if (typeof value === \"string\") {\n try {\n return { captured: capture(JSON.stringify(redactRequestBody(value))) };\n } catch {\n return {};\n }\n }\n return serializeKvValue(value);\n};\n\n/**\n * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so\n * the remaining arguments keep their positions — and so the value is only ever persisted via\n * the redacted {@link serializeWrittenValue} path.\n */\nconst serializeKvArgs = (\n operation: KvOperation,\n args: unknown[],\n): CapturedBody | undefined => {\n const withoutValue =\n operation === \"put\"\n ? args.map((arg, index) => (index === 1 ? null : arg))\n : args;\n return serializeKvValue(withoutValue).captured;\n};\n\nconst omittedReason = (value: unknown): KvOmittedReason | undefined => {\n // A stream is never read, only recognised: reading it would consume the app's bytes.\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n return \"stream\";\n }\n if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {\n return \"binary\";\n }\n return undefined;\n};\n\nconst capture = (json: string): CapturedBody =>\n json.length > MAX_BODY_CAPTURE_SIZE\n ? { body: json.slice(0, MAX_BODY_CAPTURE_SIZE), truncated: true }\n : { body: json, truncated: false };\n"],"names":[],"mappings":"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","debugId":"93bf09ea-4e78-5ac4-92cd-661f2ce03542"}
@@ -96,7 +96,65 @@ interface BindingRequestEvent extends BaseRequestEvent {
96
96
  requestBody?: CapturedBody;
97
97
  responseBody?: CapturedBody;
98
98
  }
99
- type CaptureEvent = InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent;
99
+ /** KV namespace methods the shim records. */
100
+ type KvOperation = "get" | "getWithMetadata" | "put" | "delete" | "list";
101
+ /**
102
+ * Why a KV value is absent from an event: workerd handed it over in a form that cannot be
103
+ * persisted as text. A `"stream"` value must not even be read — consuming it would take the
104
+ * bytes away from the app — and binary values are skipped deliberately, since KV blobs are
105
+ * large and would both bloat the recording and be mangled by UTF-8 capture.
106
+ */
107
+ type KvOmittedReason = "binary" | "stream";
108
+ /**
109
+ * An operation on a Cloudflare KV namespace binding, made while handling an inbound request
110
+ * (becomes a CLIENT span).
111
+ *
112
+ * KV is not HTTP: there is no method, URL or status code, so this event shares nothing with
113
+ * {@link BaseRequestEvent} beyond correlation and timing. Values are JSON so that a single
114
+ * `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for.
115
+ */
116
+ interface KvOperationEvent {
117
+ kind: "kv";
118
+ /** Correlates the operation to the inbound request being handled. */
119
+ requestId: string;
120
+ frontendSessionId?: string;
121
+ /**
122
+ * The `env` key the KV namespace was found under. Always present, unlike on a binding
123
+ * event: a namespace has no factory equivalent to `DurableObjectNamespace.get()`, so one
124
+ * that was never seen on `env` is not recorded at all.
125
+ */
126
+ bindingName: string;
127
+ operation: KvOperation;
128
+ /**
129
+ * The key operated on, when it is a single string. Absent for `list` (which spans keys) and
130
+ * for a bulk `get` (which takes an array) — both keep their arguments in {@link args}.
131
+ */
132
+ key?: string;
133
+ /**
134
+ * JSON of the call's arguments: the read `type`, the `put` options, the `list` selector.
135
+ * A `put` value appears as `null` here — it is captured in {@link value} instead, so that
136
+ * redaction applies to it.
137
+ */
138
+ args?: CapturedBody;
139
+ /** JSON of the value a `put` wrote, with secret-looking fields redacted. */
140
+ value?: CapturedBody;
141
+ /**
142
+ * JSON of what the operation returned: the value for `get`, `{value, metadata}` for
143
+ * `getWithMetadata`, the key page for `list`. Absent for `put`/`delete`, which return
144
+ * nothing, and for a value that could not be captured (see {@link omitted}).
145
+ */
146
+ result?: CapturedBody;
147
+ /**
148
+ * Why the operation's value is missing. Refers to the written value for `put` and to the
149
+ * read value otherwise — a `put` has no result, so this is never ambiguous.
150
+ */
151
+ omitted?: KvOmittedReason;
152
+ startTimeMs: number;
153
+ endTimeMs: number;
154
+ /** Set when the operation threw. */
155
+ error?: string;
156
+ }
157
+ type CaptureEvent = InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent | KvOperationEvent;
100
158
  interface CaptureEventsPayload {
101
159
  events: CaptureEvent[];
102
160
  }
@@ -139,5 +197,5 @@ type OutboundFetchLookupResponse = {
139
197
  outcome: "passthrough";
140
198
  };
141
199
  //#endregion
142
- export { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundFetchLookupRequest, OutboundFetchLookupResponse, OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
200
+ export { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, KvOmittedReason, KvOperation, KvOperationEvent, OutboundFetchLookupRequest, OutboundFetchLookupResponse, OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
143
201
  //# sourceMappingURL=protocol.d.ts.map
package/dist/protocol.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * is the single source of truth for the shape of capture events.
6
6
  */
7
7
 
8
- !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]="58075d9c-7ce8-5f51-94e8-b073da0ecb33")}catch(e){}}();
8
+ !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]="532e3e0d-6b1c-51c2-8086-a05de2625e79")}catch(e){}}();
9
9
  const SIDECAR_PROTOCOL_VERSION = "1";
10
10
  /** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */
11
11
  const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
@@ -43,4 +43,4 @@ const CAPTURED_HEADERS = ["content-type", FRONTEND_SESSION_ID_HEADER];
43
43
  export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, REPLAY_SIDECAR_URL_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
44
44
 
45
45
  //# sourceMappingURL=protocol.js.map
46
- //# debugId=58075d9c-7ce8-5f51-94e8-b073da0ecb33
46
+ //# debugId=532e3e0d-6b1c-51c2-8086-a05de2625e79
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","sources":["../src/protocol.ts"],"sourcesContent":["/**\n * Wire protocol between the in-worker shim and the Meticulous backend\n * recorder sidecar. The sidecar imports these types (type-only), so this file\n * is the single source of truth for the shape of capture events.\n */\n\nexport const SIDECAR_PROTOCOL_VERSION = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * Replay routes. A sidecar started in record mode answers 404 on both, which the shim\n * treats as \"replay unavailable\" and falls back to plain pass-through — so a new shim\n * keeps working against an older, record-only sidecar.\n */\nexport const SIDECAR_REPLAY_SESSION_PATH = \"/v1/replay/session\";\nexport const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = \"/v1/replay/outbound-fetch\";\n\n/**\n * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner\n * on requests to the app under test. Workerd cannot see container environment variables,\n * so an inbound header is the only way per-replay config can reach the shim.\n *\n * The shim validates the value before using it and only honours a loopback /\n * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.\n */\nexport const REPLAY_SIDECAR_URL_HEADER =\n \"x-meticulous-backend-replay-sidecar-url\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/**\n * A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a\n * Durable Object stub — made while handling an inbound request (becomes a CLIENT span).\n *\n * Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the\n * call never leaves the isolate and its URL is whatever the caller invented. Keeping the two\n * apart means a binding call and real egress can never be confused for one another when\n * these spans are eventually mocked.\n */\nexport interface BindingRequestEvent extends BaseRequestEvent {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Absent when the instance was never seen on\n * `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather\n * than being a binding in its own right.\n */\n bindingName?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\nexport type CaptureEvent =\n | InboundRequestEvent\n | OutboundRequestEvent\n | BindingRequestEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n\n/**\n * Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds\n * mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.\n */\nexport interface ReplaySessionInfoResponse {\n found: boolean;\n /**\n * Epoch ms of the session's last recorded activity. The worker freezes its clock here so\n * that credentials minted during the recording are already issued but not yet expired.\n */\n clockAnchorMs?: number;\n}\n\n/** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */\nexport interface OutboundFetchLookupRequest {\n frontendSessionId: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /**\n * Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the\n * hash it compares against a recording is derived from byte-identical input on both the\n * record and replay sides.\n */\n requestBody?: CapturedBody;\n}\n\n/**\n * Either a recorded response to serve, or an instruction to let the real call through.\n * An unrecognised `outcome` must be treated as `passthrough`, so an older shim keeps\n * working against a newer sidecar.\n */\nexport type OutboundFetchLookupResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n | { outcome: \"passthrough\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;AASF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"58075d9c-7ce8-5f51-94e8-b073da0ecb33"}
1
+ {"version":3,"file":"protocol.js","sources":["../src/protocol.ts"],"sourcesContent":["/**\n * Wire protocol between the in-worker shim and the Meticulous backend\n * recorder sidecar. The sidecar imports these types (type-only), so this file\n * is the single source of truth for the shape of capture events.\n */\n\nexport const SIDECAR_PROTOCOL_VERSION = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * Replay routes. A sidecar started in record mode answers 404 on both, which the shim\n * treats as \"replay unavailable\" and falls back to plain pass-through — so a new shim\n * keeps working against an older, record-only sidecar.\n */\nexport const SIDECAR_REPLAY_SESSION_PATH = \"/v1/replay/session\";\nexport const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = \"/v1/replay/outbound-fetch\";\n\n/**\n * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner\n * on requests to the app under test. Workerd cannot see container environment variables,\n * so an inbound header is the only way per-replay config can reach the shim.\n *\n * The shim validates the value before using it and only honours a loopback /\n * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.\n */\nexport const REPLAY_SIDECAR_URL_HEADER =\n \"x-meticulous-backend-replay-sidecar-url\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/**\n * A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a\n * Durable Object stub — made while handling an inbound request (becomes a CLIENT span).\n *\n * Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the\n * call never leaves the isolate and its URL is whatever the caller invented. Keeping the two\n * apart means a binding call and real egress can never be confused for one another when\n * these spans are eventually mocked.\n */\nexport interface BindingRequestEvent extends BaseRequestEvent {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Absent when the instance was never seen on\n * `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather\n * than being a binding in its own right.\n */\n bindingName?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/** KV namespace methods the shim records. */\nexport type KvOperation = \"get\" | \"getWithMetadata\" | \"put\" | \"delete\" | \"list\";\n\n/**\n * Why a KV value is absent from an event: workerd handed it over in a form that cannot be\n * persisted as text. A `\"stream\"` value must not even be read — consuming it would take the\n * bytes away from the app — and binary values are skipped deliberately, since KV blobs are\n * large and would both bloat the recording and be mangled by UTF-8 capture.\n */\nexport type KvOmittedReason = \"binary\" | \"stream\";\n\n/**\n * An operation on a Cloudflare KV namespace binding, made while handling an inbound request\n * (becomes a CLIENT span).\n *\n * KV is not HTTP: there is no method, URL or status code, so this event shares nothing with\n * {@link BaseRequestEvent} beyond correlation and timing. Values are JSON so that a single\n * `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for.\n */\nexport interface KvOperationEvent {\n kind: \"kv\";\n /** Correlates the operation to the inbound request being handled. */\n requestId: string;\n frontendSessionId?: string;\n /**\n * The `env` key the KV namespace was found under. Always present, unlike on a binding\n * event: a namespace has no factory equivalent to `DurableObjectNamespace.get()`, so one\n * that was never seen on `env` is not recorded at all.\n */\n bindingName: string;\n operation: KvOperation;\n /**\n * The key operated on, when it is a single string. Absent for `list` (which spans keys) and\n * for a bulk `get` (which takes an array) — both keep their arguments in {@link args}.\n */\n key?: string;\n /**\n * JSON of the call's arguments: the read `type`, the `put` options, the `list` selector.\n * A `put` value appears as `null` here — it is captured in {@link value} instead, so that\n * redaction applies to it.\n */\n args?: CapturedBody;\n /** JSON of the value a `put` wrote, with secret-looking fields redacted. */\n value?: CapturedBody;\n /**\n * JSON of what the operation returned: the value for `get`, `{value, metadata}` for\n * `getWithMetadata`, the key page for `list`. Absent for `put`/`delete`, which return\n * nothing, and for a value that could not be captured (see {@link omitted}).\n */\n result?: CapturedBody;\n /**\n * Why the operation's value is missing. Refers to the written value for `put` and to the\n * read value otherwise — a `put` has no result, so this is never ambiguous.\n */\n omitted?: KvOmittedReason;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the operation threw. */\n error?: string;\n}\n\nexport type CaptureEvent =\n | InboundRequestEvent\n | OutboundRequestEvent\n | BindingRequestEvent\n | KvOperationEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n\n/**\n * Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds\n * mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.\n */\nexport interface ReplaySessionInfoResponse {\n found: boolean;\n /**\n * Epoch ms of the session's last recorded activity. The worker freezes its clock here so\n * that credentials minted during the recording are already issued but not yet expired.\n */\n clockAnchorMs?: number;\n}\n\n/** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */\nexport interface OutboundFetchLookupRequest {\n frontendSessionId: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /**\n * Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the\n * hash it compares against a recording is derived from byte-identical input on both the\n * record and replay sides.\n */\n requestBody?: CapturedBody;\n}\n\n/**\n * Either a recorded response to serve, or an instruction to let the real call through.\n * An unrecognised `outcome` must be treated as `passthrough`, so an older shim keeps\n * working against a newer sidecar.\n */\nexport type OutboundFetchLookupResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n | { outcome: \"passthrough\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;AASF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"532e3e0d-6b1c-51c2-8086-a05de2625e79"}
@@ -0,0 +1,238 @@
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]="3821aa29-b3ba-551f-957d-45a05bdc02bf")}catch(e){}}();
3
+ import { requestCaptureContext } from "./context.js";
4
+ import { warnOnce } from "./log.js";
5
+ //#region src/virtual-random.ts
6
+ /**
7
+ * Per-session randomness virtualization for replay mode.
8
+ *
9
+ * Mocking every outbound `fetch` is not enough to make a replay deterministic: a Worker that
10
+ * mints an id (`crypto.randomUUID()` for a guest id, `getRandomValues` for a token) produces
11
+ * a different value in the base replay than in the head replay, so the rendered HTML differs
12
+ * and any screenshot showing that value diffs forever. This replaces the worker's sources of
13
+ * randomness with seeded generators while serving a replayed request, so two replays of the
14
+ * same recorded session render identically.
15
+ *
16
+ * Deliberately a *generator*, not a recording: like the browser replayer we do not capture
17
+ * the numbers the original session produced, we produce fresh ones from a fixed seed. The
18
+ * app still sees plausible random values, and two replays see the same ones.
19
+ *
20
+ * Ported from the Node backend recorder's `replay/virtual-random.ts`, which in turn mirrors
21
+ * the browser replayer's `mockOutRandomNumberGeneration`, including its butterfly-effect
22
+ * containment: a separate sequence per call stack, seeded from a per-call-site counter, so a
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. Two differences from
25
+ * the Node version, both forced by the runtime:
26
+ *
27
+ * - the session comes from the request context rather than a session resolver with a
28
+ * process-wide fallback, because workerd's AsyncLocalStorage propagates reliably from the
29
+ * inbound handler;
30
+ * - only the globals workerd actually has are patched — there is no `node:crypto` module to
31
+ * mutate.
32
+ *
33
+ * Reads outside a replayed request — module evaluation, a recording, a deployed production
34
+ * worker — fall through to the native generators untouched.
35
+ */
36
+ const nativeMathRandom = Math.random.bind(Math);
37
+ const nativeRandomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
38
+ const nativeGetRandomValues = globalThis.crypto?.getRandomValues?.bind(globalThis.crypto);
39
+ /**
40
+ * Frames of the stack that belong to this module and must be skipped to reach the app's call
41
+ * site, counted from the `new Error()` inside {@link getCallStack}.
42
+ *
43
+ * Getting this wrong degrades gracefully rather than breaking: a call site that resolves to
44
+ * one of our own frames merges sequences that should have been separate, which costs
45
+ * butterfly-effect isolation but is still perfectly deterministic.
46
+ */
47
+ const FRAMES_TO_APP = 3;
48
+ /** How much of the stack identifies a sequence. Ten frames tells call paths apart cheaply. */
49
+ const STACK_FRAMES = 10;
50
+ /** Stack captures per session, after which sequences are keyed by call site alone. */
51
+ 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
+ const UNKNOWN_CALL_SITE = "unknown";
55
+ const stateBySession = /* @__PURE__ */ new Map();
56
+ /**
57
+ * Alea, the same algorithm the browser replayer gets from `seedrandom`. Inlined because this
58
+ * package is published to npm and ships no runtime dependencies — a customer's Worker bundle
59
+ * should not grow for this.
60
+ */
61
+ const createSeededRandom = (seed) => {
62
+ let n = 4022871197;
63
+ const mash = (data) => {
64
+ for (let i = 0; i < data.length; i++) {
65
+ n += data.charCodeAt(i);
66
+ let h = .02519603282416938 * n;
67
+ n = h >>> 0;
68
+ h -= n;
69
+ h *= n;
70
+ n = h >>> 0;
71
+ h -= n;
72
+ n += h * 4294967296;
73
+ }
74
+ return (n >>> 0) * 23283064365386963e-26;
75
+ };
76
+ let s0 = mash(" ");
77
+ let s1 = mash(" ");
78
+ let s2 = mash(" ");
79
+ let c = 1;
80
+ s0 -= mash(seed);
81
+ if (s0 < 0) s0 += 1;
82
+ s1 -= mash(seed);
83
+ if (s1 < 0) s1 += 1;
84
+ s2 -= mash(seed);
85
+ if (s2 < 0) s2 += 1;
86
+ return () => {
87
+ const t = 2091639 * s0 + c * 23283064365386963e-26;
88
+ s0 = s1;
89
+ s1 = s2;
90
+ c = t | 0;
91
+ s2 = t - c;
92
+ return s2;
93
+ };
94
+ };
95
+ const getCallStack = () => {
96
+ const originalLimit = Error.stackTraceLimit;
97
+ try {
98
+ Error.stackTraceLimit = FRAMES_TO_APP + STACK_FRAMES;
99
+ const stack = (/* @__PURE__ */ new Error()).stack;
100
+ if (stack == null) return {
101
+ stack: UNKNOWN_CALL_SITE,
102
+ site: UNKNOWN_CALL_SITE
103
+ };
104
+ const lines = stack.split("\n").slice(FRAMES_TO_APP + 1);
105
+ return {
106
+ stack: lines.join("\n") || UNKNOWN_CALL_SITE,
107
+ site: lines[0] ?? UNKNOWN_CALL_SITE
108
+ };
109
+ } catch {
110
+ return {
111
+ stack: UNKNOWN_CALL_SITE,
112
+ site: UNKNOWN_CALL_SITE
113
+ };
114
+ } finally {
115
+ Error.stackTraceLimit = originalLimit;
116
+ }
117
+ };
118
+ const getSessionState = () => {
119
+ const ctx = requestCaptureContext.getStore();
120
+ if (ctx === void 0 || ctx.mode !== "replay") return;
121
+ const existing = stateBySession.get(ctx.frontendSessionId);
122
+ 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
+ const created = {
128
+ frontendSessionId: ctx.frontendSessionId,
129
+ generatorBySequenceKey: /* @__PURE__ */ new Map(),
130
+ nextCallerIdByCallSite: /* @__PURE__ */ new Map(),
131
+ stackCaptures: 0
132
+ };
133
+ stateBySession.set(ctx.frontendSessionId, created);
134
+ return created;
135
+ };
136
+ /**
137
+ * The generator for the sequence the current call belongs to, or undefined outside a
138
+ * replayed request.
139
+ *
140
+ * Sequences are keyed by the whole call stack, so a second call down the same path continues
141
+ * the same sequence and gets a different value. The *seed* comes from a counter kept per
142
+ * immediate call site, which is what keeps a shared id helper honest: two call paths through
143
+ * the same `generateId()` share a call site, so they are seeded 0 and 1 and return different
144
+ * ids.
145
+ *
146
+ * Note what the seed deliberately does NOT contain: any part of the stack. Base and head are
147
+ * different builds of the app, so the same logical call site has a different string in each —
148
+ * a seed built from it would change on every rebuild. The consequence, which the browser
149
+ * replayer shares, is that two *distinct* call sites each seen for the first time both seed at
150
+ * 0 and hand out the same first value. That is the accepted trade: an alternative that
151
+ * separated them (say, an ordinal per call site) would shift every later site's values as soon
152
+ * as a PR added one randomness call, which is the butterfly effect this whole scheme exists to
153
+ * avoid.
154
+ */
155
+ const openSequence = () => {
156
+ const state = getSessionState();
157
+ if (state === void 0) return;
158
+ let sequenceKey = UNKNOWN_CALL_SITE;
159
+ let callSite = UNKNOWN_CALL_SITE;
160
+ if (state.stackCaptures < MAX_STACK_CAPTURES) {
161
+ state.stackCaptures++;
162
+ const captured = getCallStack();
163
+ sequenceKey = captured.stack;
164
+ callSite = captured.site;
165
+ }
166
+ const existing = state.generatorBySequenceKey.get(sequenceKey);
167
+ if (existing !== void 0) return existing;
168
+ const callerId = state.nextCallerIdByCallSite.get(callSite) ?? 0;
169
+ const generator = createSeededRandom(`meticulous-random-${state.frontendSessionId}-${callerId}`);
170
+ state.generatorBySequenceKey.set(sequenceKey, generator);
171
+ state.nextCallerIdByCallSite.set(callSite, callerId + 1);
172
+ return generator;
173
+ };
174
+ const HEX = "0123456789abcdef";
175
+ /** The two bits of a v4 UUID's variant field are fixed, leaving these four values. */
176
+ const VARIANT = "89ab";
177
+ const buildUuid = (draw) => {
178
+ let uuid = "";
179
+ for (let i = 0; i < 36; i++) if (i === 8 || i === 13 || i === 18 || i === 23) uuid += "-";
180
+ else if (i === 14) uuid += "4";
181
+ else if (i === 19) uuid += VARIANT[Math.floor(draw() * 4)];
182
+ else uuid += HEX[Math.floor(draw() * 16)];
183
+ return uuid;
184
+ };
185
+ /**
186
+ * Fills the view's bytes rather than its elements, so every typed array — including the
187
+ * BigInt ones, whose elements cannot be assigned a number — is handled by one path.
188
+ */
189
+ const fillBytes = (view, draw) => {
190
+ const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
191
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(draw() * 256);
192
+ };
193
+ let installed = false;
194
+ /**
195
+ * Replaces the worker's sources of randomness with per-session seeded generators. Idempotent,
196
+ * and a no-op failure: a worker that will not let them be replaced still replays, just
197
+ * without randomness virtualisation.
198
+ *
199
+ * Called lazily from the replay branch of the request wrapper rather than at module scope,
200
+ * for the same reasons as the virtual clock: record mode and deployed production workers then
201
+ * pay nothing, and the package declares no `sideEffects`, so a module-scope side effect could
202
+ * legitimately be tree-shaken away by a customer's bundler.
203
+ */
204
+ const installVirtualRandom = () => {
205
+ if (installed) return;
206
+ installed = true;
207
+ try {
208
+ Math.random = () => openSequence()?.() ?? nativeMathRandom();
209
+ if (globalThis.crypto != null) {
210
+ Object.defineProperty(globalThis.crypto, "randomUUID", {
211
+ value: () => {
212
+ const draw = openSequence();
213
+ if (draw !== void 0) return buildUuid(draw);
214
+ return nativeRandomUUID?.() ?? buildUuid(nativeMathRandom);
215
+ },
216
+ configurable: true,
217
+ writable: true
218
+ });
219
+ Object.defineProperty(globalThis.crypto, "getRandomValues", {
220
+ value: (array) => {
221
+ const draw = openSequence();
222
+ if (draw === void 0) return nativeGetRandomValues?.(array) ?? array;
223
+ fillBytes(array, draw);
224
+ return array;
225
+ },
226
+ configurable: true,
227
+ writable: true
228
+ });
229
+ }
230
+ } catch (error) {
231
+ warnOnce("virtual-random", "Could not install the Meticulous replay random-number generators — ids minted by the app will differ between replays.", error);
232
+ }
233
+ };
234
+ //#endregion
235
+ export { installVirtualRandom };
236
+
237
+ //# sourceMappingURL=virtual-random.js.map
238
+ //# debugId=3821aa29-b3ba-551f-957d-45a05bdc02bf
@@ -0,0 +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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alwaysmeticulous/backend-recorder-workerd",
3
- "version": "2.319.0",
3
+ "version": "2.321.0",
4
4
  "type": "module",
5
5
  "description": "Meticulous backend recorder shim for Cloudflare Workers (workerd). Reports inbound and outbound HTTP capture events to a local Meticulous recorder sidecar during development recording.",
6
6
  "license": "ISC",