From b91eeb3938d5630ae6e6947378d9497755e29d55 Mon Sep 17 00:00:00 2001 From: Thiago Santos Date: Mon, 10 Aug 2026 23:38:53 -0300 Subject: [PATCH] fix(otel-nestjs-instrumentation): force RPC span kind when interceptor starts fallback transaction When no guard runs (e.g. gRPC/microservice), the interceptor now passes effectiveType='rpc' to otelInstrumentation.create(), ensuring the fallback span is always SERVER/RPC instead of potentially being created as HTTP. The create() function signature was simplified: replaced forceSpanKind parameter with effectiveType?: 'http' | 'rpc'. When undefined, it falls back to context.getType(). This reduces cognitive complexity from 17 to below the SonarCloud limit of 15. --- .../src/internal/otel-instrumentation.ts | 20 +++++++++++++------ .../src/otel.interceptor.ts | 9 ++++++++- .../start-otel-instrumentation-if-absent.ts | 9 ++++++++- .../internal/otel-instrumentation.spec.ts | 2 +- .../test/otel-context-guard.spec.ts | 4 ++++ .../test/otel-interceptor.spec.ts | 1 + 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/libs/otel-nestjs-instrumentation/src/internal/otel-instrumentation.ts b/libs/otel-nestjs-instrumentation/src/internal/otel-instrumentation.ts index 723b1ce..5fe074a 100644 --- a/libs/otel-nestjs-instrumentation/src/internal/otel-instrumentation.ts +++ b/libs/otel-nestjs-instrumentation/src/internal/otel-instrumentation.ts @@ -31,9 +31,15 @@ export const otelInstrumentation = { * * @param transactionName - The name for the span * @param context - The NestJS execution context - * @returns Object containing trace ID, span ID, and optional finalizer function + * @param effectiveType When provided, overrides automatic type detection + * (e.g., 'rpc' from interceptor fallback) + * @returns Trace ID or undefined if tracer is unavailable */ - create(transactionName: string, context: ExecutionContext) { + create( + transactionName: string, + context: ExecutionContext, + effectiveType?: 'http' | 'rpc', + ) { // Create a new span since none exists const tracer = otel.trace.getTracer(tracerName); if (!tracer) return undefined; @@ -54,11 +60,13 @@ export const otelInstrumentation = { // Use default context if extraction fails } - // Determine span kind and attributes based on context type + // Determine span kind and attributes based on effective type let spanKind = otel.SpanKind.INTERNAL; // default for unknown contexts const attributes: Record = {}; - if (context.getType() === 'http') { + effectiveType ??= context.getType() as 'http' | 'rpc'; + + if (effectiveType === 'http') { spanKind = otel.SpanKind.SERVER; try { const request = context.switchToHttp().getRequest<{ @@ -74,10 +82,10 @@ export const otelInstrumentation = { } catch { // Ignore request extraction errors } - } else if (context.getType() === 'rpc') { + } else if (effectiveType === 'rpc') { spanKind = otel.SpanKind.SERVER; try { - attributes['rpc.method'] = context.getHandler()?.name || 'Call'; + attributes['rpc.method'] = context.getHandler()?.name ?? 'Call'; } catch { // Ignore RPC context extraction errors } diff --git a/libs/otel-nestjs-instrumentation/src/otel.interceptor.ts b/libs/otel-nestjs-instrumentation/src/otel.interceptor.ts index dca981f..9f362e6 100644 --- a/libs/otel-nestjs-instrumentation/src/otel.interceptor.ts +++ b/libs/otel-nestjs-instrumentation/src/otel.interceptor.ts @@ -106,7 +106,14 @@ export class OtelInterceptor implements NestInterceptor { * @returns Observable that completes when the request is finished */ intercept(context: ExecutionContext, next: CallHandler) { - startOtelInstrumentationIfAbsent(context, this.context, this.emitter); + // If no guard ran (e.g. gRPC/microservice), force RPC since HTTP spans + // would have been started by the guard already. + startOtelInstrumentationIfAbsent( + context, + this.context, + this.emitter, + 'rpc', + ); const span = otel.trace.getActiveSpan(); if (!span) return next.handle(); const traceId = span.spanContext().traceId; diff --git a/libs/otel-nestjs-instrumentation/src/start-otel-instrumentation-if-absent.ts b/libs/otel-nestjs-instrumentation/src/start-otel-instrumentation-if-absent.ts index dff9d4e..163fc64 100644 --- a/libs/otel-nestjs-instrumentation/src/start-otel-instrumentation-if-absent.ts +++ b/libs/otel-nestjs-instrumentation/src/start-otel-instrumentation-if-absent.ts @@ -11,11 +11,14 @@ import { * @param context ExecutionContext to use * @param internalContext The async local storage context * @param emitter Event emitter for monitoring + * @param effectiveType When provided, overrides automatic type detection + * (e.g., 'rpc' when called from interceptor as fallback for non-HTTP transports) */ export function startOtelInstrumentationIfAbsent( context: ExecutionContext, internalContext: InternalContext, emitter: EventEmitter, + effectiveType?: 'http' | 'rpc', ): void { // If a span is already active, don't create another one const existingTraceId = otelInstrumentation.getCurrentTransactionId(); @@ -25,7 +28,11 @@ export function startOtelInstrumentationIfAbsent( const transactionName = getTransactionName(context); try { - traceId = otelInstrumentation.create(transactionName, context); + traceId = otelInstrumentation.create( + transactionName, + context, + effectiveType, + ); } catch (error) { emitter.emit('spanStartFailed', error); return; diff --git a/libs/otel-nestjs-instrumentation/test/internal/otel-instrumentation.spec.ts b/libs/otel-nestjs-instrumentation/test/internal/otel-instrumentation.spec.ts index 6d83650..5c09740 100644 --- a/libs/otel-nestjs-instrumentation/test/internal/otel-instrumentation.spec.ts +++ b/libs/otel-nestjs-instrumentation/test/internal/otel-instrumentation.spec.ts @@ -258,7 +258,7 @@ describe('OtelInstrumentation', () => { { kind: mockOtelApi.SpanKind.SERVER, attributes: { - 'rpc.method': 'Call', // Fallback when no name + 'rpc.method': '', // Empty string preserved by ?? (not null/undefined) 'nestjs.controller': 'RpcController', 'nestjs.handler': 'unknown', // Falls back to 'unknown' when name is empty }, diff --git a/libs/otel-nestjs-instrumentation/test/otel-context-guard.spec.ts b/libs/otel-nestjs-instrumentation/test/otel-context-guard.spec.ts index 29b1c07..0e253c0 100644 --- a/libs/otel-nestjs-instrumentation/test/otel-context-guard.spec.ts +++ b/libs/otel-nestjs-instrumentation/test/otel-context-guard.spec.ts @@ -100,6 +100,7 @@ describe('OtelContextGuard', () => { expect(mockOtelInstrumentation.create).toHaveBeenCalledWith( expect.any(String), mockExecutionContext, + undefined, ); }); @@ -182,6 +183,7 @@ describe('OtelContextGuard', () => { expect(mockOtelInstrumentation.create).toHaveBeenCalledWith( expect.stringContaining('TestController.testMethod'), mockContext, + undefined, ); }); @@ -200,6 +202,7 @@ describe('OtelContextGuard', () => { expect(mockOtelInstrumentation.create).toHaveBeenCalledWith( expect.stringContaining('TestController.processMessage'), mockContext, + undefined, ); }); @@ -216,6 +219,7 @@ describe('OtelContextGuard', () => { expect(mockOtelInstrumentation.create).toHaveBeenCalledWith( expect.stringContaining('TestController.testMethod'), mockContext, + undefined, ); }); diff --git a/libs/otel-nestjs-instrumentation/test/otel-interceptor.spec.ts b/libs/otel-nestjs-instrumentation/test/otel-interceptor.spec.ts index a52517b..f8bf804 100644 --- a/libs/otel-nestjs-instrumentation/test/otel-interceptor.spec.ts +++ b/libs/otel-nestjs-instrumentation/test/otel-interceptor.spec.ts @@ -458,6 +458,7 @@ describe('OtelInterceptor', () => { expect(mockOtelInstrumentation.create).toHaveBeenCalledWith( expect.stringContaining('TestController'), mockExecutionContext, + 'rpc', // interceptor fallback forces RPC type ); expect(mockEmitter.emit).toHaveBeenCalledWith( 'spanStarted',