Skip to content

Commit b3ad36e

Browse files
committed
feat(otel): collect api response logs
1 parent a28fcd2 commit b3ad36e

5 files changed

Lines changed: 120 additions & 19 deletions

File tree

__tests__/unit/otel/logger.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { SeverityNumber } from "@opentelemetry/api-logs";
2+
import { expect, test } from "vitest";
3+
import { isKnownLevel, toSeverityNumber } from "../../../src/otel/logger";
4+
5+
test("toSeverityNumber maps known levels", () => {
6+
expect(toSeverityNumber("debug")).toBe(SeverityNumber.DEBUG);
7+
expect(toSeverityNumber("info")).toBe(SeverityNumber.INFO);
8+
expect(toSeverityNumber("warn")).toBe(SeverityNumber.WARN);
9+
expect(toSeverityNumber("error")).toBe(SeverityNumber.ERROR);
10+
});
11+
12+
test("toSeverityNumber handles casing and trimming", () => {
13+
expect(toSeverityNumber(" INFO ")).toBe(SeverityNumber.INFO);
14+
expect(toSeverityNumber("Warn")).toBe(SeverityNumber.WARN);
15+
});
16+
17+
test("toSeverityNumber returns UNSPECIFIED for unknown", () => {
18+
expect(toSeverityNumber("unknown" as any)).toBe(SeverityNumber.UNSPECIFIED);
19+
expect(toSeverityNumber("")).toBe(SeverityNumber.UNSPECIFIED);
20+
expect(toSeverityNumber(undefined as any)).toBe(SeverityNumber.UNSPECIFIED);
21+
expect(toSeverityNumber(null as any)).toBe(SeverityNumber.UNSPECIFIED);
22+
});
23+
24+
test("isKnownLevel", () => {
25+
expect(isKnownLevel("info")).toBe(true);
26+
expect(isKnownLevel("foo")).toBe(false);
27+
});

src/api/request.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { RequestContext } from "../models/models";
2+
import { emit } from "../otel/logger";
23
import { injectPropagation } from "../otel/propagation";
3-
import { logger } from "../utils/logger";
4+
import { makeInstance } from "../utils/logger";
45
import { isMatch } from "../utils/match";
56

