diff --git a/server/src/__tests__/instrumentation.test.ts b/server/src/__tests__/instrumentation.test.ts index 475dc770355..cf9764967cd 100644 --- a/server/src/__tests__/instrumentation.test.ts +++ b/server/src/__tests__/instrumentation.test.ts @@ -43,6 +43,8 @@ describe("resolveProtocol", () => { ["http/protobuf", "http/protobuf", "@opentelemetry/exporter-trace-otlp-proto"], ["http/json", "http/json", "@opentelemetry/exporter-trace-otlp-http"], ["HTTP/JSON", "http/json", "@opentelemetry/exporter-trace-otlp-http"], + // Surrounding space is trimmed before the switch, not only letter case. + [" HTTP/PROTOBUF ", "http/protobuf", "@opentelemetry/exporter-trace-otlp-proto"], ])("maps OTEL_EXPORTER_OTLP_PROTOCOL=%s to %s", async (raw, protocol, packageName) => { if (raw === undefined) delete process.env[PROTOCOL_ENV]; else process.env[PROTOCOL_ENV] = raw; @@ -60,8 +62,15 @@ describe("resolveProtocol", () => { const { resolveProtocol } = await importFreshInstrumentation(); - expect(resolveProtocol().protocol).toBe("grpc"); + // The fallback must also select the grpc exporter package, not just a + // grpc-labeled protocol. + expect(resolveProtocol()).toEqual({ + protocol: "grpc", + packageName: "@opentelemetry/exporter-trace-otlp-grpc", + }); expect(warn).toHaveBeenCalledWith(expect.stringContaining("carrier-pigeon")); + // One unknown input logs exactly one warning, not one per lookup. + expect(warn).toHaveBeenCalledOnce(); }); }); @@ -249,8 +258,14 @@ describe("getStartupTracer", () => { // startup seam calls, so the caller needs no null check. expect(tracer).not.toBeNull(); expect(typeof tracer.startSpan).toBe("function"); - // A no-op tracer must open and end a span without throwing. - expect(() => tracer.startSpan("workspace.resolve").end()).not.toThrow(); + // A no-op tracer must carry attribute, status, and end calls without + // throwing: the startup seam sets attributes and status before it ends. + expect(() => { + const span = tracer.startSpan("workspace.resolve"); + span.setAttribute("k", "v"); + span.setStatus({ code: 0 }); + span.end(); + }).not.toThrow(); }); it("loads no OTel SDK package when the endpoint is unset", async () => { @@ -277,6 +292,25 @@ describe("getStartupTracer", () => { }); }); +describe("getStartupTraceContext", () => { + it("returns a tracer and a parent-context helper that never throw when the endpoint is unset", async () => { + const { getStartupTraceContext } = await importFreshInstrumentation(); + + const traceContext = getStartupTraceContext(); + + // The tracer handle carries the same span surface as getStartupTracer. + expect(() => { + const span = traceContext.tracer.startSpan("runtime.exec"); + span.setAttribute("k", "v"); + span.setStatus({ code: 0 }); + span.end(); + }).not.toThrow(); + // The helper never throws. It returns a parent token when the api + // resolves, or `undefined` from the no-op fallback when it does not. + expect(() => traceContext.contextWithSpan({})).not.toThrow(); + }); +}); + describe("shutdownInstrumentation", () => { it("is a no-op when tracing is off and idempotent across calls", async () => { const { shutdownInstrumentation } = await importFreshInstrumentation(); diff --git a/server/src/instrumentation.test.ts b/server/src/instrumentation.test.ts deleted file mode 100644 index 76c29a65ec5..00000000000 --- a/server/src/instrumentation.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - getStartupTraceContext, - getStartupTracer, - resolveProtocol, -} from "./instrumentation.js"; - -// The span export path selects the OTLP trace-exporter package from the -// `OTEL_EXPORTER_OTLP_PROTOCOL` env var. `resolveProtocol` owns that choice. -// A wrong package name sends spans over the wrong wire protocol, so the -// exporter never reaches the collector. -describe("resolveProtocol", () => { - const PROTOCOL_ENV = "OTEL_EXPORTER_OTLP_PROTOCOL"; - let previous: string | undefined; - - beforeEach(() => { - previous = process.env[PROTOCOL_ENV]; - delete process.env[PROTOCOL_ENV]; - }); - - afterEach(() => { - if (previous === undefined) { - delete process.env[PROTOCOL_ENV]; - } else { - process.env[PROTOCOL_ENV] = previous; - } - vi.restoreAllMocks(); - }); - - it("defaults to grpc when the protocol is unset", () => { - expect(resolveProtocol()).toEqual({ - protocol: "grpc", - packageName: "@opentelemetry/exporter-trace-otlp-grpc", - }); - }); - - it("defaults to grpc when the protocol is an empty string", () => { - process.env[PROTOCOL_ENV] = ""; - expect(resolveProtocol()).toEqual({ - protocol: "grpc", - packageName: "@opentelemetry/exporter-trace-otlp-grpc", - }); - }); - - it("selects the grpc exporter for grpc", () => { - process.env[PROTOCOL_ENV] = "grpc"; - expect(resolveProtocol()).toEqual({ - protocol: "grpc", - packageName: "@opentelemetry/exporter-trace-otlp-grpc", - }); - }); - - it("selects the protobuf exporter for http/protobuf", () => { - process.env[PROTOCOL_ENV] = "http/protobuf"; - expect(resolveProtocol()).toEqual({ - protocol: "http/protobuf", - packageName: "@opentelemetry/exporter-trace-otlp-proto", - }); - }); - - it("selects the json exporter for http/json", () => { - process.env[PROTOCOL_ENV] = "http/json"; - expect(resolveProtocol()).toEqual({ - protocol: "http/json", - packageName: "@opentelemetry/exporter-trace-otlp-http", - }); - }); - - it("normalizes surrounding space and letter case", () => { - process.env[PROTOCOL_ENV] = " HTTP/PROTOBUF "; - expect(resolveProtocol()).toEqual({ - protocol: "http/protobuf", - packageName: "@opentelemetry/exporter-trace-otlp-proto", - }); - }); - - it("warns and falls back to grpc for an unknown protocol", () => { - process.env[PROTOCOL_ENV] = "carrier-pigeon"; - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - - expect(resolveProtocol()).toEqual({ - protocol: "grpc", - packageName: "@opentelemetry/exporter-trace-otlp-grpc", - }); - expect(warn).toHaveBeenCalledOnce(); - }); -}); - -// The manual startup spans read their tracer through these accessors. Both -// accessors must return a usable handle and never throw, whether or not -// `@opentelemetry/api` resolves. When the package is absent, the handle is a -// no-op; when it is present, the api returns a real no-op tracer while no SDK -// is registered. The span methods work in both cases. -describe("startup tracer accessors", () => { - it("returns a usable span from getStartupTracer", () => { - const tracer = getStartupTracer(); - const span = tracer.startSpan("unit-test"); - - expect(() => { - span.setAttribute("k", "v"); - span.setStatus({ code: 0 }); - span.end(); - }).not.toThrow(); - }); - - it("returns a tracer and a parent-context helper from getStartupTraceContext", () => { - const traceContext = getStartupTraceContext(); - - expect(() => traceContext.tracer.startSpan("unit-test").end()).not.toThrow(); - // The helper never throws. It returns a parent token when the api resolves, - // or `undefined` from the no-op fallback when it does not. - expect(() => traceContext.contextWithSpan({})).not.toThrow(); - }); -});