@alwaysmeticulous/backend-recorder-workerd 2.308.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 ADDED
@@ -0,0 +1,57 @@
1
+ # Meticulous Backend Recorder for Cloudflare Workers (workerd)
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.
4
+
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
+
7
+ ## Setup
8
+
9
+ 1. Install the shim:
10
+
11
+ ```bash
12
+ npm install @alwaysmeticulous/backend-recorder-workerd
13
+ ```
14
+
15
+ 2. Wrap your ES-module Worker handler:
16
+
17
+ ```ts
18
+ import { withMeticulous } from "@alwaysmeticulous/backend-recorder-workerd";
19
+
20
+ export default withMeticulous({
21
+ async fetch(request, env, ctx) {
22
+ // your app
23
+ },
24
+ });
25
+ ```
26
+
27
+ 3. Enable the `nodejs_als` (or `nodejs_compat`) compatibility flag in `wrangler.toml`:
28
+
29
+ ```toml
30
+ compatibility_flags = ["nodejs_als"]
31
+ ```
32
+
33
+ 4. Run your dev command through the Meticulous CLI, which starts the recorder sidecar and passes its URL to `wrangler dev` automatically:
34
+
35
+ ```bash
36
+ npx @alwaysmeticulous/cli record backend -- npx wrangler dev
37
+ ```
38
+
39
+ Or run the sidecar standalone with `npx @alwaysmeticulous/cli record backend` and point the shim at it yourself — the value must be a worker var/binding (host environment variables are not visible inside workerd), e.g. in `.dev.vars`:
40
+
41
+ ```
42
+ METICULOUS_SIDECAR_URL=http://127.0.0.1:9670
43
+ ```
44
+
45
+ or `wrangler dev --var METICULOUS_SIDECAR_URL:http://127.0.0.1:9670`.
46
+
47
+ 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.
48
+
49
+ ## What is captured
50
+
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
+ - **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
+
54
+ ## Limitations
55
+
56
+ - Only `fetch` egress is captured. Bindings (KV, D1, R2, Durable Objects, service bindings), WebSockets, and TCP sockets are not captured.
57
+ - `fetch` calls made outside a request handled by `withMeticulous` (e.g. in `scheduled` handlers) are not captured.
@@ -0,0 +1,15 @@
1
+ import { CapturedBody } from "./protocol.js";
2
+
3
+ //#region src/body-capture.d.ts
4
+ /** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */
5
+ declare const MAX_BODY_CAPTURE_SIZE: number;
6
+ /**
7
+ * Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as
8
+ * UTF-8. Stops (and marks the capture truncated) on the size cap or the time
9
+ * guard, so a long-lived stream can never pin the capture work. Returns
10
+ * undefined for absent bodies.
11
+ */
12
+ declare const readBodyWithCap: (stream: ReadableStream<Uint8Array> | null) => Promise<CapturedBody | undefined>;
13
+ //#endregion
14
+ export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap };
15
+ //# sourceMappingURL=body-capture.d.ts.map
@@ -0,0 +1,59 @@
1
+ //#region src/body-capture.ts
2
+ /** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */
3
+ const MAX_BODY_CAPTURE_SIZE = 256 * 1024;
4
+ /** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */
5
+ const BODY_READ_TIMEOUT_MS = 1e4;
6
+ const TIMED_OUT = Symbol("meticulous.bodyReadTimedOut");
7
+ /**
8
+ * Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as
9
+ * UTF-8. Stops (and marks the capture truncated) on the size cap or the time
10
+ * guard, so a long-lived stream can never pin the capture work. Returns
11
+ * undefined for absent bodies.
12
+ */
13
+ const readBodyWithCap = async (stream) => {
14
+ if (!stream) return;
15
+ const reader = stream.getReader();
16
+ const decoder = new TextDecoder("utf-8");
17
+ let text = "";
18
+ let bytes = 0;
19
+ let truncated = false;
20
+ let timer;
21
+ const timedOut = new Promise((resolve) => {
22
+ timer = setTimeout(() => resolve(TIMED_OUT), BODY_READ_TIMEOUT_MS);
23
+ });
24
+ try {
25
+ while (true) {
26
+ const result = await Promise.race([reader.read(), timedOut]);
27
+ if (result === TIMED_OUT) {
28
+ truncated = true;
29
+ break;
30
+ }
31
+ const { done, value } = result;
32
+ if (done) break;
33
+ if (!value || value.byteLength === 0) continue;
34
+ const remaining = MAX_BODY_CAPTURE_SIZE - bytes;
35
+ if (value.byteLength >= remaining) {
36
+ text += decoder.decode(value.subarray(0, remaining), { stream: true });
37
+ bytes += remaining;
38
+ truncated = truncated || value.byteLength > remaining;
39
+ const next = await Promise.race([reader.read(), timedOut]);
40
+ if (next === TIMED_OUT || !next.done) truncated = true;
41
+ break;
42
+ }
43
+ text += decoder.decode(value, { stream: true });
44
+ bytes += value.byteLength;
45
+ }
46
+ } finally {
47
+ clearTimeout(timer);
48
+ reader.cancel().catch(() => {});
49
+ }
50
+ text += decoder.decode();
51
+ return {
52
+ body: text,
53
+ truncated
54
+ };
55
+ };
56
+ //#endregion
57
+ export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap };
58
+
59
+ //# sourceMappingURL=body-capture.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body-capture.js","names":[],"sources":["../src/body-capture.ts"],"sourcesContent":["import type { CapturedBody } from \"./protocol\";\n\n/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */\nexport const MAX_BODY_CAPTURE_SIZE = 256 * 1024;\n\n/** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */\nconst BODY_READ_TIMEOUT_MS = 10_000;\n\nconst TIMED_OUT = Symbol(\"meticulous.bodyReadTimedOut\");\n\n/**\n * Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as\n * UTF-8. Stops (and marks the capture truncated) on the size cap or the time\n * guard, so a long-lived stream can never pin the capture work. Returns\n * undefined for absent bodies.\n */\nexport const readBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n if (!stream) {\n return undefined;\n }\n\n const reader = stream.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let text = \"\";\n let bytes = 0;\n let truncated = false;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<typeof TIMED_OUT>((resolve) => {\n timer = setTimeout(() => resolve(TIMED_OUT), BODY_READ_TIMEOUT_MS);\n });\n\n try {\n while (true) {\n const result = await Promise.race([reader.read(), timedOut]);\n if (result === TIMED_OUT) {\n truncated = true;\n break;\n }\n const { done, value } = result;\n if (done) {\n break;\n }\n if (!value || value.byteLength === 0) {\n continue;\n }\n const remaining = MAX_BODY_CAPTURE_SIZE - bytes;\n if (value.byteLength >= remaining) {\n text += decoder.decode(value.subarray(0, remaining), { stream: true });\n bytes += remaining;\n truncated = truncated || value.byteLength > remaining;\n // Even an exact-cap chunk means we stop reading, so a longer stream\n // counts as truncated.\n const next = await Promise.race([reader.read(), timedOut]);\n if (next === TIMED_OUT || !next.done) {\n truncated = true;\n }\n break;\n }\n text += decoder.decode(value, { stream: true });\n bytes += value.byteLength;\n }\n } finally {\n clearTimeout(timer);\n reader.cancel().catch(() => {});\n }\n\n text += decoder.decode();\n return { body: text, truncated };\n};\n"],"mappings":";;AAGA,MAAa,wBAAwB,MAAM;;AAG3C,MAAM,uBAAuB;AAE7B,MAAM,YAAY,OAAO,8BAA8B;;;;;;;AAQvD,MAAa,kBAAkB,OAC7B,WACsC;AACtC,KAAI,CAAC,OACH;CAGF,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,UAAU,IAAI,YAAY,QAAQ;CACxC,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,YAAY;CAEhB,IAAI;CACJ,MAAM,WAAW,IAAI,SAA2B,YAAY;AAC1D,UAAQ,iBAAiB,QAAQ,UAAU,EAAE,qBAAqB;GAClE;AAEF,KAAI;AACF,SAAO,MAAM;GACX,MAAM,SAAS,MAAM,QAAQ,KAAK,CAAC,OAAO,MAAM,EAAE,SAAS,CAAC;AAC5D,OAAI,WAAW,WAAW;AACxB,gBAAY;AACZ;;GAEF,MAAM,EAAE,MAAM,UAAU;AACxB,OAAI,KACF;AAEF,OAAI,CAAC,SAAS,MAAM,eAAe,EACjC;GAEF,MAAM,YAAY,wBAAwB;AAC1C,OAAI,MAAM,cAAc,WAAW;AACjC,YAAQ,QAAQ,OAAO,MAAM,SAAS,GAAG,UAAU,EAAE,EAAE,QAAQ,MAAM,CAAC;AACtE,aAAS;AACT,gBAAY,aAAa,MAAM,aAAa;IAG5C,MAAM,OAAO,MAAM,QAAQ,KAAK,CAAC,OAAO,MAAM,EAAE,SAAS,CAAC;AAC1D,QAAI,SAAS,aAAa,CAAC,KAAK,KAC9B,aAAY;AAEd;;AAEF,WAAQ,QAAQ,OAAO,OAAO,EAAE,QAAQ,MAAM,CAAC;AAC/C,YAAS,MAAM;;WAET;AACR,eAAa,MAAM;AACnB,SAAO,QAAQ,CAAC,YAAY,GAAG;;AAGjC,SAAQ,QAAQ,QAAQ;AACxB,QAAO;EAAE,MAAM;EAAM;EAAW"}
@@ -0,0 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/context.ts
3
+ const requestCaptureContext = new AsyncLocalStorage();
4
+ //#endregion
5
+ export { requestCaptureContext };
6
+
7
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","names":[],"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 */\nexport interface RequestCaptureContext {\n requestId: string;\n frontendSessionId: string | undefined;\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\nexport const requestCaptureContext =\n new AsyncLocalStorage<RequestCaptureContext>();\n"],"mappings":";;AAgBA,MAAa,wBACX,IAAI,mBAA0C"}
@@ -0,0 +1,5 @@
1
+ //#region src/fetch-patch.d.ts
2
+ declare const headersToRecord: (headers: Headers) => Record<string, string[]>;
3
+ //#endregion
4
+ export { headersToRecord };
5
+ //# sourceMappingURL=fetch-patch.d.ts.map
@@ -0,0 +1,127 @@
1
+ import { requestCaptureContext } from "./context.js";
2
+ import { readBodyWithCap } from "./body-capture.js";
3
+ import { warnOnce } from "./log.js";
4
+ import { CAPTURED_HEADERS } from "./protocol.js";
5
+ import { postCaptureEvents } from "./sidecar-client.js";
6
+ //#region src/fetch-patch.ts
7
+ const FETCH_PATCHED = Symbol.for("meticulous.workerd.fetchPatched");
8
+ let originalFetch;
9
+ /** The unpatched fetch — used for the shim's own sidecar requests. */
10
+ const getOriginalFetch = () => originalFetch ?? globalThis.fetch;
11
+ const capturedHeaders = new Set(CAPTURED_HEADERS);
12
+ const headersToRecord = (headers) => {
13
+ const record = {};
14
+ headers.forEach((value, name) => {
15
+ const lowerName = name.toLowerCase();
16
+ if (capturedHeaders.has(lowerName)) (record[lowerName] ??= []).push(value);
17
+ });
18
+ return record;
19
+ };
20
+ /**
21
+ * Replaces globalThis.fetch with a passive capture tee: the real fetch is
22
+ * called directly (the sidecar is never in the request path); request and
23
+ * response are cloned and reported to the sidecar in the background. Outside
24
+ * a withMeticulous request context the patch is a pure pass-through.
25
+ * Idempotent.
26
+ */
27
+ const installFetchPatch = () => {
28
+ const holder = globalThis;
29
+ if (holder[FETCH_PATCHED]) return;
30
+ holder[FETCH_PATCHED] = true;
31
+ originalFetch = globalThis.fetch.bind(globalThis);
32
+ globalThis.fetch = patchedFetch;
33
+ };
34
+ const patchedFetch = async (input, init) => {
35
+ const original = getOriginalFetch();
36
+ const ctx = requestCaptureContext.getStore();
37
+ if (!ctx) return original(input, init);
38
+ let request;
39
+ try {
40
+ request = new Request(input, init);
41
+ } catch {
42
+ return original(input, init);
43
+ }
44
+ if (request.url.startsWith(`${ctx.sidecarUrl}/`)) return original(request);
45
+ const startTimeMs = Date.now();
46
+ let meta;
47
+ let requestBodyPromise = Promise.resolve(void 0);
48
+ try {
49
+ meta = {
50
+ method: request.method,
51
+ url: request.url,
52
+ requestHeaders: headersToRecord(request.headers)
53
+ };
54
+ if (request.body) requestBodyPromise = readBodyWithCap(request.clone().body).catch(() => void 0);
55
+ } catch (error) {
56
+ warnOnce("outbound-request-capture", "Failed to capture an outbound request.", error);
57
+ meta = void 0;
58
+ }
59
+ let response;
60
+ try {
61
+ response = await original(request);
62
+ } catch (error) {
63
+ if (meta) {
64
+ const frozenMeta = meta;
65
+ const endTimeMs = Date.now();
66
+ const errorMessage = String(error);
67
+ reportInBackground(ctx, async () => {
68
+ const requestBody = await requestBodyPromise;
69
+ const event = {
70
+ kind: "outbound",
71
+ requestId: ctx.requestId,
72
+ ...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
73
+ ...frozenMeta,
74
+ ...requestBody !== void 0 ? { requestBody } : {},
75
+ startTimeMs,
76
+ endTimeMs,
77
+ error: errorMessage
78
+ };
79
+ await postCaptureEvents(original, ctx.sidecarUrl, [event]);
80
+ });
81
+ }
82
+ throw error;
83
+ }
84
+ if (meta) {
85
+ const frozenMeta = meta;
86
+ try {
87
+ const responseClone = response.clone();
88
+ const responseHeaders = headersToRecord(response.headers);
89
+ const statusCode = response.status;
90
+ const endTimeMs = Date.now();
91
+ ctx.waitUntil((async () => {
92
+ const [requestBody, responseBody] = await Promise.all([requestBodyPromise, readBodyWithCap(responseClone.body).catch(() => void 0)]);
93
+ const event = {
94
+ kind: "outbound",
95
+ requestId: ctx.requestId,
96
+ ...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
97
+ ...frozenMeta,
98
+ responseHeaders,
99
+ statusCode,
100
+ ...requestBody !== void 0 ? { requestBody } : {},
101
+ ...responseBody !== void 0 ? { responseBody } : {},
102
+ startTimeMs,
103
+ endTimeMs
104
+ };
105
+ await postCaptureEvents(original, ctx.sidecarUrl, [event]);
106
+ })().catch((error) => {
107
+ warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
108
+ }));
109
+ } catch (error) {
110
+ warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
111
+ }
112
+ }
113
+ return response;
114
+ };
115
+ const reportInBackground = (ctx, report) => {
116
+ try {
117
+ ctx.waitUntil(report().catch((error) => {
118
+ warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
119
+ }));
120
+ } catch (error) {
121
+ warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
122
+ }
123
+ };
124
+ //#endregion
125
+ export { getOriginalFetch, headersToRecord, installFetchPatch };
126
+
127
+ //# sourceMappingURL=fetch-patch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch-patch.js","names":[],"sources":["../src/fetch-patch.ts"],"sourcesContent":["import { readBodyWithCap } from \"./body-capture\";\nimport { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport {\n CAPTURED_HEADERS,\n type CapturedBody,\n type OutboundRequestEvent,\n} from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\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\nlet originalFetch: FetchFn | undefined;\n\n/** The unpatched fetch — used for the shim's own sidecar requests. */\nexport const getOriginalFetch = (): FetchFn =>\n originalFetch ?? globalThis.fetch;\n\nconst capturedHeaders = new Set<string>(CAPTURED_HEADERS);\n\nexport const headersToRecord = (headers: Headers): Record<string, string[]> => {\n const record: Record<string, string[]> = {};\n headers.forEach((value, name) => {\n const lowerName = name.toLowerCase();\n if (capturedHeaders.has(lowerName)) {\n (record[lowerName] ??= []).push(value);\n }\n });\n return record;\n};\n\n/**\n * Replaces globalThis.fetch with a passive capture tee: the real fetch is\n * called directly (the sidecar is never in the request path); request and\n * response are cloned and reported to the sidecar in the background. Outside\n * a withMeticulous request context the patch is a pure pass-through.\n * 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 originalFetch = 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 capture the shim's own reports to the sidecar.\n if (request.url.startsWith(`${ctx.sidecarUrl}/`)) {\n return original(request);\n }\n\n const startTimeMs = Date.now();\n let meta:\n | {\n method: string;\n url: string;\n requestHeaders: Record<string, string[]>;\n }\n | undefined;\n let requestBodyPromise: Promise<CapturedBody | undefined> =\n Promise.resolve(undefined);\n try {\n meta = {\n method: request.method,\n url: request.url,\n requestHeaders: headersToRecord(request.headers),\n };\n if (request.body) {\n const bodyClone = request.clone();\n requestBodyPromise = readBodyWithCap(bodyClone.body).catch(\n () => undefined,\n );\n }\n } catch (error) {\n warnOnce(\n \"outbound-request-capture\",\n \"Failed to capture an outbound request.\",\n error,\n );\n meta = undefined;\n }\n\n let response: Response;\n try {\n response = await original(request);\n } catch (error) {\n if (meta) {\n const frozenMeta = meta;\n const endTimeMs = Date.now();\n const errorMessage = String(error);\n reportInBackground(ctx, async () => {\n const requestBody = await requestBodyPromise;\n const event: OutboundRequestEvent = {\n kind: \"outbound\",\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n ...frozenMeta,\n ...(requestBody !== undefined ? { requestBody } : {}),\n startTimeMs,\n endTimeMs,\n error: errorMessage,\n };\n await postCaptureEvents(original, ctx.sidecarUrl, [event]);\n });\n }\n throw error;\n }\n\n if (meta) {\n const frozenMeta = meta;\n try {\n const responseClone = response.clone();\n const responseHeaders = headersToRecord(response.headers);\n const statusCode = response.status;\n const endTimeMs = Date.now();\n ctx.waitUntil(\n (async () => {\n const [requestBody, responseBody] = await Promise.all([\n requestBodyPromise,\n readBodyWithCap(responseClone.body).catch(() => undefined),\n ]);\n const event: OutboundRequestEvent = {\n kind: \"outbound\",\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n ...frozenMeta,\n responseHeaders,\n statusCode,\n ...(requestBody !== undefined ? { requestBody } : {}),\n ...(responseBody !== undefined ? { responseBody } : {}),\n startTimeMs,\n endTimeMs,\n };\n await postCaptureEvents(original, ctx.sidecarUrl, [event]);\n })().catch((error) => {\n warnOnce(\n \"outbound-response-capture\",\n \"Failed to capture an outbound response.\",\n error,\n );\n }),\n );\n } catch (error) {\n warnOnce(\n \"outbound-response-capture\",\n \"Failed to capture an outbound response.\",\n error,\n );\n }\n }\n\n return response;\n};\n\nconst reportInBackground = (\n ctx: { waitUntil: (p: Promise<unknown>) => void },\n report: () => Promise<void>,\n): void => {\n try {\n ctx.waitUntil(\n report().catch((error) => {\n warnOnce(\n \"outbound-error-report\",\n \"Failed to report a failed outbound request.\",\n error,\n );\n }),\n );\n } catch (error) {\n warnOnce(\n \"outbound-error-report\",\n \"Failed to report a failed outbound request.\",\n error,\n );\n }\n};\n"],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ import { CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundRequestEvent, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER } from "./protocol.js";
2
+ import { MAX_BODY_CAPTURE_SIZE, readBodyWithCap } from "./body-capture.js";
3
+ import { headersToRecord } from "./fetch-patch.js";
4
+
5
+ //#region src/index.d.ts
6
+ /** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */
7
+ interface MeticulousExecutionContext {
8
+ waitUntil(promise: Promise<unknown>): void;
9
+ }
10
+ /** Structural subset of an ES-module Worker handler (`export default {...}`). */
11
+ interface MeticulousWorkerHandler<Env = never> {
12
+ fetch(request: Request, env: Env, ctx: MeticulousExecutionContext): Response | Promise<Response>;
13
+ }
14
+ interface WithMeticulousOptions {
15
+ /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */
16
+ sidecarUrl?: string;
17
+ }
18
+ /**
19
+ * Wraps an ES-module Worker handler so that, when a Meticulous recorder
20
+ * sidecar is configured, inbound requests and outgoing `fetch` calls are
21
+ * reported to it as backend capture events:
22
+ *
23
+ * export default withMeticulous({
24
+ * async fetch(request, env, ctx) { ... },
25
+ * });
26
+ *
27
+ * Activation requires a `METICULOUS_SIDECAR_URL` var/binding (e.g. via
28
+ * `.dev.vars` or `wrangler dev --var`) or `options.sidecarUrl`; without one
29
+ * the wrapper is a complete pass-through, so it is safe to keep in deployed
30
+ * code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag.
31
+ * A capture failure or unreachable sidecar never affects the app.
32
+ */
33
+ declare const withMeticulous: <H extends MeticulousWorkerHandler<never>>(handler: H, options?: WithMeticulousOptions) => H;
34
+ //#endregion
35
+ export { CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, MAX_BODY_CAPTURE_SIZE, MeticulousExecutionContext, MeticulousWorkerHandler, type OutboundRequestEvent, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, WithMeticulousOptions, headersToRecord, readBodyWithCap, withMeticulous };
36
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,116 @@
1
+ import { requestCaptureContext } from "./context.js";
2
+ import { MAX_BODY_CAPTURE_SIZE, readBodyWithCap } from "./body-capture.js";
3
+ import { warnOnce } from "./log.js";
4
+ import { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER } from "./protocol.js";
5
+ import { postCaptureEvents } from "./sidecar-client.js";
6
+ import { getOriginalFetch, headersToRecord, installFetchPatch } from "./fetch-patch.js";
7
+ //#region src/index.ts
8
+ /**
9
+ * Wraps an ES-module Worker handler so that, when a Meticulous recorder
10
+ * sidecar is configured, inbound requests and outgoing `fetch` calls are
11
+ * reported to it as backend capture events:
12
+ *
13
+ * export default withMeticulous({
14
+ * async fetch(request, env, ctx) { ... },
15
+ * });
16
+ *
17
+ * Activation requires a `METICULOUS_SIDECAR_URL` var/binding (e.g. via
18
+ * `.dev.vars` or `wrangler dev --var`) or `options.sidecarUrl`; without one
19
+ * the wrapper is a complete pass-through, so it is safe to keep in deployed
20
+ * code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag.
21
+ * A capture failure or unreachable sidecar never affects the app.
22
+ */
23
+ const withMeticulous = (handler, options) => {
24
+ const wrappedFetch = async (request, env, ctx) => {
25
+ const invokeHandler = () => handler.fetch(request, env, ctx);
26
+ let sidecarUrl;
27
+ try {
28
+ sidecarUrl = resolveSidecarUrl(options, env);
29
+ if (sidecarUrl !== void 0) installFetchPatch();
30
+ } catch (error) {
31
+ warnOnce("shim-init", "Failed to initialize the Meticulous backend recorder shim — recording disabled.", error);
32
+ sidecarUrl = void 0;
33
+ }
34
+ if (sidecarUrl === void 0) return invokeHandler();
35
+ const captureCtx = buildCaptureContext(request, sidecarUrl, ctx);
36
+ const startTimeMs = Date.now();
37
+ let requestHeaders = {};
38
+ let method = "GET";
39
+ let url = "";
40
+ try {
41
+ requestHeaders = headersToRecord(request.headers);
42
+ method = request.method;
43
+ url = request.url;
44
+ } catch (error) {
45
+ warnOnce("inbound-capture", "Failed to capture the inbound request.", error);
46
+ }
47
+ const reportInbound = (outcome) => {
48
+ try {
49
+ const event = {
50
+ kind: "inbound",
51
+ requestId: captureCtx.requestId,
52
+ ...captureCtx.frontendSessionId !== void 0 ? { frontendSessionId: captureCtx.frontendSessionId } : {},
53
+ method,
54
+ url,
55
+ requestHeaders,
56
+ startTimeMs,
57
+ endTimeMs: Date.now(),
58
+ ...outcome
59
+ };
60
+ captureCtx.waitUntil(postCaptureEvents(getOriginalFetch(), sidecarUrl, [event]));
61
+ } catch (error) {
62
+ warnOnce("inbound-report", "Failed to report the inbound request.", error);
63
+ }
64
+ };
65
+ let response;
66
+ try {
67
+ response = await requestCaptureContext.run(captureCtx, invokeHandler);
68
+ } catch (error) {
69
+ reportInbound({ error: String(error) });
70
+ throw error;
71
+ }
72
+ try {
73
+ reportInbound({
74
+ statusCode: response.status,
75
+ responseHeaders: headersToRecord(response.headers)
76
+ });
77
+ } catch (error) {
78
+ warnOnce("inbound-report", "Failed to report the inbound request.", error);
79
+ }
80
+ return response;
81
+ };
82
+ return {
83
+ ...handler,
84
+ fetch: wrappedFetch
85
+ };
86
+ };
87
+ const resolveSidecarUrl = (options, env) => {
88
+ const fromEnv = env !== null && typeof env === "object" ? env["METICULOUS_SIDECAR_URL"] : void 0;
89
+ const raw = options?.sidecarUrl ?? fromEnv;
90
+ if (typeof raw !== "string" || raw.length === 0) return;
91
+ return raw.replace(/\/+$/, "");
92
+ };
93
+ const buildCaptureContext = (request, sidecarUrl, ctx) => {
94
+ let frontendSessionId;
95
+ try {
96
+ frontendSessionId = request.headers.get("x-meticulous-session-id") ?? void 0;
97
+ } catch {
98
+ frontendSessionId = void 0;
99
+ }
100
+ return {
101
+ requestId: crypto.randomUUID(),
102
+ frontendSessionId,
103
+ sidecarUrl,
104
+ waitUntil: (promise) => {
105
+ try {
106
+ ctx.waitUntil(promise);
107
+ } catch (error) {
108
+ warnOnce("wait-until", "ctx.waitUntil failed — capture events may be lost.", error);
109
+ }
110
+ }
111
+ };
112
+ };
113
+ //#endregion
114
+ export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, MAX_BODY_CAPTURE_SIZE, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, headersToRecord, readBodyWithCap, withMeticulous };
115
+
116
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { type RequestCaptureContext, requestCaptureContext } from \"./context\";\nimport {\n getOriginalFetch,\n headersToRecord,\n installFetchPatch,\n} from \"./fetch-patch\";\nimport { warnOnce } from \"./log\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n} from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\nexport type {\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n OutboundRequestEvent,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n} from \"./protocol\";\nexport { MAX_BODY_CAPTURE_SIZE, readBodyWithCap } from \"./body-capture\";\nexport { headersToRecord } from \"./fetch-patch\";\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\n/**\n * Wraps an ES-module Worker handler so that, when a Meticulous recorder\n * sidecar is configured, inbound requests and outgoing `fetch` calls are\n * reported to it as backend capture events:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * Activation requires a `METICULOUS_SIDECAR_URL` var/binding (e.g. via\n * `.dev.vars` or `wrangler dev --var`) or `options.sidecarUrl`; without one\n * the wrapper is a complete pass-through, so it is safe to keep in deployed\n * code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag.\n * A 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 try {\n sidecarUrl = resolveSidecarUrl(options, env);\n if (sidecarUrl !== undefined) {\n installFetchPatch();\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n sidecarUrl = undefined;\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\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 requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n waitUntil: (promise) => {\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 };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAgEA,MAAa,kBACX,SACA,YACM;CACN,MAAM,eAAe,OACnB,SACA,KACA,QACsB;EACtB,MAAM,sBAAsB,QAAQ,MAAM,SAAS,KAAc,IAAI;EAErE,IAAI;AACJ,MAAI;AACF,gBAAa,kBAAkB,SAAS,IAAI;AAC5C,OAAI,eAAe,KAAA,EACjB,oBAAmB;WAEd,OAAO;AACd,YACE,aACA,mFACA,MACD;AACD,gBAAa,KAAA;;AAEf,MAAI,eAAe,KAAA,EACjB,QAAO,eAAe;EAGxB,MAAM,aAAa,oBAAoB,SAAS,YAAY,IAAI;EAChE,MAAM,cAAc,KAAK,KAAK;EAC9B,IAAI,iBAA2C,EAAE;EACjD,IAAI,SAAS;EACb,IAAI,MAAM;AACV,MAAI;AACF,oBAAiB,gBAAgB,QAAQ,QAAQ;AACjD,YAAS,QAAQ;AACjB,SAAM,QAAQ;WACP,OAAO;AACd,YACE,mBACA,0CACA,MACD;;EAGH,MAAM,iBACJ,YAGS;AACT,OAAI;IACF,MAAM,QAA6B;KACjC,MAAM;KACN,WAAW,WAAW;KACtB,GAAI,WAAW,sBAAsB,KAAA,IACjC,EAAE,mBAAmB,WAAW,mBAAmB,GACnD,EAAE;KACN;KACA;KACA;KACA;KACA,WAAW,KAAK,KAAK;KACrB,GAAG;KACJ;AACD,eAAW,UACT,kBAAkB,kBAAkB,EAAE,YAAY,CAAC,MAAM,CAAC,CAC3D;YACM,OAAO;AACd,aACE,kBACA,yCACA,MACD;;;EAIL,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,sBAAsB,IAAI,YAAY,cAAc;WAC9D,OAAO;AACd,iBAAc,EAAE,OAAO,OAAO,MAAM,EAAE,CAAC;AACvC,SAAM;;AAGR,MAAI;AACF,iBAAc;IACZ,YAAY,SAAS;IACrB,iBAAiB,gBAAgB,SAAS,QAAQ;IACnD,CAAC;WACK,OAAO;AACd,YACE,kBACA,yCACA,MACD;;AAEH,SAAO;;AAKT,QAAO;EAAE,GAAG;EAAS,OAAO;EAAc;;AAG5C,MAAM,qBACJ,SACA,QACuB;CACvB,MAAM,UACJ,QAAQ,QAAQ,OAAO,QAAQ,WAC1B,IAAgC,4BACjC,KAAA;CACN,MAAM,MAAM,SAAS,cAAc;AACnC,KAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,EAC5C;AAEF,QAAO,IAAI,QAAQ,QAAQ,GAAG;;AAGhC,MAAM,uBACJ,SACA,YACA,QAC0B;CAC1B,IAAI;AACJ,KAAI;AACF,sBACE,QAAQ,QAAQ,IAAA,0BAA+B,IAAI,KAAA;SAC/C;AACN,sBAAoB,KAAA;;AAEtB,QAAO;EACL,WAAW,OAAO,YAAY;EAC9B;EACA;EACA,YAAY,YAAY;AACtB,OAAI;AACF,QAAI,UAAU,QAAQ;YACf,OAAO;AACd,aACE,cACA,sDACA,MACD;;;EAGN"}
package/dist/log.js ADDED
@@ -0,0 +1,13 @@
1
+ //#region src/log.ts
2
+ const warned = /* @__PURE__ */ new Set();
3
+ /** console.warn once per unique key — capture problems must never spam or break the app. */
4
+ const warnOnce = (key, message, error) => {
5
+ if (warned.has(key)) return;
6
+ warned.add(key);
7
+ if (error === void 0) console.warn(`[meticulous] ${message}`);
8
+ else console.warn(`[meticulous] ${message}`, error);
9
+ };
10
+ //#endregion
11
+ export { warnOnce };
12
+
13
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.js","names":[],"sources":["../src/log.ts"],"sourcesContent":["const warned = new Set<string>();\n\n/** console.warn once per unique key — capture problems must never spam or break the app. */\nexport const warnOnce = (\n key: string,\n message: string,\n error?: unknown,\n): void => {\n if (warned.has(key)) {\n return;\n }\n warned.add(key);\n if (error === undefined) {\n console.warn(`[meticulous] ${message}`);\n } else {\n console.warn(`[meticulous] ${message}`, error);\n }\n};\n"],"mappings":";AAAA,MAAM,yBAAS,IAAI,KAAa;;AAGhC,MAAa,YACX,KACA,SACA,UACS;AACT,KAAI,OAAO,IAAI,IAAI,CACjB;AAEF,QAAO,IAAI,IAAI;AACf,KAAI,UAAU,KAAA,EACZ,SAAQ,KAAK,gBAAgB,UAAU;KAEvC,SAAQ,KAAK,gBAAgB,WAAW,MAAM"}
@@ -0,0 +1,69 @@
1
+ //#region src/protocol.d.ts
2
+ /**
3
+ * Wire protocol between the in-worker shim and the Meticulous backend
4
+ * recorder sidecar. The sidecar imports these types (type-only), so this file
5
+ * is the single source of truth for the shape of capture events.
6
+ */
7
+ declare const SIDECAR_PROTOCOL_VERSION = "1";
8
+ /** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */
9
+ declare const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
10
+ /**
11
+ * Header the Meticulous frontend recorder stamps on requests made by the app
12
+ * under recording; its value ties backend spans to the frontend session.
13
+ */
14
+ declare const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
15
+ declare const SIDECAR_EVENTS_PATH = "/v1/events";
16
+ /**
17
+ * The only headers persisted on capture events. Everything else — notably
18
+ * authorization, cookie/set-cookie, and API-key headers — is dropped at
19
+ * capture time, before it ever leaves the worker. Mirrors the Node backend
20
+ * recorder, which persists only content-type plus the Meticulous session
21
+ * header.
22
+ */
23
+ declare const CAPTURED_HEADERS: readonly ["content-type", "x-meticulous-session-id"];
24
+ interface CapturedBody {
25
+ /** UTF-8 decoded body, truncated to the capture cap. */
26
+ body: string;
27
+ /** True when the body exceeded the capture cap or the read was cut short. */
28
+ truncated: boolean;
29
+ }
30
+ interface BaseRequestEvent {
31
+ /** Correlates outbound events to the inbound request being handled. */
32
+ requestId: string;
33
+ /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */
34
+ frontendSessionId?: string;
35
+ method: string;
36
+ /** Full URL including query string. */
37
+ url: string;
38
+ /** Lower-cased header name → values. */
39
+ requestHeaders: Record<string, string[]>;
40
+ responseHeaders?: Record<string, string[]>;
41
+ statusCode?: number;
42
+ startTimeMs: number;
43
+ endTimeMs: number;
44
+ /** Set when the request failed before a response was produced. */
45
+ error?: string;
46
+ }
47
+ /**
48
+ * The worker's own inbound request (becomes a SERVER span). Bodies are not
49
+ * captured, matching the Node backend recorder's SERVER-span contract.
50
+ */
51
+ interface InboundRequestEvent extends BaseRequestEvent {
52
+ kind: "inbound";
53
+ }
54
+ /**
55
+ * An outgoing `fetch` call made while handling an inbound request (becomes a
56
+ * CLIENT span).
57
+ */
58
+ interface OutboundRequestEvent extends BaseRequestEvent {
59
+ kind: "outbound";
60
+ requestBody?: CapturedBody;
61
+ responseBody?: CapturedBody;
62
+ }
63
+ type CaptureEvent = InboundRequestEvent | OutboundRequestEvent;
64
+ interface CaptureEventsPayload {
65
+ events: CaptureEvent[];
66
+ }
67
+ //#endregion
68
+ export { CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundRequestEvent, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER };
69
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1,27 @@
1
+ //#region src/protocol.ts
2
+ /**
3
+ * Wire protocol between the in-worker shim and the Meticulous backend
4
+ * recorder sidecar. The sidecar imports these types (type-only), so this file
5
+ * is the single source of truth for the shape of capture events.
6
+ */
7
+ const SIDECAR_PROTOCOL_VERSION = "1";
8
+ /** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */
9
+ const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
10
+ /**
11
+ * Header the Meticulous frontend recorder stamps on requests made by the app
12
+ * under recording; its value ties backend spans to the frontend session.
13
+ */
14
+ const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
15
+ const SIDECAR_EVENTS_PATH = "/v1/events";
16
+ /**
17
+ * The only headers persisted on capture events. Everything else — notably
18
+ * authorization, cookie/set-cookie, and API-key headers — is dropped at
19
+ * capture time, before it ever leaves the worker. Mirrors the Node backend
20
+ * recorder, which persists only content-type plus the Meticulous session
21
+ * header.
22
+ */
23
+ const CAPTURED_HEADERS = ["content-type", FRONTEND_SESSION_ID_HEADER];
24
+ //#endregion
25
+ export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER };
26
+
27
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","names":[],"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 * 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\nexport type CaptureEvent = InboundRequestEvent | OutboundRequestEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n"],"mappings":";;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;;;AASnC,MAAa,mBAAmB,CAC9B,gBACA,2BACD"}
@@ -0,0 +1,25 @@
1
+ import { warnOnce } from "./log.js";
2
+ import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER } from "./protocol.js";
3
+ //#region src/sidecar-client.ts
4
+ /** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */
5
+ const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
6
+ try {
7
+ const payload = { events };
8
+ const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {
9
+ method: "POST",
10
+ headers: {
11
+ "content-type": "application/json",
12
+ [SIDECAR_PROTOCOL_VERSION_HEADER]: "1"
13
+ },
14
+ body: JSON.stringify(payload)
15
+ });
16
+ if (!response.ok) warnOnce("sidecar-rejected", `Meticulous sidecar rejected capture events (HTTP ${response.status}).`);
17
+ await response.arrayBuffer().catch(() => void 0);
18
+ } catch (error) {
19
+ warnOnce("sidecar-unreachable", "Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.", error);
20
+ }
21
+ };
22
+ //#endregion
23
+ export { postCaptureEvents };
24
+
25
+ //# sourceMappingURL=sidecar-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sidecar-client.js","names":[],"sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n} from \"./protocol\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n events: CaptureEvent[],\n): Promise<void> => {\n try {\n const payload: CaptureEventsPayload = { events };\n const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n });\n if (!response.ok) {\n warnOnce(\n \"sidecar-rejected\",\n `Meticulous sidecar rejected capture events (HTTP ${response.status}).`,\n );\n }\n await response.arrayBuffer().catch(() => undefined);\n } catch (error) {\n warnOnce(\n \"sidecar-unreachable\",\n \"Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.\",\n error,\n );\n }\n};\n"],"mappings":";;;;AAYA,MAAa,oBAAoB,OAC/B,SACA,YACA,WACkB;AAClB,KAAI;EACF,MAAM,UAAgC,EAAE,QAAQ;EAChD,MAAM,WAAW,MAAM,QAAQ,GAAG,aAAa,uBAAuB;GACpE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC9B,CAAC;AACF,MAAI,CAAC,SAAS,GACZ,UACE,oBACA,oDAAoD,SAAS,OAAO,IACrE;AAEH,QAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;UAC5C,OAAO;AACd,WACE,uBACA,4GACA,MACD"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@alwaysmeticulous/backend-recorder-workerd",
3
+ "version": "2.308.0",
4
+ "type": "module",
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
+ "license": "ISC",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": "./dist/index.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "keywords": [
17
+ "meticulous",
18
+ "cloudflare",
19
+ "workers",
20
+ "workerd",
21
+ "recorder"
22
+ ],
23
+ "scripts": {
24
+ "clean": "rimraf dist tsconfig.tsbuildinfo",
25
+ "build": "tsdown",
26
+ "dev": "tsdown --watch",
27
+ "format": "prettier --write src",
28
+ "lint": "oxlint --type-aware src",
29
+ "lint:commit": "eslint --cache $(git diff --relative --name-only --diff-filter=ACMRTUXB master | grep -E \"(.js$|.ts$|.tsx$)\")",
30
+ "lint:fix": "oxlint --type-aware src --fix",
31
+ "depcheck": "depcheck --ignore-patterns=dist",
32
+ "test": "vitest run --passWithNoTests"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.0.0",
36
+ "tsdown": "^0.21.7",
37
+ "vitest": "catalog:"
38
+ },
39
+ "author": {
40
+ "name": "The Meticulous Team",
41
+ "email": "eng@meticulous.ai",
42
+ "url": "https://meticulous.ai"
43
+ },
44
+ "engines": {
45
+ "node": ">=20.19.0"
46
+ },
47
+ "homepage": "https://github.com/alwaysmeticulous/meticulous-sdk",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/alwaysmeticulous/meticulous-sdk.git",
51
+ "directory": "packages/backend-recorder-workerd"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/alwaysmeticulous/meticulous-sdk/issues"
55
+ }
56
+ }