diff --git a/server/package.json b/server/package.json index 8872572fa8..d7989623b9 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1075.0", + "@opentelemetry/api": "^1.9.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", diff --git a/server/src/instrumentation.test.ts b/server/src/instrumentation.test.ts new file mode 100644 index 0000000000..76c29a65ec --- /dev/null +++ b/server/src/instrumentation.test.ts @@ -0,0 +1,114 @@ +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(); + }); +}); diff --git a/server/src/instrumentation.ts b/server/src/instrumentation.ts index 17647d5909..e96ed82f0d 100644 --- a/server/src/instrumentation.ts +++ b/server/src/instrumentation.ts @@ -283,9 +283,15 @@ async function bootstrapOtel(endpoint: string): Promise { // and the exporter appends /v1/traces only when it reads the env var // itself — an explicit `url` is used verbatim and would silently POST // to the wrong path. Pass `url` only for gRPC, which has no path. - traceExporter: protocol === "grpc" + // `importExporter` types `OTLPTraceExporter` as `=> unknown` so the + // module graph stays free of the optional OTLP/SDK packages. Without + // that type, `traceExporter` needs `SpanExporter`, and an import of + // `SpanExporter` breaks the compile when the optional packages are + // absent. Cast to `never` instead: `never` is assignable to + // `SpanExporter` and needs no import. + traceExporter: (protocol === "grpc" ? new OTLPTraceExporter({ url: endpoint }) - : new OTLPTraceExporter(), + : new OTLPTraceExporter()) as never, instrumentations: [ getNodeAutoInstrumentations({ // Too chatty for this workload.