Skip to content

Commit 2c49e8b

Browse files
committed
Capture pino logs in Fastify
1 parent 48eda0d commit 2c49e8b

File tree

4 files changed

+107
-0
lines changed

4 files changed

+107
-0
lines changed

src/common/pino.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { AsyncLocalStorage } from "async_hooks";
2+
import pino from "pino";
3+
4+
import { LogRecord } from "./requestLogger.js";
5+
6+
const logLevelMap: Record<number, string> = {
7+
10: "trace",
8+
20: "debug",
9+
30: "info",
10+
40: "warn",
11+
50: "error",
12+
60: "fatal",
13+
};
14+
15+
export function patchPino(
16+
logger: pino.BaseLogger,
17+
logsContext: AsyncLocalStorage<LogRecord[]>,
18+
filterLogs: (obj: any) => boolean = () => true,
19+
): void {
20+
const loggerInternal = logger as any; // Cast to access internal pino properties
21+
const originalStream = loggerInternal[pino.symbols.streamSym];
22+
23+
if (originalStream) {
24+
const messageKey = loggerInternal[pino.symbols.messageKeySym];
25+
const captureStream = new ApitallyLogCaptureStream(
26+
logsContext,
27+
messageKey,
28+
filterLogs,
29+
);
30+
loggerInternal[pino.symbols.streamSym] = pino.multistream(
31+
[
32+
{ level: 0, stream: originalStream },
33+
{ level: 0, stream: captureStream },
34+
],
35+
{
36+
levels: loggerInternal.levels,
37+
},
38+
);
39+
}
40+
}
41+
42+
class ApitallyLogCaptureStream implements pino.DestinationStream {
43+
private logsContext: AsyncLocalStorage<LogRecord[]>;
44+
private messageKey: string;
45+
private filterLogs: (obj: any) => boolean;
46+
47+
constructor(
48+
logsContext: AsyncLocalStorage<LogRecord[]>,
49+
messageKey: string,
50+
filterLogs: (obj: any) => boolean = () => true,
51+
) {
52+
this.logsContext = logsContext;
53+
this.messageKey = messageKey;
54+
this.filterLogs = filterLogs;
55+
}
56+
57+
write(msg: string): void {
58+
const logs = this.logsContext.getStore();
59+
if (!logs || !msg) {
60+
return;
61+
}
62+
63+
let obj: any;
64+
try {
65+
obj = JSON.parse(msg);
66+
} catch (e) {
67+
return;
68+
}
69+
if (obj === null || typeof obj !== "object" || !this.filterLogs(obj)) {
70+
return;
71+
}
72+
73+
try {
74+
if (typeof obj[this.messageKey] === "string") {
75+
logs.push({
76+
timestamp: this.convertTime(obj.time),
77+
level: logLevelMap[obj.level] || "info",
78+
message: obj[this.messageKey],
79+
});
80+
}
81+
} catch (e) {
82+
// ignore
83+
}
84+
}
85+
86+
private convertTime(time: any): number {
87+
if (typeof time === "number" && !isNaN(time)) {
88+
return time / 1000; // Convert milliseconds to seconds
89+
}
90+
return Date.now() / 1000; // Fallback to current time
91+
}
92+
}

src/fastify/plugin.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { patchConsole } from "../common/consoleCapture.js";
1212
import { consumerFromStringOrObject } from "../common/consumerRegistry.js";
1313
import { parseContentLength } from "../common/headers.js";
1414
import { getPackageVersion } from "../common/packageVersions.js";
15+
import { patchPino } from "../common/pino.js";
1516
import {
1617
convertBody,
1718
convertHeaders,
@@ -52,6 +53,7 @@ const apitallyPlugin: FastifyPluginAsync<ApitallyConfig> = async (
5253

5354
if (client.requestLogger.config.captureLogs) {
5455
patchConsole(logsContext);
56+
patchPino(fastify.log, logsContext, filterLogs);
5557
}
5658

5759
fastify.decorateRequest("apitallyConsumer", null);
@@ -315,6 +317,11 @@ function extractNestValidationErrors(message: any[]): ValidationError[] {
315317
}
316318
}
317319

320+
function filterLogs(obj: any) {
321+
// Filter out "request completed" logs
322+
return !(obj.res && obj.responseTime);
323+
}
324+
318325
export { apitallyPlugin };
319326

320327
export default fp(apitallyPlugin, {

tests/fastify/app.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ describe("Plugin for Fastify", () => {
8484
]);
8585
expect(call[1].body).toBeInstanceOf(Buffer);
8686
expect(call[1].body!.toString()).toMatch(/^Hello John!/);
87+
expect(call[3]).toBeDefined();
88+
expect(call[3]).toHaveLength(2);
8789
expect(call[3]![0].level).toBe("log");
8890
expect(call[3]![0].message).toBe("Test 1");
8991
expect(call[3]![1].level).toBe("warn");
@@ -103,6 +105,10 @@ describe("Plugin for Fastify", () => {
103105
expect(call[0].body!.toString()).toMatch(/^{"name":"John","age":20}$/);
104106
expect(call[1].body).toBeInstanceOf(Buffer);
105107
expect(call[1].body!.toString()).toMatch(/^Hello John!/);
108+
expect(call[3]).toBeDefined();
109+
expect(call[3]).toHaveLength(1);
110+
expect(call[3]![0].level).toBe("info");
111+
expect(call[3]![0].message).toBe("Test 3");
106112
});
107113

108114
it("Validation error counter", async () => {

tests/fastify/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CLIENT_ID, ENV } from "../utils.js";
66
export const getApp = async () => {
77
const app = Fastify({
88
ajv: { customOptions: { allErrors: true } },
9+
logger: true,
910
});
1011

1112
await app.register(apitallyPlugin, {
@@ -74,6 +75,7 @@ export const getApp = async () => {
7475
},
7576
async function (request) {
7677
const { name, age } = request.body;
78+
request.log.info("Test 3");
7779
return `Hello ${name}! You are ${age} years old!`;
7880
},
7981
);

0 commit comments

Comments
 (0)