From 078a039a4f862396b6a2d54ff37c95626cd64b96 Mon Sep 17 00:00:00 2001 From: Darpan Kakadia Date: Thu, 18 Jun 2026 17:16:51 +0200 Subject: [PATCH] Fix drained span local export --- .changeset/0000-avoid-local-otlp-v2.md | 5 + .../filter-when-drained-span-processor.ts | 19 +++- packages/otel/src/sdk.test.ts | 95 +++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 .changeset/0000-avoid-local-otlp-v2.md create mode 100644 packages/otel/src/sdk.test.ts diff --git a/.changeset/0000-avoid-local-otlp-v2.md b/.changeset/0000-avoid-local-otlp-v2.md new file mode 100644 index 0000000..3ab35dd --- /dev/null +++ b/.changeset/0000-avoid-local-otlp-v2.md @@ -0,0 +1,5 @@ +--- +"@vercel/otel": patch +--- + +Avoid local OTLP exports for drained Vercel traces when request context is lost before span end. diff --git a/packages/otel/src/processor/filter-when-drained-span-processor.ts b/packages/otel/src/processor/filter-when-drained-span-processor.ts index 355b622..1213d53 100644 --- a/packages/otel/src/processor/filter-when-drained-span-processor.ts +++ b/packages/otel/src/processor/filter-when-drained-span-processor.ts @@ -8,9 +8,12 @@ import { diag } from "@opentelemetry/api"; import { isDraining } from "../vercel-request-context/is-draining"; let reported = false; +const MAX_DRAINED_SPAN_KEYS = 10_000; /** @internal */ export class FilterWhenDrainedSpanProcessor implements SpanProcessor { + private readonly drainedSpanKeys = new Set(); + constructor(private processor: SpanProcessor) {} forceFlush(): Promise { @@ -23,6 +26,14 @@ export class FilterWhenDrainedSpanProcessor implements SpanProcessor { onStart(span: Span, parentContext: Context): void { if (isDraining()) { + const spanKey = getSpanKey(span); + if (this.drainedSpanKeys.size >= MAX_DRAINED_SPAN_KEYS) { + const oldestSpanKey = this.drainedSpanKeys.values().next(); + if (!oldestSpanKey.done) { + this.drainedSpanKeys.delete(oldestSpanKey.value); + } + } + this.drainedSpanKeys.add(spanKey); if (!reported) { reported = true; diag.debug( @@ -35,9 +46,15 @@ export class FilterWhenDrainedSpanProcessor implements SpanProcessor { } onEnd(span: ReadableSpan): void { - if (isDraining()) { + const spanKey = getSpanKey(span); + if (this.drainedSpanKeys.delete(spanKey) || isDraining()) { return; } this.processor.onEnd(span); } } + +function getSpanKey(span: Span | ReadableSpan): string { + const { traceId, spanId } = span.spanContext(); + return `${traceId}:${spanId}`; +} diff --git a/packages/otel/src/sdk.test.ts b/packages/otel/src/sdk.test.ts new file mode 100644 index 0000000..2f6b550 --- /dev/null +++ b/packages/otel/src/sdk.test.ts @@ -0,0 +1,95 @@ +import { trace } from "@opentelemetry/api"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Sdk } from "./sdk"; +import type { VercelRequestContext } from "./vercel-request-context/api"; + +const requestContextSymbol = Symbol.for("@vercel/request-context"); +const originalEnv = process.env; + +let activeContext: VercelRequestContext | undefined; + +beforeEach(() => { + activeContext = undefined; + process.env = { ...originalEnv }; + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_HEADERS; + delete process.env.OTEL_EXPORTER_OTLP_PROTOCOL; + delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS; + delete process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL; + delete process.env.VERCEL_OTEL_ENDPOINTS; + delete process.env.VERCEL_OTEL_ENDPOINTS_PORT; + delete process.env.VERCEL_OTEL_ENDPOINTS_PROTOCOL; + Reflect.set(globalThis, requestContextSymbol, { + get: () => activeContext, + }); +}); + +afterEach(() => { + trace.disable(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(globalThis, requestContextSymbol); + process.env = originalEnv; +}); + +describe("Sdk trace export", () => { + it("keeps automatic local OTLP export without trace drains", async () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response())); + vi.stubGlobal("fetch", fetchMock); + + await exportOneSpan(); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:4318/v1/traces", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("skips automatic local OTLP export when trace drains are configured", async () => { + activeContext = createDrainingContext(); + const fetchMock = vi.fn(() => Promise.resolve(new Response())); + vi.stubGlobal("fetch", fetchMock); + + await exportOneSpan(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("skips automatic local OTLP export when trace drain context is lost before span end", async () => { + activeContext = createDrainingContext(); + const fetchMock = vi.fn(() => Promise.resolve(new Response())); + vi.stubGlobal("fetch", fetchMock); + + await exportOneSpan(() => { + activeContext = undefined; + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + +}); + +async function exportOneSpan(beforeEnd?: () => void): Promise { + const sdk = new Sdk({ + autoDetectResources: false, + instrumentations: [], + serviceName: "test-service", + }); + sdk.start(); + const span = trace.getTracer("test").startSpan("test span"); + beforeEnd?.(); + span.end(); + await sdk.shutdown(); +} + +function createDrainingContext(): VercelRequestContext { + return { + headers: {}, + telemetry: { + reportSpans: vi.fn(), + traceDrains: ["traceful.dev"], + }, + url: "https://example.com", + waitUntil: vi.fn(), + }; +}