UNPKG

nestjs-otel

Version:
268 lines (267 loc) 14.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const api_1 = require("@opentelemetry/api"); const sdk_trace_node_1 = require("@opentelemetry/sdk-trace-node"); const rxjs_1 = require("rxjs"); const wide_event_context_1 = require("./wide-event.context"); const wide_event_interceptor_1 = require("./wide-event.interceptor"); const wide_event_service_1 = require("./wide-event.service"); const wide_event_span_processor_1 = require("./wide-event.span-processor"); class CatsController { findAll() { } } const executionContext = { getClass: () => CatsController, getHandler: () => CatsController.prototype.findAll, }; const callHandler = (handle) => ({ handle, }); describe("WideEventInterceptor", () => { let interceptor; let traceExporter; let provider; beforeAll(() => { traceExporter = new sdk_trace_node_1.InMemorySpanExporter(); provider = new sdk_trace_node_1.NodeTracerProvider({ spanProcessors: [new sdk_trace_node_1.SimpleSpanProcessor(traceExporter)], }); provider.register(); }); beforeEach(() => { interceptor = new wide_event_interceptor_1.WideEventInterceptor(); }); afterEach(() => { traceExporter.reset(); }); afterAll(async () => { await provider.shutdown(); }); const interceptWithRootSpan = async (handle, options) => { const activeInterceptor = options ? new wide_event_interceptor_1.WideEventInterceptor(options) : interceptor; const tracer = api_1.trace.getTracer("test"); const span = tracer.startSpan("http_request"); try { return await api_1.context.with(api_1.trace.setSpan(api_1.context.active(), span), async () => await (0, rxjs_1.lastValueFrom)(activeInterceptor.intercept(executionContext, callHandler(handle)))); } finally { span.end(); } }; it("should flush handler metadata onto the active span", async () => { const result = await interceptWithRootSpan(() => (0, rxjs_1.of)("ok")); expect(result).toBe("ok"); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["code.function.name"]).toBe("CatsController.findAll"); }); it("should mark the span as a wide event on flush", async () => { await interceptWithRootSpan(() => (0, rxjs_1.of)("ok")); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["nestjs_otel.wide_event"]).toBe(true); }); it("should not let a handler-set field override the wide event marker", async () => { const service = new wide_event_service_1.WideEventService(); await interceptWithRootSpan(() => (0, rxjs_1.defer)(() => { service.set("nestjs_otel.wide_event", false); return (0, rxjs_1.of)("ok"); })); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["nestjs_otel.wide_event"]).toBe(true); }); it("should not mark the span when the observable is unsubscribed before completion", async () => { const span = api_1.trace.getTracer("test").startSpan("http_request"); const neverCompletes = new rxjs_1.Observable((s) => { s.next("partial"); }); const obs$ = api_1.context.with(api_1.trace.setSpan(api_1.context.active(), span), () => interceptor.intercept(executionContext, callHandler(() => neverCompletes))); const sub = obs$.subscribe(); sub.unsubscribe(); span.end(); const [finished] = traceExporter.getFinishedSpans(); expect(finished.attributes["nestjs_otel.wide_event"]).toBeUndefined(); }); it("should flush attributes set by the service during the request", async () => { const service = new wide_event_service_1.WideEventService(); await interceptWithRootSpan(() => (0, rxjs_1.defer)(() => { service.set("user.id", "u-1"); service.increment("db.queries"); return (0, rxjs_1.of)("ok"); })); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["user.id"]).toBe("u-1"); expect(span.attributes["db.queries"]).toBe(1); }); it("should record error attributes when the handler throws", async () => { const error = new Error("boom"); await expect(interceptWithRootSpan(() => (0, rxjs_1.throwError)(() => error))).rejects.toThrow("boom"); // #then const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["error.type"]).toBe("Error"); expect(span.attributes["error.message"]).toBe("boom"); expect(span.attributes["error.stack"]).toBe(error.stack); }); it("should not record error.stack when the error has no stack", async () => { const error = new Error("no stack"); error.stack = undefined; await expect(interceptWithRootSpan(() => (0, rxjs_1.throwError)(() => error))).rejects.toThrow("no stack"); // #then const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["error.stack"]).toBeUndefined(); }); it("should seed attributes from the configured seed callback", async () => { await interceptWithRootSpan(() => (0, rxjs_1.of)("ok"), { wideEvents: { seed: (ctx) => ({ "app.controller": ctx.getClass().name }), }, }); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["app.controller"]).toBe("CatsController"); }); it("should record a seed error without failing the request", async () => { const result = await interceptWithRootSpan(() => (0, rxjs_1.of)("ok"), { wideEvents: { seed: () => { throw new Error("seed boom"); }, }, }); expect(result).toBe("ok"); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["wide_event.seed.error"]).toBe("seed boom"); }); it("should set span status to ERROR when the handler throws", async () => { await expect(interceptWithRootSpan(() => (0, rxjs_1.throwError)(() => new Error("fail")))).rejects.toThrow("fail"); const [span] = traceExporter.getFinishedSpans(); expect(span.status.code).toBe(api_1.SpanStatusCode.ERROR); }); it("should handle exotic errors with null constructor without replacing the original error", async () => { const exoticError = Object.create(Error.prototype); exoticError.message = "exotic"; Object.defineProperty(exoticError, "constructor", { value: null }); await expect(interceptWithRootSpan(() => (0, rxjs_1.throwError)(() => exoticError))).rejects.toBe(exoticError); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["error.type"]).toBe("Error"); expect(span.attributes["error.message"]).toBe("exotic"); }); it("should not flush attributes when the observable is unsubscribed before completion", async () => { const span = api_1.trace.getTracer("test").startSpan("http_request"); const neverCompletes = new rxjs_1.Observable((s) => { s.next("partial"); }); const obs$ = api_1.context.with(api_1.trace.setSpan(api_1.context.active(), span), () => interceptor.intercept(executionContext, callHandler(() => neverCompletes))); const sub = obs$.subscribe(); sub.unsubscribe(); span.end(); const [finished] = traceExporter.getFinishedSpans(); expect(finished.attributes["code.function.name"]).toBeUndefined(); }); it("should not throw and should produce no seed error when seed returns null", async () => { const result = await interceptWithRootSpan(() => (0, rxjs_1.of)("ok"), { wideEvents: { seed: () => null }, }); expect(result).toBe("ok"); const [span] = traceExporter.getFinishedSpans(); expect(span.attributes["wide_event.seed.error"]).toBeUndefined(); }); it("should flush onto the request root span instead of the nested active span", async () => { // #given const tracer = api_1.trace.getTracer("test"); const rootSpan = tracer.startSpan("http_request"); const req = { [wide_event_context_1.WIDE_EVENT_ROOT_SPAN]: rootSpan, }; const httpExecutionContext = { getClass: () => CatsController, getHandler: () => CatsController.prototype.findAll, getType: () => "http", switchToHttp: () => ({ getRequest: () => req }), }; // #when: run the interceptor inside a DIFFERENT (nested) active span const nestedSpan = tracer.startSpan("nested_interceptor_span"); await api_1.context.with(api_1.trace.setSpan(api_1.context.active(), nestedSpan), async () => (0, rxjs_1.lastValueFrom)(interceptor.intercept(httpExecutionContext, callHandler(() => (0, rxjs_1.of)("ok"))))); nestedSpan.end(); rootSpan.end(); // #then: marker + metadata land on the root span, not the nested one const finished = traceExporter.getFinishedSpans(); const root = finished.find((s) => s.name === "http_request"); const nested = finished.find((s) => s.name === "nested_interceptor_span"); expect(root?.attributes["nestjs_otel.wide_event"]).toBe(true); expect(root?.attributes["code.function.name"]).toBe("CatsController.findAll"); expect(nested?.attributes["nestjs_otel.wide_event"]).toBeUndefined(); }); it("should read the root span from request.raw (Fastify) when absent on the request", async () => { // #given: Fastify middie sets the span on the raw IncomingMessage, while // switchToHttp().getRequest() returns the FastifyRequest wrapper. const tracer = api_1.trace.getTracer("test"); const rootSpan = tracer.startSpan("http_request"); const fastifyRequest = { raw: { [wide_event_context_1.WIDE_EVENT_ROOT_SPAN]: rootSpan } }; const httpExecutionContext = { getClass: () => CatsController, getHandler: () => CatsController.prototype.findAll, getType: () => "http", switchToHttp: () => ({ getRequest: () => fastifyRequest }), }; // #when: run inside a different nested active span const nestedSpan = tracer.startSpan("nested_span"); await api_1.context.with(api_1.trace.setSpan(api_1.context.active(), nestedSpan), async () => (0, rxjs_1.lastValueFrom)(interceptor.intercept(httpExecutionContext, callHandler(() => (0, rxjs_1.of)("ok"))))); nestedSpan.end(); rootSpan.end(); // #then const finished = traceExporter.getFinishedSpans(); const root = finished.find((s) => s.name === "http_request"); const nested = finished.find((s) => s.name === "nested_span"); expect(root?.attributes["nestjs_otel.wide_event"]).toBe(true); expect(nested?.attributes["nestjs_otel.wide_event"]).toBeUndefined(); }); it("should fall back to the active span when the request root span already ended", async () => { // #given: an ephemeral root span (e.g. @fastify/otel phase span) that ends // before the flush runs, plus a live nested active span. const tracer = api_1.trace.getTracer("test"); const endedRootSpan = tracer.startSpan("ephemeral_phase_span"); endedRootSpan.end(); const fastifyRequest = { raw: { [wide_event_context_1.WIDE_EVENT_ROOT_SPAN]: endedRootSpan } }; const httpExecutionContext = { getClass: () => CatsController, getHandler: () => CatsController.prototype.findAll, getType: () => "http", switchToHttp: () => ({ getRequest: () => fastifyRequest }), }; const activeSpan = tracer.startSpan("handler_span"); await api_1.context.with(api_1.trace.setSpan(api_1.context.active(), activeSpan), async () => (0, rxjs_1.lastValueFrom)(interceptor.intercept(httpExecutionContext, callHandler(() => (0, rxjs_1.of)("ok"))))); activeSpan.end(); // #then: marker lands on the live active span, not the ended root span const finished = traceExporter.getFinishedSpans(); const active = finished.find((s) => s.name === "handler_span"); const ended = finished.find((s) => s.name === "ephemeral_phase_span"); expect(active?.attributes["nestjs_otel.wide_event"]).toBe(true); expect(active?.attributes["code.function.name"]).toBe("CatsController.findAll"); expect(ended?.attributes["nestjs_otel.wide_event"]).toBeUndefined(); }); it("should prefer the processor's local-root span over the active span", async () => { // #given: a registered local-root span plus a nested active span in the // same trace (as instrumentation-nestjs-core would create). const processor = new wide_event_span_processor_1.WideEventSpanProcessor(); const tracer = api_1.trace.getTracer("test"); const rootSpan = tracer.startSpan("GET /actors"); processor.onStart(rootSpan, {}); const childContext = api_1.trace.setSpan(api_1.context.active(), rootSpan); const nestedSpan = tracer.startSpan("ActorController.findAll", undefined, childContext); await api_1.context.with(api_1.trace.setSpan(api_1.context.active(), nestedSpan), async () => (0, rxjs_1.lastValueFrom)(interceptor.intercept(executionContext, callHandler(() => (0, rxjs_1.of)("ok"))))); nestedSpan.end(); rootSpan.end(); // #then: marker lands on the local-root span, not the nested span const finished = traceExporter.getFinishedSpans(); const root = finished.find((s) => s.name === "GET /actors"); const nested = finished.find((s) => s.name === "ActorController.findAll"); expect(root?.attributes["nestjs_otel.wide_event"]).toBe(true); expect(root?.attributes["code.function.name"]).toBe("CatsController.findAll"); expect(nested?.attributes["nestjs_otel.wide_event"]).toBeUndefined(); }); it("should propagate the handler result untouched when no span is active", async () => { const result = await (0, rxjs_1.lastValueFrom)(interceptor.intercept(executionContext, callHandler(() => (0, rxjs_1.of)("ok")))); expect(result).toBe("ok"); expect(traceExporter.getFinishedSpans()).toHaveLength(0); }); });