Files
stack/apps/api/src/telemetry/telemetry.interceptor.ts
Jason Woltje 51e6ad0792 feat(#131): add OpenTelemetry tracing infrastructure
Implement comprehensive distributed tracing for HTTP requests and LLM
operations using OpenTelemetry with GenAI semantic conventions.

Features:
- TelemetryService: SDK initialization with OTLP HTTP exporter
- TelemetryInterceptor: Automatic HTTP request spans
- @TraceLlmCall decorator: LLM operation tracing
- GenAI semantic conventions for model/token tracking
- Graceful degradation when tracing disabled

Instrumented:
- All HTTP requests (automatic spans)
- OllamaProvider chat/chatStream/embed operations
- Token counts, model names, durations

Environment:
- OTEL_ENABLED (default: true)
- OTEL_SERVICE_NAME (default: mosaic-api)
- OTEL_EXPORTER_OTLP_ENDPOINT (default: localhost:4318)

Tests: 23 passing with full coverage

Fixes #131

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 12:55:11 -06:00

101 lines
3.1 KiB
TypeScript

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common";
import { Observable, throwError } from "rxjs";
import { tap, catchError } from "rxjs/operators";
import type { Request, Response } from "express";
import { TelemetryService } from "./telemetry.service";
import type { Span } from "@opentelemetry/api";
import { SpanKind } from "@opentelemetry/api";
import {
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_URL_FULL,
ATTR_URL_PATH,
} from "@opentelemetry/semantic-conventions";
/**
* Interceptor that automatically creates OpenTelemetry spans for all HTTP requests.
* Records HTTP method, URL, status code, and trace context in response headers.
*
* @example
* ```typescript
* // Apply globally in AppModule
* @Module({
* providers: [
* {
* provide: APP_INTERCEPTOR,
* useClass: TelemetryInterceptor,
* },
* ],
* })
* export class AppModule {}
* ```
*/
@Injectable()
export class TelemetryInterceptor implements NestInterceptor {
private readonly logger = new Logger(TelemetryInterceptor.name);
constructor(private readonly telemetryService: TelemetryService) {}
/**
* Intercept HTTP requests and wrap them in OpenTelemetry spans.
*
* @param context - The execution context
* @param next - The next call handler
* @returns Observable of the response with tracing applied
*/
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<Request>();
const response = httpContext.getResponse<Response>();
const method = request.method;
const path = request.path || request.url;
const spanName = `${method} ${path}`;
const span = this.telemetryService.startSpan(spanName, {
kind: SpanKind.SERVER,
attributes: {
[ATTR_HTTP_REQUEST_METHOD]: method,
[ATTR_URL_PATH]: path,
[ATTR_URL_FULL]: request.url,
"code.function": context.getHandler().name,
"code.namespace": context.getClass().name,
},
});
return next.handle().pipe(
tap(() => {
this.finalizeSpan(span, response);
}),
catchError((error: Error) => {
this.telemetryService.recordException(span, error);
this.finalizeSpan(span, response);
return throwError(() => error);
})
);
}
/**
* Finalize the span by setting status code and adding trace context to headers.
*
* @param span - The span to finalize
* @param response - The HTTP response
*/
private finalizeSpan(span: Span, response: Response): void {
try {
const statusCode = response.statusCode;
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, statusCode);
// Add trace context to response headers for distributed tracing
const spanContext = span.spanContext();
if (spanContext.traceId) {
response.setHeader("x-trace-id", spanContext.traceId);
}
} catch (error) {
this.logger.warn("Failed to finalize span", error);
} finally {
span.end();
}
}
}