@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.
- package/package.json +58 -0
- package/src/execution-context/index.ts +16 -0
- package/src/ids/__tests__/index.test.ts +80 -0
- package/src/ids/index.ts +60 -0
- package/src/log/__tests__/http.test.ts +109 -0
- package/src/log/__tests__/index.test.ts +254 -0
- package/src/log/__tests__/instrumented.test.ts +319 -0
- package/src/log/__tests__/scheduled.test.ts +107 -0
- package/src/log/core.ts +230 -0
- package/src/log/http.ts +72 -0
- package/src/log/index.ts +22 -0
- package/src/log/instrumented.ts +191 -0
- package/src/log/scheduled.ts +84 -0
- package/src/react/auth.tsx +63 -0
- package/src/react/index.ts +2 -0
- package/src/react-start/__tests__/dev-envelope.test.ts +153 -0
- package/src/react-start/__tests__/smoke.test.ts +77 -0
- package/src/react-start/auth-provider.tsx +81 -0
- package/src/react-start/client.ts +11 -0
- package/src/react-start/dev-envelope.ts +136 -0
- package/src/react-start/envelope-middleware.ts +135 -0
- package/src/react-start/index.ts +29 -0
- package/src/react-start/logging.ts +70 -0
- package/src/react-start/platform-start-app.ts +382 -0
- package/src/react-start/platform-start-context.ts +25 -0
- package/src/react-start/request-logger.ts +60 -0
- package/src/react-start/service-clients.ts +246 -0
- package/src/request-context/__tests__/index.test.ts +90 -0
- package/src/request-context/index.ts +189 -0
- package/src/roles.ts +25 -0
- package/src/version/__tests__/index.test.ts +140 -0
- package/src/version/index.ts +85 -0
- package/src/version.gen.ts +7 -0
|
@@ -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
|
+
});
|
package/src/log/core.ts
ADDED
|
@@ -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
|
+
}
|