@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 +57 -0
- package/dist/body-capture.d.ts +15 -0
- package/dist/body-capture.js +59 -0
- package/dist/body-capture.js.map +1 -0
- package/dist/context.js +7 -0
- package/dist/context.js.map +1 -0
- package/dist/fetch-patch.d.ts +5 -0
- package/dist/fetch-patch.js +127 -0
- package/dist/fetch-patch.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +116 -0
- package/dist/index.js.map +1 -0
- package/dist/log.js +13 -0
- package/dist/log.js.map +1 -0
- package/dist/protocol.d.ts +69 -0
- package/dist/protocol.js +27 -0
- package/dist/protocol.js.map +1 -0
- package/dist/sidecar-client.js +25 -0
- package/dist/sidecar-client.js.map +1 -0
- package/package.json +56 -0
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"}
|
package/dist/context.js
ADDED
|
@@ -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,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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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
package/dist/log.js.map
ADDED
|
@@ -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
|
package/dist/protocol.js
ADDED
|
@@ -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
|
+
}
|