@somewhatintelligent/kit 0.0.1-beta.1783705886.297c5be

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.
@@ -0,0 +1,319 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { requireRequestLog } from "../index";
3
+ import { instrumented, logged } from "../instrumented";
4
+
5
+ interface FakeMeta {
6
+ requestId: string;
7
+ actor: { kind: "user" | "service" | "anonymous"; userId?: string; serviceName?: string };
8
+ callerApp?: string;
9
+ }
10
+
11
+ function actorId(actor: FakeMeta["actor"]): string | null {
12
+ if (actor.kind === "user") return actor.userId ?? null;
13
+ if (actor.kind === "service") return actor.serviceName ?? null;
14
+ return null;
15
+ }
16
+
17
+ const baseMeta: FakeMeta = {
18
+ requestId: "req_01",
19
+ actor: { kind: "user", userId: "user_01" },
20
+ callerApp: "test_caller",
21
+ };
22
+
23
+ let logSpy: ReturnType<typeof vi.spyOn>;
24
+ let errorSpy: ReturnType<typeof vi.spyOn>;
25
+
26
+ beforeEach(() => {
27
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
28
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
29
+ });
30
+
31
+ afterEach(() => {
32
+ logSpy.mockRestore();
33
+ errorSpy.mockRestore();
34
+ });
35
+
36
+ function makeConfig() {
37
+ return {
38
+ service: "fake_service",
39
+ resolveContext: ({ args }: { methodName: string; args: unknown[]; instance: unknown }) => {
40
+ const meta = args[1] as FakeMeta;
41
+ return {
42
+ requestId: meta.requestId,
43
+ actorKind: meta.actor.kind,
44
+ actorId: actorId(meta.actor),
45
+ callerApp: meta.callerApp,
46
+ };
47
+ },
48
+ deriveOutcome: (ret: unknown) => {
49
+ const r = ret as { ok: boolean; error?: string };
50
+ return r.ok ? "ok" : r.error;
51
+ },
52
+ };
53
+ }
54
+
55
+ describe("@instrumented class decorator", () => {
56
+ test("wraps every async method on the prototype", async () => {
57
+ @instrumented(makeConfig())
58
+ class Service {
59
+ async doThing(input: { id: string }, _meta: FakeMeta) {
60
+ return { ok: true, value: input.id };
61
+ }
62
+ async otherThing(_input: object, _meta: FakeMeta) {
63
+ return { ok: true, value: "other" };
64
+ }
65
+ }
66
+
67
+ const svc = new Service();
68
+ await svc.doThing({ id: "x" }, baseMeta);
69
+ await svc.otherThing({}, baseMeta);
70
+
71
+ expect(logSpy).toHaveBeenCalledTimes(2);
72
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({
73
+ service: "fake_service",
74
+ event: "rpc",
75
+ operation: "fake_service.doThing",
76
+ outcome: "ok",
77
+ request_id: "req_01",
78
+ actor_kind: "user",
79
+ actor_id: "user_01",
80
+ caller_app: "test_caller",
81
+ });
82
+ expect(logSpy.mock.calls[1]![0]).toMatchObject({
83
+ operation: "fake_service.otherThing",
84
+ });
85
+ });
86
+
87
+ test("derives outcome from return value via deriveOutcome", async () => {
88
+ @instrumented(makeConfig())
89
+ class Service {
90
+ async failing(_i: object, _m: FakeMeta) {
91
+ return { ok: false, error: "not_found" };
92
+ }
93
+ }
94
+
95
+ await new Service().failing({}, baseMeta);
96
+ expect(logSpy).toHaveBeenCalledTimes(1);
97
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({ outcome: "not_found" });
98
+ });
99
+
100
+ test("emits internal_error + rethrows on throw", async () => {
101
+ @instrumented(makeConfig())
102
+ class Service {
103
+ async kaboom(_i: object, _m: FakeMeta): Promise<{ ok: boolean }> {
104
+ throw new Error("boom");
105
+ }
106
+ }
107
+
108
+ await expect(new Service().kaboom({}, baseMeta)).rejects.toThrow("boom");
109
+ expect(errorSpy).toHaveBeenCalledTimes(1);
110
+ expect(errorSpy.mock.calls[0]![0]).toMatchObject({
111
+ outcome: "internal_error",
112
+ error_message: "boom",
113
+ operation: "fake_service.kaboom",
114
+ });
115
+ });
116
+
117
+ test("ALS scope is open inside method body — requireRequestLog().add works", async () => {
118
+ @instrumented(makeConfig())
119
+ class Service {
120
+ async doThing(input: { id: string }, _meta: FakeMeta) {
121
+ requireRequestLog().add({ resource_id: input.id, custom: "field" });
122
+ return { ok: true };
123
+ }
124
+ }
125
+
126
+ await new Service().doThing({ id: "res_01" }, baseMeta);
127
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({
128
+ resource_id: "res_01",
129
+ custom: "field",
130
+ });
131
+ });
132
+
133
+ test("@logged.skip excludes method from instrumentation", async () => {
134
+ @instrumented(makeConfig())
135
+ class Service {
136
+ async tracked(_i: object, _m: FakeMeta) {
137
+ return { ok: true };
138
+ }
139
+
140
+ // oxlint-disable-next-line typescript/unbound-method -- decorator reference, not a call
141
+ @logged.skip
142
+ async healthCheck() {
143
+ return { status: "healthy" };
144
+ }
145
+ }
146
+
147
+ const svc = new Service();
148
+ await svc.tracked({}, baseMeta);
149
+ await svc.healthCheck();
150
+
151
+ // Only the tracked() call emits.
152
+ expect(logSpy).toHaveBeenCalledTimes(1);
153
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({ operation: "fake_service.tracked" });
154
+ });
155
+
156
+ test("operation override via resolveContext is respected", async () => {
157
+ @instrumented({
158
+ service: "fake_service",
159
+ resolveContext: ({ methodName, args }) => {
160
+ const meta = args[1] as FakeMeta;
161
+ return {
162
+ operation: `custom.${methodName}.override`,
163
+ requestId: meta.requestId,
164
+ actorKind: meta.actor.kind,
165
+ actorId: actorId(meta.actor),
166
+ callerApp: meta.callerApp,
167
+ };
168
+ },
169
+ })
170
+ class Service {
171
+ async someOp(_i: object, _m: FakeMeta) {
172
+ return { ok: true };
173
+ }
174
+ }
175
+
176
+ await new Service().someOp({}, baseMeta);
177
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({
178
+ operation: "custom.someOp.override",
179
+ });
180
+ });
181
+
182
+ test("preserves return value through wrapper", async () => {
183
+ @instrumented(makeConfig())
184
+ class Service {
185
+ async doThing(_i: object, _m: FakeMeta) {
186
+ return { ok: true, value: 42 };
187
+ }
188
+ }
189
+
190
+ const result = await new Service().doThing({}, baseMeta);
191
+ expect(result).toEqual({ ok: true, value: 42 });
192
+ });
193
+
194
+ test("works on classes that extend a base class (own-method only)", async () => {
195
+ class Base {
196
+ async baseMethod() {
197
+ return { ok: true };
198
+ }
199
+ }
200
+
201
+ @instrumented(makeConfig())
202
+ class Derived extends Base {
203
+ async ownMethod(_i: object, _m: FakeMeta) {
204
+ return { ok: true, value: "own" };
205
+ }
206
+ }
207
+
208
+ const d = new Derived();
209
+ await d.ownMethod({}, baseMeta);
210
+ await d.baseMethod();
211
+
212
+ // Only ownMethod is wrapped; Base.baseMethod is on Base.prototype, not Derived.prototype.
213
+ expect(logSpy).toHaveBeenCalledTimes(1);
214
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({ operation: "fake_service.ownMethod" });
215
+ });
216
+
217
+ test("multiple instances share wrapped prototype methods", async () => {
218
+ @instrumented(makeConfig())
219
+ class Service {
220
+ async doThing(_i: object, _m: FakeMeta) {
221
+ return { ok: true };
222
+ }
223
+ }
224
+
225
+ const a = new Service();
226
+ const b = new Service();
227
+ await a.doThing({}, baseMeta);
228
+ await b.doThing({}, baseMeta);
229
+ expect(logSpy).toHaveBeenCalledTimes(2);
230
+ });
231
+
232
+ test("onError converts throws to a returned value (Result-style APIs)", async () => {
233
+ @instrumented({
234
+ ...makeConfig(),
235
+ onError: (e: unknown) => ({
236
+ ok: false,
237
+ error: "internal_error",
238
+ message: e instanceof Error ? e.message : String(e),
239
+ }),
240
+ })
241
+ class Service {
242
+ async kaboom(_i: object, _m: FakeMeta): Promise<{ ok: boolean }> {
243
+ throw new Error("boom");
244
+ }
245
+ }
246
+
247
+ const result = await new Service().kaboom({}, baseMeta);
248
+ // Throw was converted, NOT propagated.
249
+ expect(result).toEqual({ ok: false, error: "internal_error", message: "boom" });
250
+ // Line still emitted at error level with internal_error outcome.
251
+ expect(errorSpy).toHaveBeenCalledTimes(1);
252
+ expect(errorSpy.mock.calls[0]![0]).toMatchObject({
253
+ outcome: "internal_error",
254
+ error_message: "boom",
255
+ });
256
+ });
257
+
258
+ test("onError unset → throws still propagate (default behavior preserved)", async () => {
259
+ @instrumented(makeConfig())
260
+ class Service {
261
+ async kaboom(_i: object, _m: FakeMeta): Promise<{ ok: boolean }> {
262
+ throw new Error("rethrown");
263
+ }
264
+ }
265
+ await expect(new Service().kaboom({}, baseMeta)).rejects.toThrow("rethrown");
266
+ });
267
+
268
+ // A resolveContext failure runs BEFORE the main scope opens. It must NOT slip
269
+ // the net as a silent `outcome:exception` with empty logs (the roadie
270
+ // caller_app-misconfig hole) — it has to emit an actionable canonical line.
271
+ test("resolveContext throw emits a canonical error line + honors onError", async () => {
272
+ @instrumented({
273
+ service: "fake_service",
274
+ resolveContext: () => {
275
+ throw new Error("ROADIE binding missing props.callerApp");
276
+ },
277
+ onError: (e: unknown) => ({
278
+ ok: false,
279
+ error: "internal_error",
280
+ message: e instanceof Error ? e.message : String(e),
281
+ }),
282
+ })
283
+ class Service {
284
+ async doThing(_i: object, _m: FakeMeta): Promise<{ ok: boolean }> {
285
+ return { ok: true };
286
+ }
287
+ }
288
+
289
+ const result = await new Service().doThing({}, baseMeta);
290
+ // Converted via onError, not a raw throw.
291
+ expect(result).toMatchObject({ ok: false, error: "internal_error" });
292
+ // ...and the failure is LOGGED with a phase marker + message, not silent.
293
+ expect(errorSpy).toHaveBeenCalledTimes(1);
294
+ expect(errorSpy.mock.calls[0]![0]).toMatchObject({
295
+ service: "fake_service",
296
+ operation: "fake_service.doThing",
297
+ outcome: "internal_error",
298
+ error_phase: "resolve_context",
299
+ error_message: "ROADIE binding missing props.callerApp",
300
+ });
301
+ });
302
+
303
+ test("resolveContext throw with no onError still emits a line, then rethrows", async () => {
304
+ @instrumented({
305
+ service: "fake_service",
306
+ resolveContext: () => {
307
+ throw new Error("no ctx");
308
+ },
309
+ })
310
+ class Service {
311
+ async doThing(_i: object, _m: FakeMeta): Promise<{ ok: boolean }> {
312
+ return { ok: true };
313
+ }
314
+ }
315
+ await expect(new Service().doThing({}, baseMeta)).rejects.toThrow("no ctx");
316
+ expect(errorSpy).toHaveBeenCalledTimes(1);
317
+ expect(errorSpy.mock.calls[0]![0]).toMatchObject({ error_phase: "resolve_context" });
318
+ });
319
+ });
@@ -0,0 +1,107 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { requireRequestLog } from "../index";
3
+ import { loggedJob } from "../scheduled";
4
+
5
+ let logSpy: ReturnType<typeof vi.spyOn>;
6
+ let errorSpy: ReturnType<typeof vi.spyOn>;
7
+
8
+ beforeEach(() => {
9
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
10
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
11
+ });
12
+
13
+ afterEach(() => {
14
+ logSpy.mockRestore();
15
+ errorSpy.mockRestore();
16
+ });
17
+
18
+ describe("loggedJob", () => {
19
+ test("emits event=job with synthesized requestId + service actor", async () => {
20
+ const job = loggedJob({ service: "roadie", operation: "roadie.job.reap" }, async () => ({
21
+ reaped: 7,
22
+ }));
23
+
24
+ const result = await job();
25
+ expect(result).toEqual({ reaped: 7 });
26
+ expect(logSpy).toHaveBeenCalledTimes(1);
27
+ const line = logSpy.mock.calls[0]![0] as Record<string, unknown>;
28
+ expect(line).toMatchObject({
29
+ service: "roadie",
30
+ event: "job",
31
+ operation: "roadie.job.reap",
32
+ outcome: "ok",
33
+ actor_kind: "service",
34
+ actor_id: "roadie",
35
+ });
36
+ // Default requestId is crypto.randomUUID — non-empty string.
37
+ expect(typeof line.request_id).toBe("string");
38
+ expect((line.request_id as string).length).toBeGreaterThan(8);
39
+ });
40
+
41
+ test("generateRequestId override is used", async () => {
42
+ let count = 0;
43
+ const job = loggedJob(
44
+ {
45
+ service: "roadie",
46
+ operation: "roadie.job.x",
47
+ generateRequestId: () => `custom_${++count}`,
48
+ },
49
+ async () => ({ ok: true }),
50
+ );
51
+
52
+ await job();
53
+ await job();
54
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({ request_id: "custom_1" });
55
+ expect(logSpy.mock.calls[1]![0]).toMatchObject({ request_id: "custom_2" });
56
+ });
57
+
58
+ test("requireRequestLog().add inside handler captures domain fields", async () => {
59
+ const job = loggedJob({ service: "roadie", operation: "roadie.job.reap" }, async () => {
60
+ requireRequestLog().add({ reaped_count: 12, swept_grants: ["g1", "g2"] });
61
+ });
62
+
63
+ await job();
64
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({
65
+ reaped_count: 12,
66
+ swept_grants: ["g1", "g2"],
67
+ });
68
+ });
69
+
70
+ test("throw → internal_error at error level + rethrow", async () => {
71
+ const job = loggedJob({ service: "roadie", operation: "roadie.job.fail" }, async () => {
72
+ throw new Error("scheduled task crashed");
73
+ });
74
+
75
+ await expect(job()).rejects.toThrow("scheduled task crashed");
76
+ expect(errorSpy.mock.calls[0]![0]).toMatchObject({
77
+ outcome: "internal_error",
78
+ error_message: "scheduled task crashed",
79
+ });
80
+ });
81
+
82
+ test("passes args through to handler", async () => {
83
+ const job = loggedJob(
84
+ { service: "roadie", operation: "roadie.job.passthrough" },
85
+ async (a: number, b: string) => ({ a, b }),
86
+ );
87
+
88
+ const result = await job(7, "hello");
89
+ expect(result).toEqual({ a: 7, b: "hello" });
90
+ });
91
+
92
+ test("resolveContext extra fields land on line", async () => {
93
+ const job = loggedJob(
94
+ {
95
+ service: "roadie",
96
+ operation: "roadie.job.with_caller",
97
+ resolveContext: () => ({ callerApp: "roadie" }),
98
+ },
99
+ async () => undefined,
100
+ );
101
+
102
+ await job();
103
+ expect(logSpy.mock.calls[0]![0]).toMatchObject({
104
+ caller_app: "roadie",
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Canonical log line — one structured JSON line per logical operation.
3
+ *
4
+ * The platform's single log shape, used at every boundary: HTTP edges,
5
+ * service-binding RPC methods, TanStack server-fns, scheduled jobs, and
6
+ * Better Auth events. Every line carries `{service, event, operation,
7
+ * outcome, duration_ms, request_id, caller_app, actor_kind, actor_id,
8
+ * time}` plus per-op domain fields. `request_id` is NEVER minted here —
9
+ * it always comes from the caller (`cf-request-id` adopted at the entry
10
+ * Worker, propagated unchanged through every layer).
11
+ *
12
+ * Field accrual is propagated via `AsyncLocalStorage` — handlers anywhere
13
+ * in the async call stack call `getRequestLog()` (or `requireRequestLog()`)
14
+ * to add domain fields without explicit parameter threading. This is what
15
+ * makes boundary instrumentation work: framework-level wrappers open the
16
+ * scope, handler code is plain.
17
+ *
18
+ * Logging is intentionally decoupled from `Result<T, E>` envelopes.
19
+ * Outcome is set explicitly via `log.outcome(code)`; the primitive
20
+ * defaults to `"ok"` on normal return and `"internal_error"` on throw.
21
+ * Callers that use Result envelopes wrap this primitive in their own
22
+ * thin adapter that derives outcome from the result discriminant.
23
+ *
24
+ * Sub-modules (`./http`, `./instrumented`, `./scheduled`, `./server-fn`)
25
+ * import the primitives from this file rather than `./index` so the barrel
26
+ * stays acyclic.
27
+ */
28
+ /// <reference types="node" />
29
+ // Namespace import — see kit/request-context for the same rationale. Vite
30
+ // externalizes `node:async_hooks` in the client environment to a Proxy
31
+ // that throws on any property access. A named `{ AsyncLocalStorage }`
32
+ // destructure becomes a module-init `stub["AsyncLocalStorage"]` access in
33
+ // Vite's dev transform and crashes the browser. The namespace binding is
34
+ // inert until `logStorage()` is invoked server-side.
35
+ import * as nodeAsyncHooks from "node:async_hooks";
36
+ import type { AsyncLocalStorage as ALS } from "node:async_hooks";
37
+ import { getActorId, getActorKind, getCallerApp, getRequestId } from "../request-context";
38
+
39
+ export type Level = "info" | "warn" | "error";
40
+
41
+ export interface CanonicalLogBuilder {
42
+ /** Merge fields into the line. Last write wins. Forbidden keys are stripped at emit time. */
43
+ add(fields: Record<string, unknown>): void;
44
+ /** Set the outcome code. Defaults to `"ok"` on normal return. Overridden to `"internal_error"` on throw. */
45
+ outcome(code: string): void;
46
+ }
47
+
48
+ export interface CanonicalLogContext {
49
+ /** Component emitting the line — `"roadie"`, `"guestlist"`, `"identity"`, etc. */
50
+ service: string;
51
+ /** Event kind — `"rpc" | "http" | "server_fn" | "job" | "auth"`. Free-form to allow new categories. */
52
+ event: string;
53
+ /** Abstract operation name — `"roadie.signPart"`, `"identity.server_fn.submit_attempt"`. */
54
+ operation: string;
55
+ /**
56
+ * Request ID. Optional — when omitted, read from the active
57
+ * `withRequestContext` ALS scope at emit time. Pass explicitly only
58
+ * when no ALS scope is open (e.g. RPC paths where `meta.requestId` is
59
+ * authoritative).
60
+ */
61
+ requestId?: string;
62
+ /** Actor identity kind. Optional — read from ALS at emit time when omitted. */
63
+ actorKind?: string;
64
+ /** Actor identifier. Optional — read from ALS at emit time when omitted. */
65
+ actorId?: string | null;
66
+ /** For RPC events: which app/service called this method. Optional — read from ALS at emit time when omitted. */
67
+ callerApp?: string;
68
+ /** Outcome codes that should emit at error level. Defaults: `{"backend_unavailable", "internal_error"}`. */
69
+ errorOutcomes?: ReadonlySet<string>;
70
+ /** Field names that must never be emitted. Defaults to a security-baseline set. */
71
+ forbiddenFields?: ReadonlySet<string>;
72
+ /** Field-name prefixes whose matches must never be emitted. Defaults: `["R2_", "S3_"]`. */
73
+ forbiddenPrefixes?: readonly string[];
74
+ }
75
+
76
+ const DEFAULT_ERROR_OUTCOMES: ReadonlySet<string> = new Set([
77
+ "backend_unavailable",
78
+ "internal_error",
79
+ ]);
80
+
81
+ const DEFAULT_FORBIDDEN_FIELDS: ReadonlySet<string> = new Set([
82
+ "url",
83
+ "uploadUrl",
84
+ "body",
85
+ "requiredHeaders",
86
+ "permissionScope",
87
+ "password",
88
+ "secret",
89
+ "authorization",
90
+ "cookie",
91
+ "S3_ACCESS_KEY_ID",
92
+ "S3_SECRET_ACCESS_KEY",
93
+ ]);
94
+
95
+ const DEFAULT_FORBIDDEN_PREFIXES: readonly string[] = ["R2_", "S3_"];
96
+
97
+ let _logStorage: ALS<CanonicalLogBuilder> | undefined;
98
+ function logStorage(): ALS<CanonicalLogBuilder> {
99
+ return (_logStorage ??= new nodeAsyncHooks.AsyncLocalStorage<CanonicalLogBuilder>());
100
+ }
101
+
102
+ /**
103
+ * Read the current canonical log builder from the surrounding async context.
104
+ * Returns `null` if no `withCanonicalLog` scope is active. Use inside handlers
105
+ * to accrue domain fields from anywhere in the call stack.
106
+ */
107
+ export function getRequestLog(): CanonicalLogBuilder | null {
108
+ return logStorage().getStore() ?? null;
109
+ }
110
+
111
+ /**
112
+ * Like `getRequestLog()` but throws if no scope is active. Use when the
113
+ * call site must be inside a logged boundary (server-fn handler, RPC
114
+ * method, instrumented job) — failing loudly beats silently dropping
115
+ * domain fields.
116
+ */
117
+ export function requireRequestLog(): CanonicalLogBuilder {
118
+ const log = logStorage().getStore();
119
+ if (!log) {
120
+ throw new Error(
121
+ "requireRequestLog() called outside any withCanonicalLog scope — " +
122
+ "this code path is not running inside an instrumented boundary",
123
+ );
124
+ }
125
+ return log;
126
+ }
127
+
128
+ // Removes forbidden fields (per the security baseline) AND `undefined`
129
+ // values. `undefined` would JSON-stringify away in production but pollutes
130
+ // `console.log(line)` output during dev (e.g. `caller_app: undefined` on a
131
+ // leaf service's own events). `null` is preserved — it's a meaningful
132
+ // signal (`actor_id: null` for anonymous, etc.).
133
+ function stripLine(
134
+ line: Record<string, unknown>,
135
+ fields: ReadonlySet<string>,
136
+ prefixes: readonly string[],
137
+ ): Record<string, unknown> {
138
+ for (const key of Object.keys(line)) {
139
+ if (line[key] === undefined || fields.has(key) || prefixes.some((p) => key.startsWith(p))) {
140
+ delete line[key];
141
+ }
142
+ }
143
+ return line;
144
+ }
145
+
146
+ /**
147
+ * Serialize a thrown value into log-safe `message`/`stack` strings.
148
+ * `String(x)` on a non-Error object yields `[object Object]` /
149
+ * `[object Response]` — useless in a canonical line. Thrown `Response`s
150
+ * (TanStack `redirect()` et al) are named by status; other non-Error
151
+ * objects are JSON-encoded (truncated) so the line stays actionable.
152
+ */
153
+ export function describeThrown(e: unknown): { message: string; stack?: string } {
154
+ if (e instanceof Error) return { message: e.message, stack: e.stack };
155
+ if (e instanceof Response) return { message: `thrown Response (status ${e.status})` };
156
+ if (typeof e === "object" && e !== null) {
157
+ try {
158
+ return { message: JSON.stringify(e).slice(0, 500) };
159
+ } catch {
160
+ return { message: "[unserializable object]" };
161
+ }
162
+ }
163
+ return { message: String(e) };
164
+ }
165
+
166
+ export async function withCanonicalLog<T>(
167
+ ctx: CanonicalLogContext,
168
+ fn: (log: CanonicalLogBuilder) => Promise<T>,
169
+ ): Promise<T> {
170
+ const errorOutcomes = ctx.errorOutcomes ?? DEFAULT_ERROR_OUTCOMES;
171
+ const forbiddenFields = ctx.forbiddenFields ?? DEFAULT_FORBIDDEN_FIELDS;
172
+ const forbiddenPrefixes = ctx.forbiddenPrefixes ?? DEFAULT_FORBIDDEN_PREFIXES;
173
+
174
+ const start = Date.now();
175
+ const fields: Record<string, unknown> = {};
176
+ let outcome = "ok";
177
+
178
+ const builder: CanonicalLogBuilder = {
179
+ add(extra) {
180
+ Object.assign(fields, extra);
181
+ },
182
+ outcome(code) {
183
+ outcome = code;
184
+ },
185
+ };
186
+
187
+ const emit = (finalOutcome: string, level: Level) => {
188
+ // Read correlation fields at EMIT time, not scope-open time. This lets
189
+ // boundary middleware open a scope with just `{ requestId }` early, then
190
+ // patch in actor/caller info as handlers resolve them. By the time emit
191
+ // runs (handler done, scope closing), the ALS reflects the final state.
192
+ // Explicit ctx values still win — RPC paths pass meta.requestId directly.
193
+ const line = stripLine(
194
+ {
195
+ service: ctx.service,
196
+ event: ctx.event,
197
+ operation: ctx.operation,
198
+ outcome: finalOutcome,
199
+ request_id: ctx.requestId ?? getRequestId() ?? undefined,
200
+ caller_app: ctx.callerApp ?? getCallerApp() ?? undefined,
201
+ actor_kind: ctx.actorKind ?? getActorKind() ?? undefined,
202
+ actor_id: ctx.actorId ?? getActorId() ?? null,
203
+ duration_ms: Date.now() - start,
204
+ time: new Date().toISOString(),
205
+ ...fields,
206
+ },
207
+ forbiddenFields,
208
+ forbiddenPrefixes,
209
+ );
210
+ if (level === "error") console.error(line);
211
+ else if (level === "warn") console.warn(line);
212
+ else console.log(line);
213
+ };
214
+
215
+ return logStorage().run(builder, async () => {
216
+ try {
217
+ const result = await fn(builder);
218
+ emit(outcome, errorOutcomes.has(outcome) ? "error" : "info");
219
+ return result;
220
+ } catch (e) {
221
+ // Always attach a traceback so an internal_error line is actionable — a
222
+ // canonical line that says "something threw" with no stack is the gap we
223
+ // are closing. Falls back to the serialized value for non-Error throws.
224
+ const { message, stack } = describeThrown(e);
225
+ builder.add({ error_message: message, error_stack: stack ?? message });
226
+ emit("internal_error", "error");
227
+ throw e;
228
+ }
229
+ });
230
+ }