@alwaysmeticulous/backend-recorder-workerd 2.324.0 → 2.326.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.
Files changed (65) hide show
  1. package/README.md +94 -4
  2. package/dist/binding-patch.js +17 -8
  3. package/dist/binding-patch.js.map +1 -1
  4. package/dist/capture-buffer.js +114 -0
  5. package/dist/capture-buffer.js.map +1 -0
  6. package/dist/context.js +6 -3
  7. package/dist/context.js.map +1 -1
  8. package/dist/error-capture.d.ts +44 -0
  9. package/dist/error-capture.js +65 -0
  10. package/dist/error-capture.js.map +1 -0
  11. package/dist/fetch-patch.js +5 -5
  12. package/dist/fetch-patch.js.map +1 -1
  13. package/dist/index.d.ts +63 -39
  14. package/dist/index.js +0 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/kv-capture.d.ts +39 -0
  17. package/dist/kv-capture.js +92 -0
  18. package/dist/kv-capture.js.map +1 -0
  19. package/dist/kv-patch.js +21 -94
  20. package/dist/kv-patch.js.map +1 -1
  21. package/dist/outbound-capture.js +20 -28
  22. package/dist/outbound-capture.js.map +1 -1
  23. package/dist/pages-function.d.ts +33 -0
  24. package/dist/pages-function.js +28 -0
  25. package/dist/pages-function.js.map +1 -0
  26. package/dist/postgres/capture.d.ts +108 -0
  27. package/dist/postgres/capture.js +136 -0
  28. package/dist/postgres/capture.js.map +1 -0
  29. package/dist/postgres/with-meticulous-postgres.d.ts +32 -0
  30. package/dist/postgres/with-meticulous-postgres.js +152 -0
  31. package/dist/postgres/with-meticulous-postgres.js.map +1 -0
  32. package/dist/protocol.d.ts +76 -11
  33. package/dist/protocol.js +12 -7
  34. package/dist/protocol.js.map +1 -1
  35. package/dist/provisional-session-id.d.ts +58 -0
  36. package/dist/provisional-session-id.js +75 -0
  37. package/dist/provisional-session-id.js.map +1 -0
  38. package/dist/publish-session-id.js +41 -0
  39. package/dist/publish-session-id.js.map +1 -0
  40. package/dist/replay-fetch.js +4 -3
  41. package/dist/replay-fetch.js.map +1 -1
  42. package/dist/sidecar-client.js +29 -18
  43. package/dist/sidecar-client.js.map +1 -1
  44. package/dist/sidecar-transport.d.ts +38 -0
  45. package/dist/sidecar-transport.js +52 -0
  46. package/dist/sidecar-transport.js.map +1 -0
  47. package/dist/spans/attributes.d.ts +84 -0
  48. package/dist/spans/attributes.js +88 -0
  49. package/dist/spans/attributes.js.map +1 -0
  50. package/dist/spans/readable-span.d.ts +78 -0
  51. package/dist/spans/readable-span.js +35 -0
  52. package/dist/spans/readable-span.js.map +1 -0
  53. package/dist/spans/serialize-span.d.ts +14 -0
  54. package/dist/spans/serialize-span.js +38 -0
  55. package/dist/spans/serialize-span.js.map +1 -0
  56. package/dist/spans/span-builder.d.ts +61 -0
  57. package/dist/spans/span-builder.js +250 -0
  58. package/dist/spans/span-builder.js.map +1 -0
  59. package/dist/spans/url-redaction.d.ts +14 -0
  60. package/dist/spans/url-redaction.js +40 -0
  61. package/dist/spans/url-redaction.js.map +1 -0
  62. package/dist/with-meticulous.d.ts +32 -0
  63. package/dist/with-meticulous.js +0 -0
  64. package/dist/with-meticulous.js.map +1 -0
  65. package/package.json +2 -1
package/README.md CHANGED
@@ -1,10 +1,17 @@
1
1
  # Meticulous Backend Recorder for Cloudflare Workers (workerd)
2
2
 
3
- In-worker shim for recording backend sessions from Cloudflare Workers during **local development** (`wrangler dev`). It reports the worker's inbound requests and outgoing `fetch` calls (including request/response bodies, capped at 256 KB) as capture events to a local Meticulous recorder sidecar process, which turns them into backend session recordings.
3
+ In-worker shim for recording backend sessions from Cloudflare Workers — during local development (`wrangler dev`), and from a **deployed** Worker or Pages project. It reports the worker's inbound requests, outgoing `fetch` calls (including request/response bodies, capped at 256 KB), binding and KV calls and postgres.js queries as capture events to a Meticulous recorder sidecar, which turns them into backend session recordings.
4
4
 
5
5
  The shim is passive: outgoing requests go straight to their real destination; the sidecar is never in the request path, and an unreachable sidecar or any capture failure never affects your app.
6
6
 
7
- ## Setup
7
+ Where the sidecar lives is the only difference between the two setups:
8
+
9
+ | | Sidecar | Configured by |
10
+ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
11
+ | `wrangler dev` | a local process (`npx @alwaysmeticulous/cli record backend`) | the `METICULOUS_SIDECAR_URL` var |
12
+ | deployed (`wrangler deploy`, `wrangler pages deploy`, previews) | [`@alwaysmeticulous/backend-recorder-sidecar-worker`](https://www.npmjs.com/package/@alwaysmeticulous/backend-recorder-sidecar-worker), deployed into your own Cloudflare account | a `METICULOUS_SIDECAR` service binding |
13
+
14
+ ## Setup for local development
8
15
 
9
16
  1. Install the shim:
10
17
 
@@ -46,14 +53,95 @@ The shim is passive: outgoing requests go straight to their real destination; th
46
53
 
47
54
  or `wrangler dev --var METICULOUS_SIDECAR_URL:http://127.0.0.1:9670`.
48
55
 
49
- Without `METICULOUS_SIDECAR_URL` (or `options.sidecarUrl`) the wrapper is a complete pass-through, so it is safe to leave in code that gets deployed.
56
+ Without a sidecar configured the wrapper is a complete pass-through, so it is safe to leave in code that gets deployed.
57
+
58
+ ## Setup for a deployed Worker or Pages project
59
+
60
+ 1. Deploy the sidecar into your own Cloudflare account, following the [`@alwaysmeticulous/backend-recorder-sidecar-worker`](https://www.npmjs.com/package/@alwaysmeticulous/backend-recorder-sidecar-worker) README. It is one file and one `wrangler deploy`.
61
+
62
+ 2. Add a service binding to your app, in every environment you want recorded:
63
+
64
+ ```toml
65
+ [[services]]
66
+ binding = "METICULOUS_SIDECAR"
67
+ service = "meticulous-recorder-sidecar"
68
+ ```
69
+
70
+ 3. Wrap your handler. For an ES-module Worker that is `withMeticulous`, as above. For a **Cloudflare Pages** project, whose worker exports `onRequest` rather than `{ fetch }`, use `withMeticulousPagesFunction`:
71
+
72
+ ```ts
73
+ import { withMeticulousPagesFunction } from "@alwaysmeticulous/backend-recorder-workerd";
74
+
75
+ export const onRequest = withMeticulousPagesFunction(handler);
76
+ ```
77
+
78
+ Apply it **outermost**, before any wrapper of your own that re-writes `context.env`: the recorder discovers bindings on the `env` it is handed, so a wrapper substituting its own façades first would hide the real instances.
79
+
80
+ 4. If your app queries Postgres — over Hyperdrive, say — hand us the client so its queries are recorded too. There is no seam to patch automatically in a Worker: `postgres` was inlined into your bundle at build time and never passes through a module loader.
81
+
82
+ ```ts
83
+ import { withMeticulousPostgres } from "@alwaysmeticulous/backend-recorder-workerd";
84
+
85
+ const sql = withMeticulousPostgres(
86
+ postgres(env.HYPERDRIVE.connectionString),
87
+ );
88
+ ```
89
+
90
+ One call covers every client in the isolate, and it returns the client unchanged. Apply it outermost if something else also wraps the client (Sentry's `instrumentPostgresJsSql`, a logging proxy).
91
+
92
+ Nothing needs to change between environments: the wrapper activates on whichever sidecar it finds, and a deployment with neither configured is a pass-through. A binding wins over a `METICULOUS_SIDECAR_URL` var when both are present, since only the binding can have been added to that deployment's wrangler configuration deliberately.
93
+
94
+ A request's capture events are batched into **one** report, sent under `ctx.waitUntil` after your response has gone out — so an SSR request making twenty calls costs one round trip, not twenty.
50
95
 
51
96
  ## What is captured
52
97
 
53
- - **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.
98
+ - **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 — or, for the page's initial navigation, via an id the shim mints itself (see [Session ids for the first page render](#session-ids-for-the-first-page-render)).
54
99
  - **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).
55
100
  - **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.
56
101
  - **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.
102
+ - **postgres.js queries**, when you apply `withMeticulousPostgres` (see above): the SQL with `$1`-style placeholders, the interpolated parameters, the row shape the query asked for, and the resolved rows — or the error, so a query that legitimately failed replays as the same failure rather than as a gap. Cursors, `.forEach()`, COPY streams and `.describe()` pass through unrecorded, since they do not resolve with a single result.
103
+
104
+ ## Session ids for the first page render
105
+
106
+ A browser cannot put a custom header on a top-level navigation, and on the navigation that starts a session the frontend recorder has not minted an id yet — it does that only once the HTML has arrived and the snippet has run. So the request that produces your server-side render is the one request that can never arrive tagged, and everything it does (the render's own `fetch` calls, its binding and KV reads) would be recorded against no session at all.
107
+
108
+ The shim closes that gap from the other end: for such a request it **mints the session id itself**, records the whole request under it, and publishes it to the page, which adopts it instead of minting its own. The entire page load then sits under one session id.
109
+
110
+ **When it mints.** Only for what is plausibly a browser navigating to a page: a `GET`/`HEAD` with `Sec-Fetch-Dest: document` (or, absent that header, an `Accept` containing `text/html`) and no inbound `x-meticulous-session-id`. An in-page `fetch`, an RSC navigation, an iframe, a health check and a crawler all decline. It only ever happens while recording, so a deployed worker with no sidecar configured is untouched.
111
+
112
+ **How the page learns it.** Two channels, either sufficient:
113
+
114
+ 1. `Server-Timing: metsession;desc="<id>"` on the response. The recorder reads it from `performance.getEntriesByType("navigation")[0].serverTiming` — the one response header a document's own script can read back, and it persists for the document's lifetime, so this works however late the snippet loads. **No app change needed.** Restricted to secure contexts, and a cache or proxy may strip it.
115
+ 2. Your app renders it, via `getMeticulousSessionId()` — typically as `data-session-id` on the recorder's own `<script>` tag (`window.METICULOUS_SESSION_ID` also works). Exact and immune to header caching; costs one line.
116
+
117
+ ```ts
118
+ import {
119
+ getMeticulousSessionId,
120
+ withMeticulous,
121
+ } from "@alwaysmeticulous/backend-recorder-workerd";
122
+
123
+ export default withMeticulous({
124
+ async fetch() {
125
+ const sessionId = getMeticulousSessionId();
126
+ return new Response(
127
+ `<script src="/recorder.js" data-session-id="${escapeHtml(sessionId ?? "")}"></script>`,
128
+ { headers: { "content-type": "text/html" } },
129
+ );
130
+ },
131
+ });
132
+ ```
133
+
134
+ Escape it as you would any other value you interpolate into markup: on a document navigation it is our own minted id, but on any other request it is the inbound header verbatim.
135
+
136
+ Either way the recorder adopts an id only while its mint time is **within 60s of now, in either direction** (session ids embed their mint time). That bound is what stops a response cached at a CDN or by the browser from handing one session id to every visitor who receives it. Within the window that sharing is still possible, so **an app whose HTML is cacheable is not yet a good candidate** — turn minting off there:
137
+
138
+ ```ts
139
+ export default withMeticulous(handler, { mintProvisionalSessionIds: false });
140
+ ```
141
+
142
+ or set the `METICULOUS_BACKEND_PROVISIONAL_SESSION_IDS` var to `"false"` (a worker var/binding, like `METICULOUS_SIDECAR_URL` — host environment variables are not visible inside workerd).
143
+
144
+ `getMeticulousSessionId()` is also how the id reaches another process. A document navigation has no inbound header for your app to forward, so a worker that fans out to its own services has to pass the id on as `x-meticulous-session-id` itself; the shim does not add it to outbound requests.
57
145
 
58
146
  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.
59
147
 
@@ -71,6 +159,7 @@ Calls through bindings and KV namespaces are not served from the recording (see
71
159
 
72
160
  ## Limitations
73
161
 
162
+ - Streamed responses are captured truncated: the inbound request is reported once the handler returns, which for a streamed response is before the stream ends, and outbound bodies cap at 256 KB. An app whose main endpoints are SSE will not record them faithfully.
74
163
  - 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.
75
164
  - 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.
76
165
  - 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.
@@ -78,3 +167,4 @@ Calls through bindings and KV namespaces are not served from the recording (see
78
167
  - 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.
79
168
  - WebSocket upgrades and TCP sockets (`connect()`) are not captured.
80
169
  - `fetch` calls made outside a request handled by `withMeticulous` (e.g. in `scheduled` handlers) are not captured.
170
+ - postgres.js queries are recorded but **not replayed inside workerd**: a recording made in a Worker is served back by the Node recorder's mock store when the app under replay runs on Node. A workerd replay reaches the real database.
@@ -1,6 +1,7 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="70c459b2-f813-5c06-8d88-9bd810926724")}catch(e){}}();
3
- import { requestCaptureContext } from "./context.js";
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]="233eef03-f460-5f72-ac30-1337fa3d62e8")}catch(e){}}();
3
+ import { SIDECAR_BINDING_ENV_KEY } from "./sidecar-transport.js";
4
+ import { requestCaptureContext, sidecarOriginOf } from "./context.js";
4
5
  import { warnOnce } from "./log.js";
