Files
stack/apps/api/src/telemetry/telemetry.interceptor.spec.ts
Jason Woltje f219dd71a0
Some checks failed
ci/woodpecker/push/api Pipeline failed
fix(auth): use UUID id generation for BetterAuth DB models
2026-02-18 18:49:16 -06:00

213 lines
6.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { TelemetryInterceptor } from "./telemetry.interceptor";
import { TelemetryService } from "./telemetry.service";
import type { ExecutionContext, CallHandler } from "@nestjs/common";
import type { Span } from "@opentelemetry/api";
import { of, throwError } from "rxjs";
import { lastValueFrom } from "rxjs";
describe("TelemetryInterceptor", () => {
let interceptor: TelemetryInterceptor;
let telemetryService: TelemetryService;
let mockSpan: Span;
let mockContext: ExecutionContext;
let mockHandler: CallHandler;
beforeEach(() => {
// Mock span
mockSpan = {
end: vi.fn(),
setAttribute: vi.fn(),
setAttributes: vi.fn(),
addEvent: vi.fn(),
setStatus: vi.fn(),
updateName: vi.fn(),
isRecording: vi.fn().mockReturnValue(true),
recordException: vi.fn(),
spanContext: vi.fn().mockReturnValue({
traceId: "test-trace-id",
spanId: "test-span-id",
}),
} as unknown as Span;
// Mock telemetry service
telemetryService = {
startSpan: vi.fn().mockReturnValue(mockSpan),
recordException: vi.fn(),
getTracer: vi.fn(),
onModuleInit: vi.fn(),
onModuleDestroy: vi.fn(),
} as unknown as TelemetryService;
// Mock execution context
mockContext = {
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn().mockReturnValue({
method: "GET",
url: "/api/test",
path: "/api/test",
}),
getResponse: vi.fn().mockReturnValue({
statusCode: 200,
setHeader: vi.fn(),
headersSent: false,
writableEnded: false,
}),
}),
getClass: vi.fn().mockReturnValue({ name: "TestController" }),
getHandler: vi.fn().mockReturnValue({ name: "testHandler" }),
} as unknown as ExecutionContext;
interceptor = new TelemetryInterceptor(telemetryService);
});
describe("intercept", () => {
it("should create a span for HTTP request", async () => {
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "test" })),
} as unknown as CallHandler;
await lastValueFrom(interceptor.intercept(mockContext, mockHandler));
expect(telemetryService.startSpan).toHaveBeenCalledWith(
"GET /api/test",
expect.objectContaining({
attributes: expect.objectContaining({
"http.request.method": "GET",
"url.path": "/api/test",
}),
})
);
});
it("should set http.status_code attribute on success", async () => {
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "test" })),
} as unknown as CallHandler;
await lastValueFrom(interceptor.intercept(mockContext, mockHandler));
expect(mockSpan.setAttribute).toHaveBeenCalledWith("http.response.status_code", 200);
expect(mockSpan.end).toHaveBeenCalled();
});
it("should add trace context to response headers", async () => {
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "test" })),
} as unknown as CallHandler;
const mockResponse = mockContext.switchToHttp().getResponse();
await lastValueFrom(interceptor.intercept(mockContext, mockHandler));
expect(mockResponse.setHeader).toHaveBeenCalledWith("x-trace-id", "test-trace-id");
});
it("should not set trace header when response is already committed", async () => {
const committedResponseContext = {
...mockContext,
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn().mockReturnValue({
method: "GET",
url: "/api/test",
path: "/api/test",
}),
getResponse: vi.fn().mockReturnValue({
statusCode: 200,
setHeader: vi.fn(),
headersSent: true,
writableEnded: true,
}),
}),
} as unknown as ExecutionContext;
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "test" })),
} as unknown as CallHandler;
const committedResponse = committedResponseContext.switchToHttp().getResponse();
await lastValueFrom(interceptor.intercept(committedResponseContext, mockHandler));
expect(committedResponse.setHeader).not.toHaveBeenCalled();
});
it("should record exception on error", async () => {
const error = new Error("Test error");
mockHandler = {
handle: vi.fn().mockReturnValue(throwError(() => error)),
} as unknown as CallHandler;
await expect(lastValueFrom(interceptor.intercept(mockContext, mockHandler))).rejects.toThrow(
"Test error"
);
expect(telemetryService.recordException).toHaveBeenCalledWith(mockSpan, error);
expect(mockSpan.end).toHaveBeenCalled();
});
it("should end span even if error occurs", async () => {
const error = new Error("Test error");
mockHandler = {
handle: vi.fn().mockReturnValue(throwError(() => error)),
} as unknown as CallHandler;
await expect(
lastValueFrom(interceptor.intercept(mockContext, mockHandler))
).rejects.toThrow();
expect(mockSpan.end).toHaveBeenCalled();
});
it("should handle different HTTP methods", async () => {
const postContext = {
...mockContext,
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn().mockReturnValue({
method: "POST",
url: "/api/test",
path: "/api/test",
}),
getResponse: vi.fn().mockReturnValue({
statusCode: 201,
setHeader: vi.fn(),
}),
}),
} as unknown as ExecutionContext;
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "created" })),
} as unknown as CallHandler;
await lastValueFrom(interceptor.intercept(postContext, mockHandler));
expect(telemetryService.startSpan).toHaveBeenCalledWith(
"POST /api/test",
expect.objectContaining({
attributes: expect.objectContaining({
"http.request.method": "POST",
}),
})
);
});
it("should set controller and handler attributes", async () => {
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "test" })),
} as unknown as CallHandler;
await lastValueFrom(interceptor.intercept(mockContext, mockHandler));
expect(telemetryService.startSpan).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
attributes: expect.objectContaining({
"code.function": "testHandler",
"code.namespace": "TestController",
}),
})
);
});
});
});