67
export type RequestOptions = {
@@ -9,6 +10,8 @@ export type RequestOptions = {
910
blockIgnoredPaths?: boolean;
1011
};
1112

13+
const logger = makeInstance(emit);
14+
1215
// TODO: write test code
1316
// TODO: more simply
1417
export async function fetchFromApi(
@@ -28,17 +31,18 @@ export async function fetchFromApi(
2831
const url = buildRequestUrl(endpoint, queryParams);
2932
const header = headers ? { header: headers } : {};
3033
const startTime = Date.now();
34+
const method = "GET";
3135

3236
const response = await fetch(url, {
33-
method: "GET",
37+
method: method,
3438
cache: "no-cache",
3539
headers: header as any
3640
});
3741

3842
const duration = Date.now() - startTime;
3943
logger.httpResponse(
4044
"HTTP Response",
41-
"GET",
45+
method,
4246
url,
4347
response.status,
4448
duration,

src/instrumentation.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,6 @@ import { getOtelConfig } from "./otel/config";
33
export async function register() {
44
if (process.env.NEXT_RUNTIME === "nodejs") {
55
const otelConfig = getOtelConfig();
6-
7-
// NOTE: Explicitly initialize the logger here.
8-
const { logger } = await import("./utils/logger");
9-
logger.info({ message: "logger initialized" });
10-
116
await import("./otel/lifecycle").then(({ initializeOtel }) =>
127
initializeOtel(otelConfig)
138
);

src/otel/logger.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { context, trace } from "@opentelemetry/api";
2+
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
3+
import type { PostProcess } from "../utils/logger";
4+
5+
const levelMap: Record<string, SeverityNumber> = {
6+
debug: SeverityNumber.DEBUG,
7+
info: SeverityNumber.INFO,
8+
warn: SeverityNumber.WARN,
9+
error: SeverityNumber.ERROR
10+
};
11+
12+
export function toSeverityNumber(
13+
level: string | undefined | null
14+
): SeverityNumber {
15+
if (!level) {
16+
return SeverityNumber.UNSPECIFIED;
17+
}
18+
const key = level.toLowerCase().trim();
19+
return levelMap[key] ?? SeverityNumber.UNSPECIFIED;
20+
}
21+
22+
export function isKnownLevel(level: string): boolean {
23+
return levelMap[level.toLowerCase()] !== undefined;
24+
}
25+
26+
export const emit: PostProcess = (
27+
level: string,
28+
message: string,
29+
metadata: Record<string, any>
30+
) => {
31+
try {
32+
const severity = toSeverityNumber(level);
33+
const span = trace.getSpan(context.active());
34+
const attrs: Record<string, any> = { ...metadata };
35+
if (span) {
36+
const spanContext = span.spanContext();
37+
attrs.trace_id = spanContext.traceId;
38+
attrs.span_id = spanContext.spanId;
39+
}
40+
const logger = logs.getLogger("quintet-logger");
41+
logger.emit({
42+
body:
43+
metadata && Object.keys(metadata).length > 0
44+
? { ...metadata, message }
45+
: message,
46+
severityNumber: severity,
47+
severityText: SeverityNumber[severity],
48+
attributes: attrs
49+
});
50+
} catch {
51+
// Nothing todo
52+
}
53+
};

src/utils/logger.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,49 @@ interface LogMetadata {
44
[key: string]: any;
55
}
66

7-
class PinoLogger {
7+
export type PostProcess = (
8+
level: string,
9+
message: string,
10+
metadata: Record<string, any>
11+
) => void;
12+
const noop: PostProcess = () => {};
13+
14+
export class PinoLogger {
815
private pinoLogger: pino.Logger;
16+
private postProcess: PostProcess;
917

10-
constructor() {
18+
constructor(postProcess: PostProcess = noop) {
1119
this.pinoLogger = pino({
1220
level: process.env.NODE_ENV === "production" ? "info" : "debug",
13-
formatters: {
14-
level: (label) => ({ level: label })
15-
},
21+
formatters: { level: (label) => ({ level: label }) },
1622
timestamp: pino.stdTimeFunctions.isoTime,
1723
messageKey: "message"
1824
});
25+
this.postProcess = postProcess;
1926
}
2027

2128
debug(metadata: LogMetadata) {
22-
this.pinoLogger.debug(metadata || {});
29+
const meta = metadata || {};
30+
this.pinoLogger.debug(meta);
31+
this.postProcess("debug", meta.message, meta);
2332
}
2433

2534
info(metadata: LogMetadata) {
26-
this.pinoLogger.info(metadata || {});
35+
const meta = metadata || {};
36+
this.pinoLogger.info(meta);
37+
this.postProcess("info", meta.message, meta);
2738
}
2839

2940
warn(metadata: LogMetadata) {
30-
this.pinoLogger.warn(metadata || {});
41+
const meta = metadata || {};
42+
this.pinoLogger.warn(meta);
43+
this.postProcess("warn", meta.message, meta);
3144
}
3245

3346
error(metadata: LogMetadata) {
34-
this.pinoLogger.error(metadata || {});
47+
const meta = metadata || {};
48+
this.pinoLogger.error(meta);
49+
this.postProcess("error", meta.message, meta);
3550
}
3651

3752
httpResponse(
@@ -61,5 +76,12 @@ class PinoLogger {
6176
}
6277
}
6378

64-
// Singleton instance
65-
export const logger = new PinoLogger();
79+
let instance: PinoLogger | undefined;
80+
let initialized = false;
81+
export function makeInstance(postProcess?: PostProcess): PinoLogger {
82+
if (!initialized || !instance) {
83+
instance = new PinoLogger(postProcess || noop);
84+
initialized = true;
85+
}
86+
return instance;
87+
}

0 commit comments

Comments
 (0)