5
6
  import { captureOutboundCall } from "./outbound-capture.js";
6
7
  //#region src/binding-patch.ts
@@ -33,8 +34,15 @@ const BINDING_FETCH_PATCHED = Symbol.for("meticulous.workerd.bindingPatched");
33
34
  * so it cannot be told apart by duck-typing and has to be skipped by name. Recording it would
34
35
  * push every static asset through the body capture cap for no benefit: asset bytes are large
35
36
  * and often binary, and asset serving is typically the highest-volume call a worker makes.
37
+ *
38
+ * The sidecar binding is here as belt-and-braces only: the wrapper also passes its resolved
39
+ * instance as a `skipInstances` entry, which holds however the customer named it.
36
40
  */
37
- const DEFAULT_SKIPPED_BINDINGS = ["ASSETS", "__STATIC_CONTENT"];
41
+ const DEFAULT_SKIPPED_BINDINGS = [
42
+ "ASSETS",
43
+ "__STATIC_CONTENT",
44
+ SIDECAR_BINDING_ENV_KEY
45
+ ];
38
46
  /** Identifies a binding instance so its calls can be attributed to an `env` key. */
39
47
  const bindingNames = /* @__PURE__ */ new WeakMap();
40
48
  const skippedBindings = /* @__PURE__ */ new WeakSet();
@@ -47,7 +55,7 @@ const skippedBindings = /* @__PURE__ */ new WeakSet();
47
55
  */
