Skip to content

Commit 6abff15

Browse files
forgetsoclaude
andauthored
fix(provider,api-express-router,logger): stop request-logger context bleed and log every endpoint's envelope (#2820)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1a3cced commit 6abff15

7 files changed

Lines changed: 162 additions & 9 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@prosopo/logger": patch
3+
"@prosopo/api-express-router": patch
4+
"@prosopo/provider": patch
5+
---
6+
7+
Fix request-scoped logger fields leaking across concurrent captcha requests, and give every endpoint a proper request/response envelope in OpenObserve.
8+
9+
Three interlocking changes:
10+
11+
- **`Tasks.setLogger` no longer mutates `db.logger`.** `env.getDb()` returns a
12+
process-wide singleton; overwriting `db.logger` on every request meant two
13+
concurrent captcha submits raced, and whichever request landed second
14+
stamped its `user`/`siteKey`/`sessionId` bindings onto the *other* request's
15+
DB-level log lines. In practice you'd see a `PuzzleCaptcha record updated
16+
successfully` for user A's challenge tagged with user B's account and site
17+
key, breaking log-based forensics. `setLogger` still updates the per-request
18+
Tasks instance and its per-request manager instances (those are safe —
19+
they're constructed inside the Tasks constructor) but stops mutating the
20+
shared DB. Callers in `getPoWCaptchaChallenge` and `getPuzzleCaptchaChallenge`
21+
now pass `req.logger` directly into `new Tasks(env, req.logger)` and drop
22+
the redundant `.setLogger(req.logger)` call that followed.
23+
24+
- **`requestLoggerMiddleware` now emits `Request received` and `Response sent`
25+
envelope lines on every route** (with `method`, `path`, `status`,
26+
`durationMs`, and the request id). Previously only `/frictionless` had a
27+
`res.on('finish', ...)` block, so `getPow/PuzzleCaptchaChallenge`,
28+
`submitPow/PuzzleCaptchaSolution`, `verify.ts` etc. produced no envelope in
29+
OO — a challenge issued by one endpoint and verified by another shared
30+
nothing you could group on. Health-probe paths (`/healthz`, `/health`,
31+
`/readyz`) are excluded so they don't drown the stream. The middleware
32+
also now mirrors `x-request-id` back on the outbound response so callers
33+
downstream of the Node process can correlate without depending on Caddy.
34+
35+
- **`requestId` (set on the request logger via `.with({requestId})`) is
36+
promoted to a top-level `req_id` field on the emitted JSON log record.**
37+
OpenObserve indexes top-level fields as their own columns, so
38+
`WHERE req_id = '…'` is now cheap; previously the id only lived inside
39+
`data.requestId`, which flattened to `data_requestid` in OO's ingestion
40+
and had no top-level column. `data.requestId` is preserved for backwards
41+
compatibility with existing dashboards. Two new unit tests in
42+
`@prosopo/logger` cover the promotion and the "absent when unset" case.

packages/api-express-router/src/middlewares/requestLoggerMiddleware.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@ const getHeaderValue = (
2727
return undefined;
2828
};
2929

30+
const HEALTH_CHECK_PATHS = new Set(["/healthz", "/health", "/readyz"]);
31+
3032
export function requestLoggerMiddleware(env: ProviderEnvironment) {
3133
return (req: Request, res: Response, next: NextFunction) => {
34+
// Honour an inbound `x-request-id` (Caddy/upstream proxy assigns one and
35+
// we want the same value to link Caddy access logs to Node app logs).
36+
// Fall back to `e-<uuid>` when the upstream didn't set one.
3237
const requestId =
33-
(req.headers["x-request-id"] as string) || `e-${uuidv4()}`; // use prefix to differentiate from other IDs
38+
(req.headers["x-request-id"] as string) || `e-${uuidv4()}`;
3439
const user = getHeaderValue(req, "prosopo-user");
3540
const siteKey = getHeaderValue(req, "prosopo-site-key");
36-
const sessionId = req.body?.sessionId ? req.body.sessionId : null;
41+
const sessionId = req.body?.sessionId ? req.body.sessionId : undefined;
3742

3843
const logger = getLogger(
3944
parseLogLevel(env.config.logLevel),
@@ -45,11 +50,55 @@ export function requestLoggerMiddleware(env: ProviderEnvironment) {
4550
...(sessionId ? { sessionId } : {}),
4651
});
4752

48-
// Attach logger to the request
4953
req.logger = logger;
5054
req.requestId = requestId;
5155

52-
// Continue to next middleware
56+
// Mirror the requestId back to the client so downstream systems can
57+
// correlate their own logs to ours.
58+
res.setHeader("x-request-id", requestId);
59+
60+
// Skip request/response envelope logging for high-volume health probes
61+
// so they don't drown out useful captcha-endpoint entries.
62+
if (HEALTH_CHECK_PATHS.has(req.path)) {
63+
next();
64+
return;
65+
}
66+
67+
// Emit a single request-received line so every endpoint has an entry
68+
// log in OpenObserve even when the handler itself doesn't log. Kept at
69+
// info level; drop to debug if the volume becomes a problem.
70+
const startNs = process.hrtime.bigint();
71+
logger.info(() => ({
72+
msg: "Request received",
73+
data: {
74+
method: req.method,
75+
path: req.path,
76+
},
77+
}));
78+
79+
// Log a response-finished line. `finish` fires when the response was
80+
// sent successfully; `close` fires when the client disconnects before
81+
// the response was fully sent. Guard against both firing (once() would
82+
// only cover a single event source).
83+
let finished = false;
84+
const emitFinish = (outcome: "finish" | "close") => {
85+
if (finished) return;
86+
finished = true;
87+
const durationMs = Number(process.hrtime.bigint() - startNs) / 1e6;
88+
logger.info(() => ({
89+
msg: "Response sent",
90+
data: {
91+
method: req.method,
92+
path: req.path,
93+
status: res.statusCode,
94+
durationMs,
95+
outcome,
96+
},
97+
}));
98+
};
99+
res.on("finish", () => emitFinish("finish"));
100+
res.on("close", () => emitFinish("close"));
101+
53102
next();
54103
};
55104
}

packages/logger/src/logger.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,15 +348,30 @@ export class NativeLogger implements Logger {
348348
if (this.defaultData) {
349349
data = { ...this.defaultData, ...data };
350350
}
351+
// Promote `requestId` (set via `.with({requestId})` in the request
352+
// logger middleware) to a top-level `req_id` on the emitted record.
353+
// OpenObserve indexes top-level fields as their own columns, so this
354+
// makes `WHERE req_id = '…'` cheap and lets us correlate to Caddy's
355+
// `X-Request-ID` without a schema alias per stream. Keep the copy in
356+
// `data.requestId` for backwards compat with existing dashboards.
357+
const dataMaybeRequestId = data as { requestId?: unknown } | undefined;
358+
const reqId =
359+
dataMaybeRequestId && typeof dataMaybeRequestId.requestId === "string"
360+
? dataMaybeRequestId.requestId
361+
: undefined;
351362
const baseRecord: {
352363
scope: string;
353364
ts: string;
354365
level: LogLevel;
366+
req_id?: string;
355367
data?: LogObject;
356368
msg?: string;
357369
err?: string;
358370
errData?: Record<string, unknown>;
359371
} = { scope: this.scope, ts, level };
372+
if (reqId) {
373+
baseRecord.req_id = reqId;
374+
}
360375
if (data) {
361376
baseRecord.data = data;
362377
}

packages/logger/src/tests/logger.unit.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,4 +397,45 @@ describe("Logger.with subscope", () => {
397397
setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
398398
}
399399
});
400+
401+
it("promotes requestId from default data to a top-level req_id field", () => {
402+
setGlobalDirectives("trace");
403+
const logger = getLogger("info", "test").with({ requestId: "abc-123" });
404+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
405+
try {
406+
logger.info(() => ({ msg: "hello" }));
407+
expect(infoSpy).toHaveBeenCalledTimes(1);
408+
const output = infoSpy.mock.calls[0]?.[0];
409+
const record: {
410+
req_id?: string;
411+
data?: { requestId?: string };
412+
} = JSON.parse(output as string);
413+
expect(record.req_id).toBe("abc-123");
414+
// Backwards compat: still available inside `data` for existing dashboards.
415+
expect(record.data?.requestId).toBe("abc-123");
416+
} finally {
417+
infoSpy.mockRestore();
418+
setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
419+
}
420+
});
421+
422+
it("omits req_id when the log record has no requestId in scope", () => {
423+
setGlobalDirectives("trace");
424+
const logger = getLogger("info", "test");
425+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
426+
try {
427+
logger.info(() => ({ msg: "hello", data: { foo: "bar" } }));
428+
expect(infoSpy).toHaveBeenCalledTimes(1);
429+
const output = infoSpy.mock.calls[0]?.[0];
430+
const record: {
431+
req_id?: string;
432+
data?: Record<string, unknown>;
433+
} = JSON.parse(output as string);
434+
expect(record.req_id).toBeUndefined();
435+
expect(record.data).toEqual({ foo: "bar" });
436+
} finally {
437+
infoSpy.mockRestore();
438+
setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
439+
}
440+
});
400441
});

packages/provider/src/api/captcha/getPoWCaptchaChallenge.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ export default (
7373
return res.json(buildPowMaintenanceResponse(user, dapp));
7474
}
7575

76-
const tasks = new Tasks(env);
77-
tasks.setLogger(req.logger);
76+
const tasks = new Tasks(env, req.logger);
7877

7978
try {
8079
const clientSettings = await tasks.db.getClientRecord(dapp);

packages/provider/src/api/captcha/getPuzzleCaptchaChallenge.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ export default (
7474
}
7575

7676
const tasks = new Tasks(env, req.logger);
77-
tasks.setLogger(req.logger);
7877

7978
try {
8079
const clientSettings = await tasks.db.getClientRecord(dapp);

packages/provider/src/tasks/tasks.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,14 +213,22 @@ export class Tasks {
213213
}
214214

215215
setLogger(logger: Logger): void {
216-
// Use a logger from the request
216+
// Use a logger from the request.
217+
//
218+
// The Tasks instance and its managers are constructed per-request, so
219+
// overwriting their `.logger` refs is safe. `this.db`, however, is a
220+
// process-wide singleton from `env.getDb()` — mutating `db.logger` here
221+
// races between concurrent requests and stamps the wrong user/siteKey/
222+
// sessionId onto log lines emitted from inside DB methods. If a DB
223+
// method needs request context in its log output, wrap the call in the
224+
// caller (which already has the per-request logger via `this.logger`)
225+
// or thread the logger through as an explicit argument.
217226
this.logger = logger;
218227
this.powCaptchaManager.logger = logger;
219228
this.puzzleCaptchaManager.logger = logger;
220229
this.datasetManager.logger = logger;
221230
this.imgCaptchaManager.logger = logger;
222231
this.clientTaskManager.logger = logger;
223232
this.frictionlessManager.logger = logger;
224-
this.db.logger = logger; // Ensure the database also uses the new logger
225233
}
226234
}

0 commit comments

Comments
 (0)