Skip to content

Commit 4b53133

Browse files
ndisidorejonesphillip
authored andcommitted
chore: structured logging for Workers Logs
1 parent 1859aac commit 4b53133

58 files changed

Lines changed: 1239 additions & 109 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,23 @@ IMPORTANT: Remember when using RPC to use promise pipelining whenever possible.
5656
IMPORTANT: When using React's useState(), the state value cannot be an RPC stub. At runtime, all stubs appear to be callable (because the system doesn't actually know if the stub points to a function on the server side or not). But the setter returned by useState() has different behavior if passed a function (including any callable object): it calls the function in order to get the state. In order to avoid this problem, whenever a useState() state will contain an RpcStub, it's important to wrap the stub in an object, and set the state to that object instead.
5757

5858
IMPORTANT: RPC stubs must be disposed to prevent resource leaks on the server side. Call `stub[Symbol.dispose]()` when the stub is no longer needed (or use a `using` declaration where possible). In particular, when a React component obtains a stub in a useEffect, the cleanup function should dispose the stub.
59+
60+
IMPORTANT: Server-side logging uses `@gadgets/backend-utils/logger` (frontend browser `console.*` is out of scope):
61+
- Define a package-owned field type and module-scoped logger with a stable dot-separated `component`
62+
and, for gatekeepers, `vendorId`:
63+
`const logger = createLogger<GitHubLogFields>({ component: "gatekeeper.github", vendorId: VENDOR_ID });`.
64+
- Emit concrete event names and relevant typed fields, for example:
65+
`logger.warn("failed to notify credential expiry", { event: "credentials.expiry.notify.failed", error: err });`.
66+
Each call emits one indexed object; module/child fields such as `vendorId` are inherited.
67+
- Use immutable `logger.with(fields)` for object-owned or nearby context. Prefer module/object loggers
68+
over logger parameters, and do not replace a shallow child logger with ambient context just to
69+
remove a local variable.
70+
- For bounded operation context needed by deep helpers or independent loggers, use `createLogger`
71+
and `withLogContext` from `@gadgets/backend-utils/context-logger`. Re-establish it per operation;
72+
it does not cross RPC, hibernation, or restart, and requires `nodejs_als` or `nodejs_compat`.
73+
- Pass caught values as `error`. The helper stringifies `Error` instances and primitives, uses an
74+
own string `message` for plain objects, omits `undefined`, and adds stacks to all `Error` logs.
75+
Keep this normalization deliberately small; do not traverse causes or copy arbitrary properties.
76+
- Extend field vocabularies locally. Levels: `error` needs attention, `warn` continues best-effort,
77+
`info` is notable lifecycle, and `debug` is noisy breadcrumbs. Never log secrets, prompts, headers,
78+
tokens, or request/response bodies.