48
56
  const installBindingPatch = (env, options) => {
49
57
  try {
50
- for (const { name, value, skip } of fetcherBindings(env, options?.skipBindings)) {
58
+ for (const { name, value, skip } of fetcherBindings(env, options?.skipBindings, options?.skipInstances)) {
51
59
  patchBindingPrototype(value);
52
60
  if (skip) {
53
61
  skippedBindings.add(value);
@@ -59,14 +67,15 @@ const installBindingPatch = (env, options) => {
59
67
  warnOnce("binding-patch-install", "Failed to install the Meticulous binding patch — binding calls will not be recorded.", error);
60
68
  }
61
69
  };
62
- const fetcherBindings = (env, extraSkipped) => {
70
+ const fetcherBindings = (env, extraSkipped, skipInstances) => {
63
71
  if (env === null || typeof env !== "object") return [];
64
72
  const skipped = new Set([...DEFAULT_SKIPPED_BINDINGS, ...extraSkipped ?? []]);
73
+ const skippedInstances = new Set(skipInstances ?? []);
65
74
  const found = [];
66
75
  for (const [name, value] of Object.entries(env)) if (isFetcherLike(value)) found.push({
67
76
  name,
68
77
  value,
69
- skip: skipped.has(name)
78
+ skip: skipped.has(name) || skippedInstances.has(value)
70
79
  });
71
80
  return found;
72
81
  };
@@ -109,7 +118,7 @@ const patchedBindingFetch = (original) => function(...args) {
109
118
  if (receiver !== void 0 && skippedBindings.has(receiver)) return passThrough();
110
119
  const request = toRequest(args);
111
120
  if (request === void 0) return passThrough();
112
- if (request.url.startsWith(`${ctx.sidecarUrl}/`)) return original.apply(this, [request]);
121
+ if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) return original.apply(this, [request]);
113
122
  return captureOutboundCall(ctx, {
114
123
  kind: "binding",
115
124
  bindingName: receiver !== void 0 ? bindingNames.get(receiver) : void 0
@@ -136,4 +145,4 @@ const toRequest = (args) => {
136
145
  export { installBindingPatch };
137
146
 
138
147
  //# sourceMappingURL=binding-patch.js.map
139
- //# debugId=70c459b2-f813-5c06-8d88-9bd810926724
148
+ //# debugId=233eef03-f460-5f72-ac30-1337fa3d62e8
@@ -1 +1 @@
1
- {"version":3,"file":"binding-patch.js","sources":["../src/binding-patch.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { captureOutboundCall } from \"./outbound-capture\";\n\n/**\n * Records calls made through Cloudflare bindings (`env.MY_SERVICE.fetch(...)`).\n *\n * The `globalThis.fetch` patch cannot see these: a binding's `fetch` is a method on a host\n * object, and workerd routes it as an internal subrequest that never touches the global. So\n * the binding's *prototype* method is patched instead — the same move the Node recorder makes\n * on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`, never from a global name: in\n * workerd there is no `globalThis.Fetcher` (the runtime type is a bare TypeScript alias, not\n * one of the exposed runtime classes). Reaching it through an instance is also what makes\n * this work at all for an app that reads its bindings from `cloudflare:workers` instead of\n * the handler argument — that module hands out a *different* `env` object but the *same*\n * binding instances, so the prototype we patch is shared either way.\n *\n * Verified against real workerd in\n * `packages/backend-recorder-js/src/__tests__/workerd-binding-patch.spec.ts`, which also\n * pins the properties this relies on: `fetch` is inherited and configurable, the receiver\n * survives, one patch covers every service binding plus Durable Object stubs, and a binding\n * call is not also counted as a `globalThis.fetch` call.\n */\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst BINDING_FETCH_PATCHED = Symbol.for(\"meticulous.workerd.bindingPatched\");\n\n/**\n * Bindings never recorded, by name.\n *\n * An assets binding is shape-identical to a service binding — same prototype, same `fetch` —\n * so it cannot be told apart by duck-typing and has to be skipped by name. Recording it would\n * push every static asset through the body capture cap for no benefit: asset bytes are large\n * and often binary, and asset serving is typically the highest-volume call a worker makes.\n */\nconst DEFAULT_SKIPPED_BINDINGS = [\"ASSETS\", \"__STATIC_CONTENT\"] as const;\n\n/** Identifies a binding instance so its calls can be attributed to an `env` key. */\nconst bindingNames = new WeakMap<object, string>();\nconst skippedBindings = new WeakSet<object>();\n\ninterface FetcherLike {\n fetch: (...args: unknown[]) => Promise<Response>;\n}\n\ntype BindingFetch = (...args: unknown[]) => Promise<Response>;\n\nexport interface InstallBindingPatchOptions {\n /** Extra binding names to leave unrecorded, on top of the defaults. */\n skipBindings?: readonly string[];\n}\n\n/**\n * Discovers Fetcher-shaped bindings on `env` and patches their shared prototype so their\n * `fetch` calls are recorded. Safe to call on every request — discovery is cheap and the\n * patch 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 installBindingPatch = (\n env: unknown,\n options?: InstallBindingPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of fetcherBindings(\n env,\n options?.skipBindings,\n )) {\n // Patch even a skipped binding's prototype: it is shared with every other\n // Fetcher-shaped binding (and is in a Durable Object stub's prototype chain), so\n // seeding it is what gives those coverage. Skipping is enforced per instance at call\n // time, not by declining to patch.\n patchBindingPrototype(value);\n if (skip) {\n skippedBindings.add(value);\n continue;\n }\n bindingNames.set(value, name);\n }\n } catch (error) {\n warnOnce(\n \"binding-patch-install\",\n \"Failed to install the Meticulous binding patch — binding calls will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst fetcherBindings = (\n env: unknown,\n extraSkipped: readonly string[] | undefined,\n): DiscoveredBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>([\n ...DEFAULT_SKIPPED_BINDINGS,\n ...(extraSkipped ?? []),\n ]);\n const found: DiscoveredBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isFetcherLike(value)) {\n found.push({ name, value, skip: skipped.has(name) });\n }\n }\n return found;\n};\n\n/**\n * A binding we can record: something with a `fetch` method. Durable Object *namespaces* are\n * excluded — they have no `fetch`; it is the stub from `namespace.get()` that does, and that\n * stub inherits the same prototype, so it is covered without being discovered here.\n */\nconst isFetcherLike = (value: unknown): value is object & FetcherLike =>\n value !== null &&\n typeof value === \"object\" &&\n typeof (value as Partial<FetcherLike>).fetch === \"function\";\n\nconst patchBindingPrototype = (binding: object): void => {\n const prototype = Object.getPrototypeOf(binding) as\n | (object & Record<symbol, unknown>)\n | null;\n if (prototype === null || prototype[BINDING_FETCH_PATCHED] === true) {\n return;\n }\n const descriptor = Object.getOwnPropertyDescriptor(prototype, \"fetch\");\n if (\n descriptor === undefined ||\n typeof descriptor.value !== \"function\" ||\n descriptor.configurable !== true\n ) {\n // Either `fetch` is an own per-instance property or the runtime has locked the\n // prototype. Recording binding calls is not possible; everything else keeps working.\n warnOnce(\n \"binding-patch-unsupported\",\n \"This runtime does not expose a patchable binding `fetch` — binding calls will not be recorded.\",\n );\n return;\n }\n\n const original = descriptor.value as BindingFetch;\n Object.defineProperty(prototype, \"fetch\", {\n value: patchedBindingFetch(original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n Object.defineProperty(prototype, BINDING_FETCH_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n};\n\n/**\n * A `function`, not an arrow: `this` is the binding the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedBindingFetch = (original: BindingFetch): BindingFetch =>\n function (this: unknown, ...args: unknown[]): Promise<Response> {\n const passThrough = (): Promise<Response> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installBindingPatch` is not called for a replay request, but the\n // patch is per-isolate: a record request could have installed it earlier, and a replay\n // request must not be teed into a sidecar that has no exporter behind it.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n if (receiver !== undefined && skippedBindings.has(receiver)) {\n return passThrough();\n }\n\n const request = toRequest(args);\n if (request === undefined) {\n return passThrough();\n }\n\n // Defence in depth: a binding pointed at the sidecar must not be recorded. Forwards\n // `request`, not `args` — normalizing may already have consumed the original body.\n if (request.url.startsWith(`${ctx.sidecarUrl}/`)) {\n return original.apply(this, [request]);\n }\n\n const bindingName =\n receiver !== undefined ? bindingNames.get(receiver) : undefined;\n return captureOutboundCall(\n ctx,\n { kind: \"binding\", bindingName },\n request,\n (req) => original.apply(this, [req]),\n );\n };\n\n/**\n * Normalizes the call's arguments to a single Request, or undefined if they cannot be\n * represented (in which case the call passes through untouched and the runtime produces its\n * own error).\n *\n * A lone `Request` is passed along as-is rather than rebuilt: `new Request(existing)` drops\n * workerd's `cf` property, which the receiving worker may read.\n */\nconst toRequest = (args: unknown[]): Request | undefined => {\n try {\n const [input, init] = args;\n if (input instanceof Request && init === undefined) {\n return input;\n }\n return new Request(input as RequestInfo, init as RequestInit | undefined);\n } catch {\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAM,wBAAwB,OAAO,IAAI,oCAAoC;;;;;;;;;AAU7E,MAAM,2BAA2B,CAAC,UAAU,mBAAmB;;AAG/D,MAAM,+BAAe,IAAI,SAAyB;AAClD,MAAM,kCAAkB,IAAI,SAAiB;;;;;;;;AAoB7C,MAAa,uBACX,KACA,YACS;AACT,KAAI;AACF,OAAK,MAAM,EAAE,MAAM,OAAO,UAAU,gBAClC,KACA,SAAS,aACV,EAAE;AAKD,yBAAsB,MAAM;AAC5B,OAAI,MAAM;AACR,oBAAgB,IAAI,MAAM;AAC1B;;AAEF,gBAAa,IAAI,OAAO,KAAK;;UAExB,OAAO;AACd,WACE,yBACA,wFACA,MACD;;;AAUL,MAAM,mBACJ,KACA,iBACwB;AACxB,KAAI,QAAQ,QAAQ,OAAO,QAAQ,SACjC,QAAO,EAAE;CAEX,MAAM,UAAU,IAAI,IAAY,CAC9B,GAAG,0BACH,GAAI,gBAAgB,EAAE,CACvB,CAAC;CACF,MAAM,QAA6B,EAAE;AACrC,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,IAAI,CAC7C,KAAI,cAAc,MAAM,CACtB,OAAM,KAAK;EAAE;EAAM;EAAO,MAAM,QAAQ,IAAI,KAAK;EAAE,CAAC;AAGxD,QAAO;;;;;;;AAQT,MAAM,iBAAiB,UACrB,UAAU,QACV,OAAO,UAAU,YACjB,OAAQ,MAA+B,UAAU;AAEnD,MAAM,yBAAyB,YAA0B;CACvD,MAAM,YAAY,OAAO,eAAe,QAAQ;AAGhD,KAAI,cAAc,QAAQ,UAAU,2BAA2B,KAC7D;CAEF,MAAM,aAAa,OAAO,yBAAyB,WAAW,QAAQ;AACtE,KACE,eAAe,KAAA,KACf,OAAO,WAAW,UAAU,cAC5B,WAAW,iBAAiB,MAC5B;AAGA,WACE,6BACA,iGACD;AACD;;CAGF,MAAM,WAAW,WAAW;AAC5B,QAAO,eAAe,WAAW,SAAS;EACxC,OAAO,oBAAoB,SAAS;EACpC,UAAU;EACV,YAAY;EACZ,cAAc;EACf,CAAC;AACF,QAAO,eAAe,WAAW,uBAAuB;EACtD,OAAO;EACP,YAAY;EACZ,cAAc;EACf,CAAC;;;;;;AAOJ,MAAM,uBAAuB,aAC3B,SAAyB,GAAG,MAAoC;CAC9D,MAAM,oBAAuC,SAAS,MAAM,MAAM,KAAK;CAEvE,MAAM,MAAM,sBAAsB,UAAU;AAI5C,KAAI,CAAC,OAAO,IAAI,SAAS,SACvB,QAAO,aAAa;CAEtB,MAAM,WACJ,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,KAAA;AACrD,KAAI,aAAa,KAAA,KAAa,gBAAgB,IAAI,SAAS,CACzD,QAAO,aAAa;CAGtB,MAAM,UAAU,UAAU,KAAK;AAC/B,KAAI,YAAY,KAAA,EACd,QAAO,aAAa;AAKtB,KAAI,QAAQ,IAAI,WAAW,GAAG,IAAI,WAAW,GAAG,CAC9C,QAAO,SAAS,MAAM,MAAM,CAAC,QAAQ,CAAC;AAKxC,QAAO,oBACL,KACA;EAAE,MAAM;EAAW,aAHnB,aAAa,KAAA,IAAY,aAAa,IAAI,SAAS,GAAG,KAAA;EAGtB,EAChC,UACC,QAAQ,SAAS,MAAM,MAAM,CAAC,IAAI,CAAC,CACrC;;;;;;;;;;AAWL,MAAM,aAAa,SAAyC;AAC1D,KAAI;EACF,MAAM,CAAC,OAAO,QAAQ;AACtB,MAAI,iBAAiB,WAAW,SAAS,KAAA,EACvC,QAAO;AAET,SAAO,IAAI,QAAQ,OAAsB,KAAgC;SACnE;AACN","debugId":"70c459b2-f813-5c06-8d88-9bd810926724"}
1
+ {"version":3,"file":"binding-patch.js","sources":["../src/binding-patch.ts"],"sourcesContent":["import { requestCaptureContext, sidecarOriginOf } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { SIDECAR_BINDING_ENV_KEY } from \"./sidecar-transport\";\n\n/**\n * Records calls made through Cloudflare bindings (`env.MY_SERVICE.fetch(...)`).\n *\n * The `globalThis.fetch` patch cannot see these: a binding's `fetch` is a method on a host\n * object, and workerd routes it as an internal subrequest that never touches the global. So\n * the binding's *prototype* method is patched instead — the same move the Node recorder makes\n * on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`, never from a global name: in\n * workerd there is no `globalThis.Fetcher` (the runtime type is a bare TypeScript alias, not\n * one of the exposed runtime classes). Reaching it through an instance is also what makes\n * this work at all for an app that reads its bindings from `cloudflare:workers` instead of\n * the handler argument — that module hands out a *different* `env` object but the *same*\n * binding instances, so the prototype we patch is shared either way.\n *\n * Verified against real workerd in\n * `packages/backend-recorder-js/src/__tests__/workerd-binding-patch.spec.ts`, which also\n * pins the properties this relies on: `fetch` is inherited and configurable, the receiver\n * survives, one patch covers every service binding plus Durable Object stubs, and a binding\n * call is not also counted as a `globalThis.fetch` call.\n */\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst BINDING_FETCH_PATCHED = Symbol.for(\"meticulous.workerd.bindingPatched\");\n\n/**\n * Bindings never recorded, by name.\n *\n * An assets binding is shape-identical to a service binding — same prototype, same `fetch` —\n * so it cannot be told apart by duck-typing and has to be skipped by name. Recording it would\n * push every static asset through the body capture cap for no benefit: asset bytes are large\n * and often binary, and asset serving is typically the highest-volume call a worker makes.\n *\n * The sidecar binding is here as belt-and-braces only: the wrapper also passes its resolved\n * instance as a `skipInstances` entry, which holds however the customer named it.\n */\nconst DEFAULT_SKIPPED_BINDINGS = [\n \"ASSETS\",\n \"__STATIC_CONTENT\",\n SIDECAR_BINDING_ENV_KEY,\n] as const;\n\n/** Identifies a binding instance so its calls can be attributed to an `env` key. */\nconst bindingNames = new WeakMap<object, string>();\nconst skippedBindings = new WeakSet<object>();\n\ninterface FetcherLike {\n fetch: (...args: unknown[]) => Promise<Response>;\n}\n\ntype BindingFetch = (...args: unknown[]) => Promise<Response>;\n\nexport interface InstallBindingPatchOptions {\n /** Extra binding names to leave unrecorded, on top of the defaults. */\n skipBindings?: readonly string[];\n /**\n * Binding instances to leave unrecorded, whatever they are called on `env`. This is how the\n * sidecar's own service binding is excluded: recording the shim's reports would be an infinite\n * regress, and a name-based skip would miss a binding the customer named something else or\n * passed in through `options.sidecarBinding`.\n */\n skipInstances?: readonly object[];\n}\n\n/**\n * Discovers Fetcher-shaped bindings on `env` and patches their shared prototype so their\n * `fetch` calls are recorded. Safe to call on every request — discovery is cheap and the\n * patch 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 installBindingPatch = (\n env: unknown,\n options?: InstallBindingPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of fetcherBindings(\n env,\n options?.skipBindings,\n options?.skipInstances,\n )) {\n // Patch even a skipped binding's prototype: it is shared with every other\n // Fetcher-shaped binding (and is in a Durable Object stub's prototype chain), so\n // seeding it is what gives those coverage. Skipping is enforced per instance at call\n // time, not by declining to patch.\n patchBindingPrototype(value);\n if (skip) {\n skippedBindings.add(value);\n continue;\n }\n bindingNames.set(value, name);\n }\n } catch (error) {\n warnOnce(\n \"binding-patch-install\",\n \"Failed to install the Meticulous binding patch — binding calls will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst fetcherBindings = (\n env: unknown,\n extraSkipped: readonly string[] | undefined,\n skipInstances: readonly object[] | undefined,\n): DiscoveredBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>([\n ...DEFAULT_SKIPPED_BINDINGS,\n ...(extraSkipped ?? []),\n ]);\n const skippedInstances = new Set<object>(skipInstances ?? []);\n const found: DiscoveredBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isFetcherLike(value)) {\n found.push({\n name,\n value,\n skip: skipped.has(name) || skippedInstances.has(value),\n });\n }\n }\n return found;\n};\n\n/**\n * A binding we can record: something with a `fetch` method. Durable Object *namespaces* are\n * excluded — they have no `fetch`; it is the stub from `namespace.get()` that does, and that\n * stub inherits the same prototype, so it is covered without being discovered here.\n */\nconst isFetcherLike = (value: unknown): value is object & FetcherLike =>\n value !== null &&\n typeof value === \"object\" &&\n typeof (value as Partial<FetcherLike>).fetch === \"function\";\n\nconst patchBindingPrototype = (binding: object): void => {\n const prototype = Object.getPrototypeOf(binding) as\n | (object & Record<symbol, unknown>)\n | null;\n if (prototype === null || prototype[BINDING_FETCH_PATCHED] === true) {\n return;\n }\n const descriptor = Object.getOwnPropertyDescriptor(prototype, \"fetch\");\n if (\n descriptor === undefined ||\n typeof descriptor.value !== \"function\" ||\n descriptor.configurable !== true\n ) {\n // Either `fetch` is an own per-instance property or the runtime has locked the\n // prototype. Recording binding calls is not possible; everything else keeps working.\n warnOnce(\n \"binding-patch-unsupported\",\n \"This runtime does not expose a patchable binding `fetch` — binding calls will not be recorded.\",\n );\n return;\n }\n\n const original = descriptor.value as BindingFetch;\n Object.defineProperty(prototype, \"fetch\", {\n value: patchedBindingFetch(original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n Object.defineProperty(prototype, BINDING_FETCH_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n};\n\n/**\n * A `function`, not an arrow: `this` is the binding the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedBindingFetch = (original: BindingFetch): BindingFetch =>\n function (this: unknown, ...args: unknown[]): Promise<Response> {\n const passThrough = (): Promise<Response> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installBindingPatch` is not called for a replay request, but the\n // patch is per-isolate: a record request could have installed it earlier, and a replay\n // request must not be teed into a sidecar that has no exporter behind it.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n if (receiver !== undefined && skippedBindings.has(receiver)) {\n return passThrough();\n }\n\n const request = toRequest(args);\n if (request === undefined) {\n return passThrough();\n }\n\n // Defence in depth: a binding pointed at the sidecar must not be recorded. Forwards\n // `request`, not `args` — normalizing may already have consumed the original body.\n if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) {\n return original.apply(this, [request]);\n }\n\n const bindingName =\n receiver !== undefined ? bindingNames.get(receiver) : undefined;\n return captureOutboundCall(\n ctx,\n { kind: \"binding\", bindingName },\n request,\n (req) => original.apply(this, [req]),\n );\n };\n\n/**\n * Normalizes the call's arguments to a single Request, or undefined if they cannot be\n * represented (in which case the call passes through untouched and the runtime produces its\n * own error).\n *\n * A lone `Request` is passed along as-is rather than rebuilt: `new Request(existing)` drops\n * workerd's `cf` property, which the receiving worker may read.\n */\nconst toRequest = (args: unknown[]): Request | undefined => {\n try {\n const [input, init] = args;\n if (input instanceof Request && init === undefined) {\n return input;\n }\n return new Request(input as RequestInfo, init as RequestInit | undefined);\n } catch {\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAM,wBAAwB,OAAO,IAAI,oCAAoC;;;;;;;;;;;;AAa7E,MAAM,2BAA2B;CAC/B;CACA;CACA;CACD;;AAGD,MAAM,+BAAe,IAAI,SAAyB;AAClD,MAAM,kCAAkB,IAAI,SAAiB;;;;;;;;AA2B7C,MAAa,uBACX,KACA,YACS;AACT,KAAI;AACF,OAAK,MAAM,EAAE,MAAM,OAAO,UAAU,gBAClC,KACA,SAAS,cACT,SAAS,cACV,EAAE;AAKD,yBAAsB,MAAM;AAC5B,OAAI,MAAM;AACR,oBAAgB,IAAI,MAAM;AAC1B;;AAEF,gBAAa,IAAI,OAAO,KAAK;;UAExB,OAAO;AACd,WACE,yBACA,wFACA,MACD;;;AAUL,MAAM,mBACJ,KACA,cACA,kBACwB;AACxB,KAAI,QAAQ,QAAQ,OAAO,QAAQ,SACjC,QAAO,EAAE;CAEX,MAAM,UAAU,IAAI,IAAY,CAC9B,GAAG,0BACH,GAAI,gBAAgB,EAAE,CACvB,CAAC;CACF,MAAM,mBAAmB,IAAI,IAAY,iBAAiB,EAAE,CAAC;CAC7D,MAAM,QAA6B,EAAE;AACrC,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,IAAI,CAC7C,KAAI,cAAc,MAAM,CACtB,OAAM,KAAK;EACT;EACA;EACA,MAAM,QAAQ,IAAI,KAAK,IAAI,iBAAiB,IAAI,MAAM;EACvD,CAAC;AAGN,QAAO;;;;;;;AAQT,MAAM,iBAAiB,UACrB,UAAU,QACV,OAAO,UAAU,YACjB,OAAQ,MAA+B,UAAU;AAEnD,MAAM,yBAAyB,YAA0B;CACvD,MAAM,YAAY,OAAO,eAAe,QAAQ;AAGhD,KAAI,cAAc,QAAQ,UAAU,2BAA2B,KAC7D;CAEF,MAAM,aAAa,OAAO,yBAAyB,WAAW,QAAQ;AACtE,KACE,eAAe,KAAA,KACf,OAAO,WAAW,UAAU,cAC5B,WAAW,iBAAiB,MAC5B;AAGA,WACE,6BACA,iGACD;AACD;;CAGF,MAAM,WAAW,WAAW;AAC5B,QAAO,eAAe,WAAW,SAAS;EACxC,OAAO,oBAAoB,SAAS;EACpC,UAAU;EACV,YAAY;EACZ,cAAc;EACf,CAAC;AACF,QAAO,eAAe,WAAW,uBAAuB;EACtD,OAAO;EACP,YAAY;EACZ,cAAc;EACf,CAAC;;;;;;AAOJ,MAAM,uBAAuB,aAC3B,SAAyB,GAAG,MAAoC;CAC9D,MAAM,oBAAuC,SAAS,MAAM,MAAM,KAAK;CAEvE,MAAM,MAAM,sBAAsB,UAAU;AAI5C,KAAI,CAAC,OAAO,IAAI,SAAS,SACvB,QAAO,aAAa;CAEtB,MAAM,WACJ,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,KAAA;AACrD,KAAI,aAAa,KAAA,KAAa,gBAAgB,IAAI,SAAS,CACzD,QAAO,aAAa;CAGtB,MAAM,UAAU,UAAU,KAAK;AAC/B,KAAI,YAAY,KAAA,EACd,QAAO,aAAa;AAKtB,KAAI,QAAQ,IAAI,WAAW,GAAG,gBAAgB,IAAI,CAAC,GAAG,CACpD,QAAO,SAAS,MAAM,MAAM,CAAC,QAAQ,CAAC;AAKxC,QAAO,oBACL,KACA;EAAE,MAAM;EAAW,aAHnB,aAAa,KAAA,IAAY,aAAa,IAAI,SAAS,GAAG,KAAA;EAGtB,EAChC,UACC,QAAQ,SAAS,MAAM,MAAM,CAAC,IAAI,CAAC,CACrC;;;;;;;;;;AAWL,MAAM,aAAa,SAAyC;AAC1D,KAAI;EACF,MAAM,CAAC,OAAO,QAAQ;AACtB,MAAI,iBAAiB,WAAW,SAAS,KAAA,EACvC,QAAO;AAET,SAAO,IAAI,QAAQ,OAAsB,KAAgC;SACnE;AACN","debugId":"233eef03-f460-5f72-ac30-1337fa3d62e8"}
@@ -0,0 +1,114 @@
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]="09fca508-5827-5636-9008-5ba9edb2d94a")}catch(e){}}();
3
+ import { warnOnce } from "./log.js";
4
+ import { postCaptureEvents } from "./sidecar-client.js";
5
+ //#region src/capture-buffer.ts
6
+ /**
7
+ * Batches one request's capture events into a single report to the sidecar.
8
+ *
9
+ * A local `wrangler dev` recording could afford a POST per event over loopback. A deployed
10
+ * Worker cannot: each one is a subrequest, and — with a service-binding transport — an extra
11
+ * hop the runtime has to keep the request context alive for. An SSR request making twenty
12
+ * outbound calls should cost one report, not twenty.
13
+ *
14
+ * Batching means the report has to wait for work that finishes *after* the handler returns: a
15
+ * response body is only captured once its clone has been drained. So producers register here
16
+ * with {@link track} instead of each holding its own `waitUntil`, and {@link close} — the single
17
+ * promise the wrapper hands to `waitUntil` — drains them before sending.
18
+ */
19
+ var CaptureBuffer = class {
20
+ events = [];
21
+ bufferedChars = 0;
22
+ pending = /* @__PURE__ */ new Set();
23
+ /** Chained so two flushes can never interleave and reorder a session's chunks. */
24
+ sending = Promise.resolve();
25
+ closed = false;
26
+ constructor(transport, waitUntil) {
27
+ this.transport = transport;
28
+ this.waitUntil = waitUntil;
29
+ }
30
+ /**
31
+ * Registers work that will add zero or more events. Never rejects: a producer that throws is
32
+ * warned about once and dropped, exactly as an unbatched capture failure was.
33
+ */
34
+ track(producer, warnKey, warnMessage) {
35
+ const promise = producer().catch((error) => {
36
+ warnOnce(warnKey, warnMessage, error);
37
+ }).finally(() => {
38
+ this.pending.delete(promise);
39
+ });
40
+ this.pending.add(promise);
41
+ if (this.closed) this.waitUntil(promise);
42
+ }
43
+ /**
44
+ * Buffers one event, sending early if the buffer has grown past {@link MAX_BUFFERED_CHARS}.
45
+ *
46
+ * The early send matters for exactly the requests batching is riskiest for: a long-lived SSE
47
+ * response, or a handler making many body-carrying calls, would otherwise hold every captured
48
+ * body in isolate memory until the request ended.
49
+ */
50
+ add(event) {
51
+ if (this.closed) {
52
+ this.waitUntil(this.send([event]));
53
+ return;
54
+ }
55
+ this.events.push(event);
56
+ this.bufferedChars += estimateChars(event);
57
+ if (this.bufferedChars >= MAX_BUFFERED_CHARS) this.waitUntil(this.flush());
58
+ }
59
+ /**
60
+ * Drains every producer and sends what they buffered, repeating while draining produces more
61
+ * work (a body read that starts another capture). Idempotent; after it resolves the buffer is
62
+ * closed and later events are sent individually.
63
+ */
64
+ async close() {
65
+ for (let round = 0; round < MAX_DRAIN_ROUNDS; round++) {
66
+ if (this.pending.size === 0) break;
67
+ await Promise.all([...this.pending]);
68
+ }
69
+ this.closed = true;
70
+ await this.flush();
71
+ }
72
+ flush() {
73
+ if (this.events.length === 0) return this.sending;
74
+ const batch = this.events;
75
+ this.events = [];
76
+ this.bufferedChars = 0;
77
+ this.sending = this.sending.then(() => this.send(batch));
78
+ return this.sending;
79
+ }
80
+ /** `postCaptureEvents` never rejects — it warns once on an unreachable sidecar. */
81
+ send(events) {
82
+ return postCaptureEvents(this.transport, events);
83
+ }
84
+ };
85
+ /**
86
+ * Buffered-payload ceiling, in JSON characters. Two 256 KB bodies per event means a handful of
87
+ * body-carrying calls reaches this; the sidecar's own payload limit is 8 MB, so a batch built to
88
+ * this bound always fits with room for JSON escaping.
89
+ */
90
+ const MAX_BUFFERED_CHARS = 2 * 1024 * 1024;
91
+ /**
92
+ * Bounds `close()` in the pathological case where every drained producer starts another. Capture
93
+ * is a leaf operation, so one round is the norm and two the most a body read can cause.
94
+ */
95
+ const MAX_DRAIN_ROUNDS = 4;
96
+ /**
97
+ * Rough size of an event, counting only the fields that can be large. Cheaper than
98
+ * `JSON.stringify` on the whole event, and this only needs to be right to within a factor.
99
+ */
100
+ const estimateChars = (event) => {
101
+ let chars = 256;
102
+ if ("requestBody" in event && event.requestBody !== void 0) chars += event.requestBody.body.length;
103
+ if ("responseBody" in event && event.responseBody !== void 0) chars += event.responseBody.body.length;
104
+ if ("result" in event && event.result !== void 0) chars += event.result.body.length;
105
+ if ("value" in event && event.value !== void 0) chars += event.value.body.length;
106
+ if ("args" in event && event.args !== void 0) chars += event.args.body.length;
107
+ if ("queryText" in event) chars += event.queryText.length + event.params.length;
108
+ return chars;
109
+ };
110
+ //#endregion
111
+ export { CaptureBuffer };
112
+
113
+ //# sourceMappingURL=capture-buffer.js.map
114
+ //# debugId=09fca508-5827-5636-9008-5ba9edb2d94a
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture-buffer.js","sources":["../src/capture-buffer.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport type { CaptureEvent } from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\nimport type { SidecarTransport } from \"./sidecar-transport\";\n\n/**\n * Batches one request's capture events into a single report to the sidecar.\n *\n * A local `wrangler dev` recording could afford a POST per event over loopback. A deployed\n * Worker cannot: each one is a subrequest, and — with a service-binding transport — an extra\n * hop the runtime has to keep the request context alive for. An SSR request making twenty\n * outbound calls should cost one report, not twenty.\n *\n * Batching means the report has to wait for work that finishes *after* the handler returns: a\n * response body is only captured once its clone has been drained. So producers register here\n * with {@link track} instead of each holding its own `waitUntil`, and {@link close} — the single\n * promise the wrapper hands to `waitUntil` — drains them before sending.\n */\nexport class CaptureBuffer {\n private events: CaptureEvent[] = [];\n private bufferedChars = 0;\n private readonly pending = new Set<Promise<void>>();\n /** Chained so two flushes can never interleave and reorder a session's chunks. */\n private sending: Promise<void> = Promise.resolve();\n private closed = false;\n\n constructor(\n private readonly transport: SidecarTransport,\n private readonly waitUntil: (promise: Promise<unknown>) => void,\n ) {}\n\n /**\n * Registers work that will add zero or more events. Never rejects: a producer that throws is\n * warned about once and dropped, exactly as an unbatched capture failure was.\n */\n track(\n producer: () => Promise<void>,\n warnKey: string,\n warnMessage: string,\n ): void {\n const promise = producer()\n .catch((error) => {\n warnOnce(warnKey, warnMessage, error);\n })\n .finally(() => {\n this.pending.delete(promise);\n });\n this.pending.add(promise);\n // A closed buffer has no `close()` left to await this, so it needs its own lifetime\n // extension — see the same reasoning in `add`.\n if (this.closed) {\n this.waitUntil(promise);\n }\n }\n\n /**\n * Buffers one event, sending early if the buffer has grown past {@link MAX_BUFFERED_CHARS}.\n *\n * The early send matters for exactly the requests batching is riskiest for: a long-lived SSE\n * response, or a handler making many body-carrying calls, would otherwise hold every captured\n * body in isolate memory until the request ended.\n */\n add(event: CaptureEvent): void {\n if (this.closed) {\n // Work the app itself queued in `waitUntil` can still make calls after our own `close()`\n // has run. Report those the old way rather than dropping them.\n this.waitUntil(this.send([event]));\n return;\n }\n this.events.push(event);\n this.bufferedChars += estimateChars(event);\n if (this.bufferedChars >= MAX_BUFFERED_CHARS) {\n this.waitUntil(this.flush());\n }\n }\n\n /**\n * Drains every producer and sends what they buffered, repeating while draining produces more\n * work (a body read that starts another capture). Idempotent; after it resolves the buffer is\n * closed and later events are sent individually.\n */\n async close(): Promise<void> {\n for (let round = 0; round < MAX_DRAIN_ROUNDS; round++) {\n if (this.pending.size === 0) {\n break;\n }\n await Promise.all([...this.pending]);\n }\n this.closed = true;\n await this.flush();\n }\n\n private flush(): Promise<void> {\n if (this.events.length === 0) {\n return this.sending;\n }\n const batch = this.events;\n this.events = [];\n this.bufferedChars = 0;\n this.sending = this.sending.then(() => this.send(batch));\n return this.sending;\n }\n\n /** `postCaptureEvents` never rejects — it warns once on an unreachable sidecar. */\n private send(events: CaptureEvent[]): Promise<void> {\n return postCaptureEvents(this.transport, events);\n }\n}\n\n/**\n * Buffered-payload ceiling, in JSON characters. Two 256 KB bodies per event means a handful of\n * body-carrying calls reaches this; the sidecar's own payload limit is 8 MB, so a batch built to\n * this bound always fits with room for JSON escaping.\n */\nconst MAX_BUFFERED_CHARS = 2 * 1024 * 1024;\n\n/**\n * Bounds `close()` in the pathological case where every drained producer starts another. Capture\n * is a leaf operation, so one round is the norm and two the most a body read can cause.\n */\nconst MAX_DRAIN_ROUNDS = 4;\n\n/**\n * Rough size of an event, counting only the fields that can be large. Cheaper than\n * `JSON.stringify` on the whole event, and this only needs to be right to within a factor.\n */\nconst estimateChars = (event: CaptureEvent): number => {\n let chars = 256;\n if (\"requestBody\" in event && event.requestBody !== undefined) {\n chars += event.requestBody.body.length;\n }\n if (\"responseBody\" in event && event.responseBody !== undefined) {\n chars += event.responseBody.body.length;\n }\n if (\"result\" in event && event.result !== undefined) {\n chars += event.result.body.length;\n }\n if (\"value\" in event && event.value !== undefined) {\n chars += event.value.body.length;\n }\n if (\"args\" in event && event.args !== undefined) {\n chars += event.args.body.length;\n }\n if (\"queryText\" in event) {\n chars += event.queryText.length + event.params.length;\n }\n return chars;\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAkBA,IAAa,gBAAb,MAA2B;CACzB,SAAiC,EAAE;CACnC,gBAAwB;CACxB,0BAA2B,IAAI,KAAoB;;CAEnD,UAAiC,QAAQ,SAAS;CAClD,SAAiB;CAEjB,YACE,WACA,WACA;AAFiB,OAAA,YAAA;AACA,OAAA,YAAA;;;;;;CAOnB,MACE,UACA,SACA,aACM;EACN,MAAM,UAAU,UAAU,CACvB,OAAO,UAAU;AAChB,YAAS,SAAS,aAAa,MAAM;IACrC,CACD,cAAc;AACb,QAAK,QAAQ,OAAO,QAAQ;IAC5B;AACJ,OAAK,QAAQ,IAAI,QAAQ;AAGzB,MAAI,KAAK,OACP,MAAK,UAAU,QAAQ;;;;;;;;;CAW3B,IAAI,OAA2B;AAC7B,MAAI,KAAK,QAAQ;AAGf,QAAK,UAAU,KAAK,KAAK,CAAC,MAAM,CAAC,CAAC;AAClC;;AAEF,OAAK,OAAO,KAAK,MAAM;AACvB,OAAK,iBAAiB,cAAc,MAAM;AAC1C,MAAI,KAAK,iBAAiB,mBACxB,MAAK,UAAU,KAAK,OAAO,CAAC;;;;;;;CAShC,MAAM,QAAuB;AAC3B,OAAK,IAAI,QAAQ,GAAG,QAAQ,kBAAkB,SAAS;AACrD,OAAI,KAAK,QAAQ,SAAS,EACxB;AAEF,SAAM,QAAQ,IAAI,CAAC,GAAG,KAAK,QAAQ,CAAC;;AAEtC,OAAK,SAAS;AACd,QAAM,KAAK,OAAO;;CAGpB,QAA+B;AAC7B,MAAI,KAAK,OAAO,WAAW,EACzB,QAAO,KAAK;EAEd,MAAM,QAAQ,KAAK;AACnB,OAAK,SAAS,EAAE;AAChB,OAAK,gBAAgB;AACrB,OAAK,UAAU,KAAK,QAAQ,WAAW,KAAK,KAAK,MAAM,CAAC;AACxD,SAAO,KAAK;;;CAId,KAAa,QAAuC;AAClD,SAAO,kBAAkB,KAAK,WAAW,OAAO;;;;;;;;AASpD,MAAM,qBAAqB,IAAI,OAAO;;;;;AAMtC,MAAM,mBAAmB;;;;;AAMzB,MAAM,iBAAiB,UAAgC;CACrD,IAAI,QAAQ;AACZ,KAAI,iBAAiB,SAAS,MAAM,gBAAgB,KAAA,EAClD,UAAS,MAAM,YAAY,KAAK;AAElC,KAAI,kBAAkB,SAAS,MAAM,iBAAiB,KAAA,EACpD,UAAS,MAAM,aAAa,KAAK;AAEnC,KAAI,YAAY,SAAS,MAAM,WAAW,KAAA,EACxC,UAAS,MAAM,OAAO,KAAK;AAE7B,KAAI,WAAW,SAAS,MAAM,UAAU,KAAA,EACtC,UAAS,MAAM,MAAM,KAAK;AAE5B,KAAI,UAAU,SAAS,MAAM,SAAS,KAAA,EACpC,UAAS,MAAM,KAAK,KAAK;AAE3B,KAAI,eAAe,MACjB,UAAS,MAAM,UAAU,SAAS,MAAM,OAAO;AAEjD,QAAO","debugId":"09fca508-5827-5636-9008-5ba9edb2d94a"}
package/dist/context.js CHANGED
@@ -1,10 +1,13 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="66b36970-e102-5f7c-b0d7-2ed1d1681011")}catch(e){}}();
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]="0b307c46-2266-550b-bc50-69f2c5333db3")}catch(e){}}();
3
+ import { transportOrigin } from "./sidecar-transport.js";
3
4
  import { AsyncLocalStorage } from "node:async_hooks";
