Skip to content

Commit 48eda0d

Browse files
committed
Capture console output in Express and Fastify
1 parent 51a4162 commit 48eda0d

File tree

9 files changed

+271
-117
lines changed

9 files changed

+271
-117
lines changed

src/common/consoleCapture.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { AsyncLocalStorage } from "async_hooks";
2+
import { format } from "util";
3+
import { LogRecord } from "./requestLogger.js";
4+
5+
type LogLevel = "log" | "warn" | "error" | "info" | "debug";
6+
type ConsoleMethod = (...args: any[]) => void;
7+
8+
const ORIGINAL_METHODS = Symbol("apitally.originalConsoleMethods");
9+
10+
interface PatchedConsole extends Console {
11+
[ORIGINAL_METHODS]?: {
12+
[K in LogLevel]: ConsoleMethod;
13+
};
14+
}
15+
16+
export function patchConsole(requestContext: AsyncLocalStorage<LogRecord[]>) {
17+
const patchedConsole = console as PatchedConsole;
18+
19+
if (!patchedConsole[ORIGINAL_METHODS]) {
20+
patchedConsole[ORIGINAL_METHODS] = {
21+
log: console.log,
22+
warn: console.warn,
23+
error: console.error,
24+
info: console.info,
25+
debug: console.debug,
26+
};
27+
}
28+
29+
const captureLog = (level: LogLevel, args: any[]) => {
30+
const originalMethod = patchedConsole[ORIGINAL_METHODS]![level];
31+
const logs = requestContext.getStore();
32+
if (logs) {
33+
logs.push({
34+
timestamp: Date.now() / 1000,
35+
level,
36+
message: format(...args),
37+
});
38+
}
39+
originalMethod.apply(console, args);
40+
};
41+
42+
console.log = (...args) => captureLog("log", args);
43+
console.warn = (...args) => captureLog("warn", args);
44+
console.error = (...args) => captureLog("error", args);
45+
console.info = (...args) => captureLog("info", args);
46+
console.debug = (...args) => captureLog("debug", args);
47+
}

src/common/logging.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface Logger {
77
error: (message: string, meta?: object) => void;
88
}
99

10-
export const getLogger = () => {
10+
export function getLogger() {
1111
return createLogger({
1212
level: process.env.APITALLY_DEBUG ? "debug" : "warn",
1313
format: format.combine(
@@ -19,4 +19,4 @@ export const getLogger = () => {
1919
),
2020
transports: [new transports.Console()],
2121
});
22-
};
22+
}

src/common/requestLogger.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const MAX_BODY_SIZE = 50_000; // 50 KB (uncompressed)
1717
const MAX_FILE_SIZE = 1_000_000; // 1 MB (compressed)
1818
const MAX_FILES = 50;
1919
const MAX_PENDING_WRITES = 100;
20+
const MAX_LOG_MSG_LENGTH = 2048;
2021
const BODY_TOO_LARGE = Buffer.from("<body too large>");
2122
const BODY_MASKED = Buffer.from("<masked>");
2223
const MASKED = "******";
@@ -85,6 +86,12 @@ export type Response = {
8586
body?: Buffer;
8687
};
8788

89+
export type LogRecord = {
90+
timestamp: number;
91+
level: string;
92+
message: string;
93+
};
94+
8895
export type RequestLoggingConfig = {
8996
enabled: boolean;
9097
logQueryParams: boolean;
@@ -93,6 +100,7 @@ export type RequestLoggingConfig = {
93100
logResponseHeaders: boolean;
94101
logResponseBody: boolean;
95102
logException: boolean;
103+
captureLogs: boolean;
96104
maskQueryParams: RegExp[];
97105
maskHeaders: RegExp[];
98106
maskBodyFields: RegExp[];
@@ -113,6 +121,7 @@ const DEFAULT_CONFIG: RequestLoggingConfig = {
113121
logResponseHeaders: true,
114122
logResponseBody: false,
115123
logException: true,
124+
captureLogs: false,
116125
maskQueryParams: [],
117126
maskHeaders: [],
118127
maskBodyFields: [],
@@ -129,6 +138,7 @@ type RequestLogItem = {
129138
stacktrace: string;
130139
sentryEventId?: string;
131140
};
141+
logs?: LogRecord[];
132142
};
133143

134144
export default class RequestLogger {
@@ -323,7 +333,12 @@ export default class RequestLogger {
323333
return item;
324334
}
325335

326-
logRequest(request: Request, response: Response, error?: Error) {
336+
logRequest(
337+
request: Request,
338+
response: Response,
339+
error?: Error,
340+
logs?: LogRecord[],
341+
) {
327342
if (!this.enabled || this.suspendUntil !== null) return;
328343

329344
const url = new URL(request.url);
@@ -374,6 +389,14 @@ export default class RequestLogger {
374389
}
375390
: undefined,
376391
};
392+
393+
if (logs && logs.length > 0) {
394+
item.logs = logs.map((log) => ({
395+
timestamp: log.timestamp,
396+
level: log.level,
397+
message: truncateLogMessage(log.message),
398+
}));
399+
}
377400
this.pendingWrites.push(item);
378401

379402
if (this.pendingWrites.length > MAX_PENDING_WRITES) {
@@ -540,6 +563,14 @@ function skipEmptyValues<T extends Record<string, any>>(data: T) {
540563
) as Partial<T>;
541564
}
542565

566+
function truncateLogMessage(msg: string) {
567+
if (msg.length > MAX_LOG_MSG_LENGTH) {
568+
const suffix = "... (truncated)";
569+
return msg.slice(0, MAX_LOG_MSG_LENGTH - suffix.length) + suffix;
570+
}
571+
return msg;
572+
}
573+
543574
function checkWritableFs() {
544575
try {
545576
const testPath = join(tmpdir(), `apitally-${randomUUID()}`);

0 commit comments

Comments
 (0)