packages/backend-utils/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Backend utilities
2+
3+
`@gadgets/backend-utils` is a shared library for code that runs in Cloudflare Workers. It is not a
4+
standalone Worker and has no deployable entrypoint or Wrangler project configuration.
5+
6+
The package's Vitest configuration uses the Workers test pool to exercise runtime-specific APIs.
7+
Consumers that import `@gadgets/backend-utils/context-logger` must enable `nodejs_als` (or
8+
`nodejs_compat`); the default `@gadgets/backend-utils/logger` entry point has no such requirement.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createLogger as createBaseLogger } from "../src/logger.js";
3+
import { createLogger, withLogContext } from "../src/context-logger.js";
4+
5+
type TestLogFields = {
6+
accountId?: number;
7+
collectionId?: string;
8+
operation?: string;
9+
outcome?: string;
10+
vendorId?: string;
11+
};
12+
13+
describe("worker logger", () => {
14+
afterEach(() => vi.restoreAllMocks());
15+
16+
it("emits the message and structured context as one indexed object", () => {
17+
let spy = vi.spyOn(console, "warn").mockImplementation(() => {});
18+
let logger = createLogger<TestLogFields>({ component: "workshop.user" });
19+
20+
logger.warn("failed to load vendor", {
21+
event: "gatekeeper.vendor.load.failed",
22+
vendorId: "github",
23+
});
24+
25+
expect(spy).toHaveBeenCalledWith({
26+
message: "failed to load vendor",
27+
event: "gatekeeper.vendor.load.failed",
28+
component: "workshop.user",
29+
vendorId: "github",
30+
});
31+
});
32+
33+
it("derives immutable child loggers", () => {
34+
let spy = vi.spyOn(console, "info").mockImplementation(() => {});
35+
let logger = createLogger<TestLogFields>({ component: "workshop.user" });
36+
let accountLogger = logger.with({ accountId: 7 });
37+
38+
accountLogger.info("account disconnected", {
39+
event: "account.disconnected",
40+
outcome: "ok",
41+
});
42+
logger.info("account listing finished", {
43+
event: "account.list.finished",
44+
outcome: "ok",
45+
});
46+
47+
expect(spy.mock.calls[0]?.[0]).toMatchObject({ accountId: 7 });
48+
expect(spy.mock.calls[1]?.[0]).not.toHaveProperty("accountId");
49+
});
50+
51+
it("chains child context with nearest values taking precedence", () => {
52+
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
53+
const logger = createLogger<TestLogFields>({
54+
component: "gatekeeper.context", vendorId: "context",
55+
});
56+
const collectionLogger = logger.with({
57+
operation: "collection.sync",
58+
collectionId: "collection-1",
59+
outcome: "started",
60+
});
61+
const retryLogger = collectionLogger.with({ collectionId: "collection-2" });
62+
63+
retryLogger.info("collection sync finished", {
64+
event: "collection.sync.finished",
65+
outcome: "ok",
66+
});
67+
68+
expect(spy).toHaveBeenCalledWith({
69+
message: "collection sync finished",
70+
component: "gatekeeper.context",
71+
vendorId: "context",
72+
operation: "collection.sync",
73+
collectionId: "collection-2",
74+
event: "collection.sync.finished",
75+
outcome: "ok",
76+
});
77+
});
78+
79+
it("keeps explicit child context ahead of ambient context", () => {
80+
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
81+
const logger = createLogger<TestLogFields>({ component: "workshop.overseer" })
82+
.with({ operation: "agent.run" });
83+
84+
withLogContext({ operation: "ambient.operation" }, () => {
85+
logger.info("child wins", { event: "precedence.child" });
86+
logger.info("details win", { event: "precedence.details", operation: "log.call" });
87+
});
88+
89+
expect(spy.mock.calls[0]?.[0]).toMatchObject({ operation: "agent.run" });
90+
expect(spy.mock.calls[1]?.[0]).toMatchObject({ operation: "log.call" });
91+
});
92+
93+
it("pins component against ambient collisions", () => {
94+
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
95+
const logger = createLogger({ component: "workshop.overseer" });
96+
const runWithUnsafeContext = withLogContext as unknown as (
97+
fields: Record<string, string>, callback: () => void,
98+
) => void;
99+
100+
runWithUnsafeContext({ component: "spoofed.component" }, () => {
101+
logger.info("component stays fixed", { event: "component.fixed" });
102+
});
103+
104+
expect(spy.mock.calls[0]?.[0]).toMatchObject({ component: "workshop.overseer" });
105+
});
106+
107+
it("adds package-local fields to child loggers", () => {
108+
const spy = vi.spyOn(console, "debug").mockImplementation(() => {});
109+
const logger = createLogger<{ providerRequestId?: string }>({ component: "workshop.agent" });
110+
const requestLogger = logger.with({ providerRequestId: "request-1" });
111+
112+
requestLogger.debug("provider request started", { event: "provider.request.started" });
113+
114+
expect(spy.mock.calls[0]?.[0]).toMatchObject({ providerRequestId: "request-1" });
115+
});
116+
117+
it("inherits nested async context and restores its parent", async () => {
118+
let spy = vi.spyOn(console, "debug").mockImplementation(() => {});
119+
let logger = createLogger({ component: "workshop.overseer" });
120+
121+
await withLogContext({ operation: "agent.run", gadgetId: "gadget-1" }, async () => {
122+
await withLogContext({ chatId: 3 }, async () => {
123+
await Promise.resolve();
124+
logger.debug("nested", { event: "nested" });
125+
});
126+
logger.debug("parent", { event: "parent" });
127+
});
128+
129+
expect(spy.mock.calls[0]?.[0]).toMatchObject({
130+
operation: "agent.run",
131+
gadgetId: "gadget-1",
132+
chatId: 3,
133+
});
134+
expect(spy.mock.calls[1]?.[0]).not.toHaveProperty("chatId");
135+
});
136+
137+
it("isolates concurrent async contexts", async () => {
138+
let spy = vi.spyOn(console, "debug").mockImplementation(() => {});
139+
let logger = createLogger({ component: "workshop.overseer" });
140+
let ready = 0;
141+
let release!: () => void;
142+
const gate = new Promise<void>(resolve => { release = resolve; });
143+
144+
const runs = Promise.all([
145+
withLogContext({ chatId: 1 }, async () => {
146+
++ready;
147+
await gate;
148+
logger.debug("first", { event: "first" });
149+
}),
150+
withLogContext({ chatId: 2 }, async () => {
151+
++ready;
152+
await gate;
153+
logger.debug("second", { event: "second" });
154+
}),
155+
]);
156+
expect(ready).toBe(2);
157+
release();
158+
await runs;
159+
logger.debug("outside", { event: "outside" });
160+
161+
let logs = spy.mock.calls.map(([entry]) => entry);
162+
expect(logs).toContainEqual(expect.objectContaining({ event: "first", chatId: 1 }));
163+
expect(logs).toContainEqual(expect.objectContaining({ event: "second", chatId: 2 }));
164+
expect(logs.find(entry => entry.event === "outside")).not.toHaveProperty("chatId");
165+
});
166+
167+
it("shares async context across independently created loggers", async () => {
168+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
169+
const overseerLogger = createLogger({ component: "workshop.overseer" });
170+
const agentLogger = createLogger({ component: "workshop.agent" });
171+
172+
await withLogContext({ operation: "agent.run", gadgetId: "gadget-1", chatId: 3 }, async () => {
173+
overseerLogger.warn("overseer warning", { event: "overseer.warning" });
174+
await Promise.resolve();
175+
agentLogger.warn("agent warning", { event: "agent.warning" });
176+
});
177+
178+
expect(spy.mock.calls[0]?.[0]).toMatchObject({
179+
component: "workshop.overseer",
180+
operation: "agent.run",
181+
gadgetId: "gadget-1",
182+
chatId: 3,
183+
});
184+
expect(spy.mock.calls[1]?.[0]).toMatchObject({
185+
component: "workshop.agent",
186+
operation: "agent.run",
187+
gadgetId: "gadget-1",
188+
chatId: 3,
189+
});
190+
});
191+
192+
it("does not impose ambient context on the base logger", () => {
193+
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
194+
const logger = createBaseLogger<{ operation?: string }>({ component: "gatekeeper.github" });
195+
196+
withLogContext({ operation: "agent.run" }, () => {
197+
logger.info("base logger event", { event: "base.logger.event" });
198+
});
199+
200+
expect(spy.mock.calls[0]?.[0]).not.toHaveProperty("operation");
201+
});
202+
203+
it("preserves context after awaiting a custom thenable", async () => {
204+
const spy = vi.spyOn(console, "debug").mockImplementation(() => {});
205+
const logger = createLogger({ component: "workshop.overseer" });
206+
const thenable: PromiseLike<void> = {
207+
// oxlint-disable-next-line unicorn/no-thenable -- Deliberately models an RPC promise.
208+
then(resolve) {
209+
queueMicrotask(() => resolve());
210+
return Promise.resolve();
211+
},
212+
};
213+
214+
await withLogContext({ operation: "agent.run" }, async () => {
215+
await thenable;
216+
logger.debug("after thenable", { event: "after.thenable" });
217+
});
218+
219+
expect(spy.mock.calls[0]?.[0]).toMatchObject({ operation: "agent.run" });
220+
});
221+
222+
it("normalizes errors and adds stacks at every level", () => {
223+
let errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
224+
let warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
225+
let logger = createLogger({ component: "workshop.agent" });
226+
let error = new Error("boom");
227+
228+
logger.error("agent failed", { event: "agent.failed", error });
229+
logger.warn("agent recovered", { event: "agent.recovered", error });
230+
231+
expect(errorSpy.mock.calls[0]?.[0]).toMatchObject({
232+
error: "Error: boom",
233+
errorStack: expect.stringContaining("Error: boom"),
234+
});
235+
expect(warnSpy.mock.calls[0]?.[0]).toMatchObject({ error: "Error: boom" });
236+
expect(warnSpy.mock.calls[0]?.[0]).toMatchObject({
237+
errorStack: expect.stringContaining("Error: boom"),
238+
});
239+
});
240+
241+
it("omits undefined errors", () => {
242+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
243+
const logger = createLogger({ component: "workshop.agent" });
244+
245+
logger.warn("undefined error", { event: "undefined.error", error: undefined });
246+
247+
expect(spy.mock.calls[0]?.[0]).not.toHaveProperty("error");
248+
});
249+
250+
it("uses an own string message without serializing object contents", () => {
251+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
252+
const logger = createLogger({ component: "workshop.agent" });
253+
254+
logger.warn("object error", {
255+
event: "object.error", error: { message: "safe summary", tokenValue: "do not log" },
256+
});
257+
258+
expect(spy.mock.calls[0]?.[0]).toMatchObject({ error: "safe summary" });
259+
expect(JSON.stringify(spy.mock.calls[0]?.[0])).not.toContain("do not log");
260+
});
261+
262+
it("does not traverse error causes or copy arbitrary properties", () => {
263+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
264+
const logger = createLogger({ component: "workshop.agent" });
265+
const cause = Object.assign(new Error("inner"), { tokenValue: "do not log" });
266+
const error = Object.assign(new Error("outer", { cause }), { responseBody: "do not log" });
267+
268+
logger.warn("caused error", { event: "caused.error", error });
269+
270+
expect(spy.mock.calls[0]?.[0]).toMatchObject({
271+
error: "Error: outer",
272+
errorStack: expect.stringContaining("Error: outer"),
273+
});
274+
const serialized = JSON.stringify(spy.mock.calls[0]?.[0]);
275+
expect(serialized).not.toContain("Error: inner");
276+
expect(serialized).not.toContain("do not log");
277+
});
278+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createLogger } from "../src/logger.js";
2+
import { withLogContext } from "../src/context-logger.js";
3+
4+
let logger = createLogger<{
5+
accountId?: number;
6+
chatId?: number;
7+
durationMs?: number;
8+
providerRequestId?: string;
9+
}>({ component: "test" });
10+
let loggerWithoutPackageFields = createLogger({ component: "test" });
11+
let loggerWithSensitivePackageFields = createLogger<{
12+
body?: string;
13+
header?: string;
14+
headers?: string;
15+
prompt?: string;
16+
secret?: string;
17+
token?: string;
18+
}>({ component: "test" });
19+
// @ts-expect-error sensitive fields cannot be logger defaults even when declared
20+
createLogger<{ token?: string }>({ component: "test", token: "private token" });
21+
logger.info("valid", { event: "valid", accountId: 1, providerRequestId: "request-1" });
22+
logger.with({ chatId: 1 });
23+
logger.with({ providerRequestId: "request-2" });
24+
withLogContext({ operation: "test.operation" }, () => {});
25+
// @ts-expect-error sensitive fields are not accepted in ambient context
26+
withLogContext({ token: "private token" }, () => {});
27+
// @ts-expect-error errors belong to individual log entries, not ambient context
28+
withLogContext({ error: "boom" }, () => {});
29+
// @ts-expect-error error stacks are produced by the logger, not ambient context
30+
withLogContext({ errorStack: "stack" }, () => {});
31+
// @ts-expect-error components are fixed when a logger is created
32+
withLogContext({ component: "other" }, () => {});
33+
// @ts-expect-error events belong to individual log entries, not ambient context
34+
withLogContext({ event: "other" }, () => {});
35+
// @ts-expect-error messages are the first argument to a log method
36+
withLogContext({ message: "other" }, () => {});
37+
38+
// @ts-expect-error event is required
39+
logger.info("missing event", { accountId: 1 });
40+
// @ts-expect-error misspelled package field
41+
logger.info("misspelled", { event: "invalid", acountId: 1 });
42+
// @ts-expect-error invalid shared field type
43+
logger.info("wrong type", { event: "invalid", durationMs: "slow" });
44+
// @ts-expect-error sensitive fields are not accepted
45+
logger.info("sensitive", { event: "invalid", body: "secret" });
46+
// @ts-expect-error sensitive fields remain prohibited when declared in the package vocabulary
47+
loggerWithSensitivePackageFields.info("sensitive", { event: "invalid", body: "secret" });
48+
// @ts-expect-error sensitive fields remain prohibited when declared in the package vocabulary
49+
loggerWithSensitivePackageFields.with({ header: "authorization" });
50+
// @ts-expect-error sensitive fields remain prohibited when declared in the package vocabulary
51+
loggerWithSensitivePackageFields.with({ headers: "authorization" });
52+
// @ts-expect-error sensitive fields remain prohibited when declared in the package vocabulary
53+
loggerWithSensitivePackageFields.with({ prompt: "private prompt" });
54+
// @ts-expect-error sensitive fields remain prohibited when declared in the package vocabulary
55+
loggerWithSensitivePackageFields.with({ secret: "private secret" });
56+
// @ts-expect-error sensitive fields remain prohibited when declared in the package vocabulary
57+
loggerWithSensitivePackageFields.with({ token: "private token" });
58+
// @ts-expect-error misspelled package-local field
59+
logger.with({ providerRequstId: "request-3" });
60+
// @ts-expect-error errors belong to individual log entries, not inherited logger context
61+
logger.with({ error: new Error("boom") });
62+
// @ts-expect-error package fields must be declared on this logger
63+
loggerWithoutPackageFields.info("undeclared", { event: "invalid", accountId: 1 });

0 commit comments

Comments
 (0)