4
5
  //#region src/context.ts
5
6
  const requestCaptureContext = new AsyncLocalStorage();
7
+ /** The origin the shim's own sidecar traffic goes to, for the self-capture guards. */
8
+ const sidecarOriginOf = (ctx) => ctx.mode === "replay" ? ctx.sidecarUrl : transportOrigin(ctx.transport);
6
9
  //#endregion
7
- export { requestCaptureContext };
10
+ export { requestCaptureContext, sidecarOriginOf };
8
11
 
9
12
  //# sourceMappingURL=context.js.map
10
- //# debugId=66b36970-e102-5f7c-b0d7-2ed1d1681011
13
+ //# debugId=0b307c46-2266-550b-bc50-69f2c5333db3
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sources":["../src/context.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\n/**\n * Per-inbound-request context, entered by the withMeticulous wrapper and read\n * by the fetch patch. Requires the `nodejs_als` (or `nodejs_compat`)\n * compatibility flag in the worker's wrangler configuration.\n */\ninterface BaseRequestContext {\n requestId: string;\n /** Normalized origin of the sidecar, e.g. \"http://127.0.0.1:9670\". No trailing slash. */\n sidecarUrl: string;\n /** The inbound request's ctx.waitUntil, for post-response capture work. */\n waitUntil: (promise: Promise<unknown>) => void;\n}\n\n/** Recording: capture events are reported to the sidecar, which stays out of the request path. */\nexport interface RequestCaptureContext extends BaseRequestContext {\n mode: \"record\";\n frontendSessionId: string | undefined;\n}\n\n/**\n * Replaying: outbound calls are looked up against the sidecar's mock store before they\n * leave the worker.\n *\n * `frontendSessionId` is required here, unlike recording. Mocks are indexed per session, so\n * a replay context without one could only ever miss — and every miss falls through to the\n * real upstream. Making it non-optional keeps that silently-wrong state unrepresentable.\n */\nexport interface RequestReplayContext extends BaseRequestContext {\n mode: \"replay\";\n frontendSessionId: string;\n /**\n * Instant to freeze the worker's clock at while serving this session, or undefined to\n * leave the real clock alone.\n */\n clockAnchorMs: number | undefined;\n}\n\nexport type RequestContext = RequestCaptureContext | RequestReplayContext;\n\nexport const requestCaptureContext = new AsyncLocalStorage<RequestContext>();\n"],"names":[],"mappings":";;;;AAyCA,MAAa,wBAAwB,IAAI,mBAAmC","debugId":"66b36970-e102-5f7c-b0d7-2ed1d1681011"}
1
+ {"version":3,"file":"context.js","sources":["../src/context.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\nimport type { CaptureBuffer } from \"./capture-buffer\";\nimport { type SidecarTransport, transportOrigin } from \"./sidecar-transport\";\n\n/**\n * Per-inbound-request context, entered by the withMeticulous wrapper and read by the fetch\n * patch. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag in the worker's\n * wrangler configuration.\n */\ninterface BaseRequestContext {\n requestId: string;\n /** The inbound request's ctx.waitUntil, for post-response capture work. */\n waitUntil: (promise: Promise<unknown>) => void;\n}\n\n/** Recording: capture events are reported to the sidecar, which stays out of the request path. */\nexport interface RequestCaptureContext extends BaseRequestContext {\n mode: \"record\";\n frontendSessionId: string | undefined;\n /**\n * Set only when we minted {@link frontendSessionId} ourselves for a document navigation the\n * browser could not tag, rather than reading it off the request. Recorded on the inbound\n * event so ingestion can tell an id the page may never have adopted from one it sent us.\n * See provisional-session-id.ts.\n */\n sessionIdOrigin?: \"backend\";\n transport: SidecarTransport;\n /** Batches this request's events into one report. */\n buffer: CaptureBuffer;\n /**\n * Trace this request's spans belong to, and the id of its SERVER span. Minted here rather than\n * by the sidecar so span assembly needs no cross-batch memory — see `CorrelatedEvent`.\n */\n traceId: string;\n serverSpanId: string;\n}\n\n/**\n * Replaying: outbound calls are looked up against the sidecar's mock store before they leave the\n * worker.\n *\n * Both ids are required here, unlike recording. Mocks are indexed by session and their\n * consume-once state is isolated by replay, so omitting either would make deterministic\n * lookup impossible. Making both non-optional keeps that state unrepresentable.\n *\n * Replay is URL-only, deliberately: the sidecar URL arrives on a request header the replay runner\n * injects, validated down to a loopback / docker-gateway / private-network origin. There is no\n * binding transport here because a replay sidecar is a Node process holding the mock store, never\n * a Worker.\n */\nexport interface RequestReplayContext extends BaseRequestContext {\n mode: \"replay\";\n frontendSessionId: string;\n replayId: string;\n /** Normalized origin of the sidecar, e.g. \"http://127.0.0.1:9670\". No trailing slash. */\n sidecarUrl: string;\n /**\n * Instant to freeze the worker's clock at while serving this session, or undefined to leave the\n * real clock alone.\n */\n clockAnchorMs: number | undefined;\n}\n\nexport type RequestContext = RequestCaptureContext | RequestReplayContext;\n\nexport const requestCaptureContext = new AsyncLocalStorage<RequestContext>();\n\n/** The origin the shim's own sidecar traffic goes to, for the self-capture guards. */\nexport const sidecarOriginOf = (ctx: RequestContext): string =>\n ctx.mode === \"replay\" ? ctx.sidecarUrl : transportOrigin(ctx.transport);\n"],"names":[],"mappings":";;;;;AAiEA,MAAa,wBAAwB,IAAI,mBAAmC;;AAG5E,MAAa,mBAAmB,QAC9B,IAAI,SAAS,WAAW,IAAI,aAAa,gBAAgB,IAAI,UAAU","debugId":"0b307c46-2266-550b-bc50-69f2c5333db3"}
@@ -0,0 +1,44 @@
1
+ //#region src/error-capture.d.ts
2
+ /**
3
+ * Capturing and reconstructing a thrown error, shared by every instrumentation that replays
4
+ * failures rather than treating them as "nothing was recorded".
5
+ *
6
+ * Recording the error matters wherever the app's own behaviour depends on it: a call that
7
+ * legitimately failed during recording (a constraint violation, a rejected payment) must replay
8
+ * as the same failure, not as a miss. An uncaptured error replays as a hermetic "no recorded
9
+ * result", which renders a different page and reads as a product change.
10
+ *
11
+ * What survives: `name`, `message` and every own enumerable property — which is where a JS
12
+ * error carries the fields consumers actually branch on (`code`, `status`, `severity`). What
13
+ * does not: the class. Reconstruction returns a plain `Error`, so `error.name ===
14
+ * "PostgresError"` and `error.code` behave as recorded but `instanceof` does not match.
15
+ *
16
+ * It lives in this package, rather than in the Node recorder, because the workerd postgres.js
17
+ * capture has to produce byte-identical JSON to the Node one — the same reason `kv-capture.ts`
18
+ * is here. `packages/backend-recorder-js/src/error-capture-shared.ts` re-exports both halves.
19
+ */
20
+ /**
21
+ * Serializes a thrown value to a JSON string.
22
+ *
23
+ * `omitProps` names own properties to leave out — large debug payloads, or values that are
24
+ * redundant with attributes the span already carries. `stack` is almost always in it: it is
25
+ * recorded-process-specific and would be misleading replayed into another process.
26
+ *
27
+ * A non-`Error` throw (a string, an object) is captured by its `String()` form, which is what a
28
+ * consumer would have seen from a `catch` that assumed an `Error`.
29
+ */
30
+ declare const serializeCapturedError: (error: unknown, omitProps: ReadonlySet<string>) => string;
31
+ /**
32
+ * Reconstructs a captured error. Both message fallbacks are the caller's, because they surface
33
+ * in the app's own error handling and should name the technology that failed.
34
+ */
35
+ declare const deserializeCapturedError: (json: string, {
36
+ unparseableMessage,
37
+ fallbackMessage
38
+ }: {
39
+ unparseableMessage: string;
40
+ fallbackMessage: string;
41
+ }) => Error;
42
+ //#endregion
43
+ export { deserializeCapturedError, serializeCapturedError };
44
+ //# sourceMappingURL=error-capture.d.ts.map
@@ -0,0 +1,65 @@
1
+ //#region src/error-capture.ts
2
+ /**
3
+ * Capturing and reconstructing a thrown error, shared by every instrumentation that replays
4
+ * failures rather than treating them as "nothing was recorded".
5
+ *
6
+ * Recording the error matters wherever the app's own behaviour depends on it: a call that
7
+ * legitimately failed during recording (a constraint violation, a rejected payment) must replay
8
+ * as the same failure, not as a miss. An uncaptured error replays as a hermetic "no recorded
9
+ * result", which renders a different page and reads as a product change.
10
+ *
11
+ * What survives: `name`, `message` and every own enumerable property — which is where a JS
12
+ * error carries the fields consumers actually branch on (`code`, `status`, `severity`). What
13
+ * does not: the class. Reconstruction returns a plain `Error`, so `error.name ===
14
+ * "PostgresError"` and `error.code` behave as recorded but `instanceof` does not match.
15
+ *
16
+ * It lives in this package, rather than in the Node recorder, because the workerd postgres.js
17
+ * capture has to produce byte-identical JSON to the Node one — the same reason `kv-capture.ts`
18
+ * is here. `packages/backend-recorder-js/src/error-capture-shared.ts` re-exports both halves.
19
+ */
20
+ /**
21
+ * Serializes a thrown value to a JSON string.
22
+ *
23
+ * `omitProps` names own properties to leave out — large debug payloads, or values that are
24
+ * redundant with attributes the span already carries. `stack` is almost always in it: it is
25
+ * recorded-process-specific and would be misleading replayed into another process.
26
+ *
27
+ * A non-`Error` throw (a string, an object) is captured by its `String()` form, which is what a
28
+ * consumer would have seen from a `catch` that assumed an `Error`.
29
+ */
30
+
31
+ !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]="bf623e75-cd70-5ffa-bdf9-9e125c4dda04")}catch(e){}}();
32
+ const serializeCapturedError = (error, omitProps) => {
33
+ if (!(error instanceof Error)) return JSON.stringify({
34
+ name: "Error",
35
+ message: String(error)
36
+ });
37
+ const fields = {};
38
+ for (const [key, value] of Object.entries(error)) if (!omitProps.has(key)) fields[key] = value;
39
+ return JSON.stringify({
40
+ ...fields,
41
+ name: error.name,
42
+ message: error.message
43
+ });
44
+ };
45
+ /**
46
+ * Reconstructs a captured error. Both message fallbacks are the caller's, because they surface
47
+ * in the app's own error handling and should name the technology that failed.
48
+ */
49
+ const deserializeCapturedError = (json, { unparseableMessage, fallbackMessage }) => {
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(json);
53
+ } catch {
54
+ return new Error(unparseableMessage);
55
+ }
56
+ const { message, name, ...fields } = parsed;
57
+ const error = Object.assign(new Error(typeof message === "string" ? message : fallbackMessage), fields);
58
+ if (typeof name === "string") error.name = name;
59
+ return error;
60
+ };
61
+ //#endregion
62
+ export { deserializeCapturedError, serializeCapturedError };
63
+
64
+ //# sourceMappingURL=error-capture.js.map
65
+ //# debugId=bf623e75-cd70-5ffa-bdf9-9e125c4dda04
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-capture.js","sources":["../src/error-capture.ts"],"sourcesContent":["/**\n * Capturing and reconstructing a thrown error, shared by every instrumentation that replays\n * failures rather than treating them as \"nothing was recorded\".\n *\n * Recording the error matters wherever the app's own behaviour depends on it: a call that\n * legitimately failed during recording (a constraint violation, a rejected payment) must replay\n * as the same failure, not as a miss. An uncaptured error replays as a hermetic \"no recorded\n * result\", which renders a different page and reads as a product change.\n *\n * What survives: `name`, `message` and every own enumerable property — which is where a JS\n * error carries the fields consumers actually branch on (`code`, `status`, `severity`). What\n * does not: the class. Reconstruction returns a plain `Error`, so `error.name ===\n * \"PostgresError\"` and `error.code` behave as recorded but `instanceof` does not match.\n *\n * It lives in this package, rather than in the Node recorder, because the workerd postgres.js\n * capture has to produce byte-identical JSON to the Node one — the same reason `kv-capture.ts`\n * is here. `packages/backend-recorder-js/src/error-capture-shared.ts` re-exports both halves.\n */\n\n/**\n * Serializes a thrown value to a JSON string.\n *\n * `omitProps` names own properties to leave out — large debug payloads, or values that are\n * redundant with attributes the span already carries. `stack` is almost always in it: it is\n * recorded-process-specific and would be misleading replayed into another process.\n *\n * A non-`Error` throw (a string, an object) is captured by its `String()` form, which is what a\n * consumer would have seen from a `catch` that assumed an `Error`.\n */\nexport const serializeCapturedError = (\n error: unknown,\n omitProps: ReadonlySet<string>,\n): string => {\n if (!(error instanceof Error)) {\n return JSON.stringify({ name: \"Error\", message: String(error) });\n }\n const fields: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(error)) {\n if (!omitProps.has(key)) {\n fields[key] = value;\n }\n }\n // `name`/`message` last so an own property of either name cannot shadow the real one.\n return JSON.stringify({\n ...fields,\n name: error.name,\n message: error.message,\n });\n};\n\n/**\n * Reconstructs a captured error. Both message fallbacks are the caller's, because they surface\n * in the app's own error handling and should name the technology that failed.\n */\nexport const deserializeCapturedError = (\n json: string,\n {\n unparseableMessage,\n fallbackMessage,\n }: { unparseableMessage: string; fallbackMessage: string },\n): Error => {\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(json) as Record<string, unknown>;\n } catch {\n return new Error(unparseableMessage);\n }\n const { message, name, ...fields } = parsed;\n const error = Object.assign(\n new Error(typeof message === \"string\" ? message : fallbackMessage),\n fields,\n );\n if (typeof name === \"string\") {\n error.name = name;\n }\n return error;\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,0BACX,OACA,cACW;AACX,KAAI,EAAE,iBAAiB,OACrB,QAAO,KAAK,UAAU;EAAE,MAAM;EAAS,SAAS,OAAO,MAAM;EAAE,CAAC;CAElE,MAAM,SAAkC,EAAE;AAC1C,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,CAC9C,KAAI,CAAC,UAAU,IAAI,IAAI,CACrB,QAAO,OAAO;AAIlB,QAAO,KAAK,UAAU;EACpB,GAAG;EACH,MAAM,MAAM;EACZ,SAAS,MAAM;EAChB,CAAC;;;;;;AAOJ,MAAa,4BACX,MACA,EACE,oBACA,sBAEQ;CACV,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,KAAK;SACnB;AACN,SAAO,IAAI,MAAM,mBAAmB;;CAEtC,MAAM,EAAE,SAAS,MAAM,GAAG,WAAW;CACrC,MAAM,QAAQ,OAAO,OACnB,IAAI,MAAM,OAAO,YAAY,WAAW,UAAU,gBAAgB,EAClE,OACD;AACD,KAAI,OAAO,SAAS,SAClB,OAAM,OAAO;AAEf,QAAO","debugId":"bf623e75-cd70-5ffa-bdf9-9e125c4dda04"}
@@ -1,8 +1,8 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e73e6f17-4a76-5ec4-a107-3aa991c55b22")}catch(e){}}();
3
- import { requestCaptureContext } from "./context.js";
4
- import { getOriginalFetch, setOriginalFetch } from "./original-fetch.js";
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]="702f30a5-c657-58b7-90a4-8f78c5fcdb2b")}catch(e){}}();
3
+ import { requestCaptureContext, sidecarOriginOf } from "./context.js";
5
4
  import { captureOutboundCall } from "./outbound-capture.js";
5
+ import { getOriginalFetch, setOriginalFetch } from "./original-fetch.js";
6
6
  import { replayOutboundCall } from "./replay-fetch.js";
7
7
  //#region src/fetch-patch.ts
8
8
  const FETCH_PATCHED = Symbol.for("meticulous.workerd.fetchPatched");
@@ -35,11 +35,11 @@ const patchedFetch = async (input, init) => {
35
35
  } catch {
36
36
  return original(input, init);
37
37
  }
38
- if (request.url.startsWith(`${ctx.sidecarUrl}/`)) return original(request);
38
+ if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) return original(request);
39
39
  return ctx.mode === "replay" ? replayOutboundCall(ctx, request, (req) => original(req)) : captureOutboundCall(ctx, { kind: "outbound" }, request, (req) => original(req));
40
40
  };
41
41
  //#endregion
42
42
  export { installFetchPatch };
43
43
 
44
44
  //# sourceMappingURL=fetch-patch.js.map
45
- //# debugId=e73e6f17-4a76-5ec4-a107-3aa991c55b22
45
+ //# debugId=702f30a5-c657-58b7-90a4-8f78c5fcdb2b
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-patch.js","sources":["../src/fetch-patch.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { getOriginalFetch, setOriginalFetch } from \"./original-fetch\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { replayOutboundCall } from \"./replay-fetch\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst FETCH_PATCHED = Symbol.for(\"meticulous.workerd.fetchPatched\");\n\nexport { getOriginalFetch } from \"./original-fetch\";\nexport { headersToRecord } from \"./outbound-capture\";\n\n/**\n * Replaces globalThis.fetch with the mode-appropriate interception:\n *\n * - recording: a passive capture tee. The real fetch is called directly (the sidecar is\n * never in the request path); request and response are cloned and reported in the\n * background.\n * - replaying: decide-then-serve. The call is looked up against the sidecar's mock store\n * and a recorded response is synthesised, or the call fails — replay is hermetic unless\n * the request opts out with the `meticulous-passthrough` header.\n *\n * Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.\n */\nexport const installFetchPatch = (): void => {\n const holder = globalThis as { [FETCH_PATCHED]?: boolean };\n if (holder[FETCH_PATCHED]) {\n return;\n }\n holder[FETCH_PATCHED] = true;\n setOriginalFetch(globalThis.fetch.bind(globalThis));\n globalThis.fetch = patchedFetch;\n};\n\nconst patchedFetch: FetchFn = async (input, init) => {\n const original = getOriginalFetch();\n const ctx = requestCaptureContext.getStore();\n if (!ctx) {\n return original(input, init);\n }\n\n // Normalize to a single Request. This may consume `input`'s body (when\n // `input` is a Request), so from here on the real fetch must be given\n // `request`, never the original arguments.\n let request: Request;\n try {\n request = new Request(input, init);\n } catch {\n // Unconstructable arguments — let the real fetch produce the error.\n return original(input, init);\n }\n\n // Never intercept the shim's own traffic to the sidecar. Defence in depth: every\n // shim → sidecar call already goes through the unpatched fetch.\n if (request.url.startsWith(`${ctx.sidecarUrl}/`)) {\n return original(request);\n }\n\n return ctx.mode === \"replay\"\n ? replayOutboundCall(ctx, request, (req) => original(req))\n : captureOutboundCall(ctx, { kind: \"outbound\" }, request, (req) =>\n original(req),\n );\n};\n"],"names":[],"mappings":";;;;;;;AAQA,MAAM,gBAAgB,OAAO,IAAI,kCAAkC;;;;;;;;;;;;;AAiBnE,MAAa,0BAAgC;CAC3C,MAAM,SAAS;AACf,KAAI,OAAO,eACT;AAEF,QAAO,iBAAiB;AACxB,kBAAiB,WAAW,MAAM,KAAK,WAAW,CAAC;AACnD,YAAW,QAAQ;;AAGrB,MAAM,eAAwB,OAAO,OAAO,SAAS;CACnD,MAAM,WAAW,kBAAkB;CACnC,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,CAAC,IACH,QAAO,SAAS,OAAO,KAAK;CAM9B,IAAI;AACJ,KAAI;AACF,YAAU,IAAI,QAAQ,OAAO,KAAK;SAC5B;AAEN,SAAO,SAAS,OAAO,KAAK;;AAK9B,KAAI,QAAQ,IAAI,WAAW,GAAG,IAAI,WAAW,GAAG,CAC9C,QAAO,SAAS,QAAQ;AAG1B,QAAO,IAAI,SAAS,WAChB,mBAAmB,KAAK,UAAU,QAAQ,SAAS,IAAI,CAAC,GACxD,oBAAoB,KAAK,EAAE,MAAM,YAAY,EAAE,UAAU,QACvD,SAAS,IAAI,CACd","debugId":"e73e6f17-4a76-5ec4-a107-3aa991c55b22"}
1
+ {"version":3,"file":"fetch-patch.js","sources":["../src/fetch-patch.ts"],"sourcesContent":["import { requestCaptureContext, sidecarOriginOf } from \"./context\";\nimport { getOriginalFetch, setOriginalFetch } from \"./original-fetch\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { replayOutboundCall } from \"./replay-fetch\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst FETCH_PATCHED = Symbol.for(\"meticulous.workerd.fetchPatched\");\n\nexport { getOriginalFetch } from \"./original-fetch\";\nexport { headersToRecord } from \"./outbound-capture\";\n\n/**\n * Replaces globalThis.fetch with the mode-appropriate interception:\n *\n * - recording: a passive capture tee. The real fetch is called directly (the sidecar is\n * never in the request path); request and response are cloned and reported in the\n * background.\n * - replaying: decide-then-serve. The call is looked up against the sidecar's mock store\n * and a recorded response is synthesised, or the call fails — replay is hermetic unless\n * the request opts out with the `meticulous-passthrough` header.\n *\n * Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.\n */\nexport const installFetchPatch = (): void => {\n const holder = globalThis as { [FETCH_PATCHED]?: boolean };\n if (holder[FETCH_PATCHED]) {\n return;\n }\n holder[FETCH_PATCHED] = true;\n setOriginalFetch(globalThis.fetch.bind(globalThis));\n globalThis.fetch = patchedFetch;\n};\n\nconst patchedFetch: FetchFn = async (input, init) => {\n const original = getOriginalFetch();\n const ctx = requestCaptureContext.getStore();\n if (!ctx) {\n return original(input, init);\n }\n\n // Normalize to a single Request. This may consume `input`'s body (when\n // `input` is a Request), so from here on the real fetch must be given\n // `request`, never the original arguments.\n let request: Request;\n try {\n request = new Request(input, init);\n } catch {\n // Unconstructable arguments — let the real fetch produce the error.\n return original(input, init);\n }\n\n // Never intercept the shim's own traffic to the sidecar. Defence in depth: every\n // shim → sidecar call already goes through the unpatched fetch, or a service binding.\n if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) {\n return original(request);\n }\n\n return ctx.mode === \"replay\"\n ? replayOutboundCall(ctx, request, (req) => original(req))\n : captureOutboundCall(ctx, { kind: \"outbound\" }, request, (req) =>\n original(req),\n );\n};\n"],"names":[],"mappings":";;;;;;;AAQA,MAAM,gBAAgB,OAAO,IAAI,kCAAkC;;;;;;;;;;;;;AAiBnE,MAAa,0BAAgC;CAC3C,MAAM,SAAS;AACf,KAAI,OAAO,eACT;AAEF,QAAO,iBAAiB;AACxB,kBAAiB,WAAW,MAAM,KAAK,WAAW,CAAC;AACnD,YAAW,QAAQ;;AAGrB,MAAM,eAAwB,OAAO,OAAO,SAAS;CACnD,MAAM,WAAW,kBAAkB;CACnC,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,CAAC,IACH,QAAO,SAAS,OAAO,KAAK;CAM9B,IAAI;AACJ,KAAI;AACF,YAAU,IAAI,QAAQ,OAAO,KAAK;SAC5B;AAEN,SAAO,SAAS,OAAO,KAAK;;AAK9B,KAAI,QAAQ,IAAI,WAAW,GAAG,gBAAgB,IAAI,CAAC,GAAG,CACpD,QAAO,SAAS,QAAQ;AAG1B,QAAO,IAAI,SAAS,WAChB,mBAAmB,KAAK,UAAU,QAAQ,SAAS,IAAI,CAAC,GACxD,oBAAoB,KAAK,EAAE,MAAM,YAAY,EAAE,UAAU,QACvD,SAAS,IAAI,CACd","debugId":"702f30a5-c657-58b7-90a4-8f78c5fcdb2b"}