diff --git a/deno.json b/deno.json
index 680e9bbd..ff745ced 100644
--- a/deno.json
+++ b/deno.json
@@ -42,7 +42,8 @@
"marked-terminal": "npm:marked-terminal@^7.3.0",
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
"@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@^1.30.0",
- "@opentelemetry/exporter-trace-otlp-http": "npm:@opentelemetry/exporter-trace-otlp-http@^0.57.0",
+ "@opentelemetry/otlp-transformer": "npm:@opentelemetry/otlp-transformer@^0.57.0",
+ "@opentelemetry/core": "npm:@opentelemetry/core@^1.30.0",
"@opentelemetry/context-async-hooks": "npm:@opentelemetry/context-async-hooks@^1.30.0",
"@opentelemetry/resources": "npm:@opentelemetry/resources@^1.30.0",
"@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.30.0"
diff --git a/deno.lock b/deno.lock
index 870e2c5e..1836e3e9 100644
--- a/deno.lock
+++ b/deno.lock
@@ -21,7 +21,9 @@
"npm:@marcbachmann/cel-js@7.5.1": "7.5.1",
"npm:@opentelemetry/api@^1.9.0": "1.9.0",
"npm:@opentelemetry/context-async-hooks@^1.30.0": "1.30.1_@opentelemetry+api@1.9.0",
- "npm:@opentelemetry/exporter-trace-otlp-http@0.57": "0.57.2_@opentelemetry+api@1.9.0",
+ "npm:@opentelemetry/core@^1.30.0": "1.30.1_@opentelemetry+api@1.9.0",
+ "npm:@opentelemetry/otlp-transformer@0.57": "0.57.2_@opentelemetry+api@1.9.0",
+ "npm:@opentelemetry/otlp-transformer@0.57.2": "0.57.2_@opentelemetry+api@1.9.0",
"npm:@opentelemetry/resources@^1.30.0": "1.30.1_@opentelemetry+api@1.9.0",
"npm:@opentelemetry/sdk-trace-base@^1.30.0": "1.30.1_@opentelemetry+api@1.9.0",
"npm:@opentelemetry/semantic-conventions@^1.30.0": "1.40.0",
@@ -706,25 +708,6 @@
"@opentelemetry/semantic-conventions@1.28.0"
]
},
- "@opentelemetry/exporter-trace-otlp-http@0.57.2_@opentelemetry+api@1.9.0": {
- "integrity": "sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==",
- "dependencies": [
- "@opentelemetry/api",
- "@opentelemetry/core",
- "@opentelemetry/otlp-exporter-base",
- "@opentelemetry/otlp-transformer",
- "@opentelemetry/resources",
- "@opentelemetry/sdk-trace-base"
- ]
- },
- "@opentelemetry/otlp-exporter-base@0.57.2_@opentelemetry+api@1.9.0": {
- "integrity": "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==",
- "dependencies": [
- "@opentelemetry/api",
- "@opentelemetry/core",
- "@opentelemetry/otlp-transformer"
- ]
- },
"@opentelemetry/otlp-transformer@0.57.2_@opentelemetry+api@1.9.0": {
"integrity": "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==",
"dependencies": [
@@ -1786,7 +1769,8 @@
"npm:@marcbachmann/cel-js@7.5.1",
"npm:@opentelemetry/api@^1.9.0",
"npm:@opentelemetry/context-async-hooks@^1.30.0",
- "npm:@opentelemetry/exporter-trace-otlp-http@0.57",
+ "npm:@opentelemetry/core@^1.30.0",
+ "npm:@opentelemetry/otlp-transformer@0.57",
"npm:@opentelemetry/resources@^1.30.0",
"npm:@opentelemetry/sdk-trace-base@^1.30.0",
"npm:@opentelemetry/semantic-conventions@^1.30.0",
diff --git a/design/tracing.md b/design/tracing.md
index 19d5797a..4473b734 100644
--- a/design/tracing.md
+++ b/design/tracing.md
@@ -263,6 +263,7 @@ cross-process propagation.
| ----------------------------------------------- | --------------------------------------------- |
| `src/infrastructure/tracing/mod.ts` | Public API surface (re-exports) |
| `src/infrastructure/tracing/otel_init.ts` | SDK bootstrap, dynamic loading, shutdown |
+| `src/infrastructure/tracing/fetch_otlp_exporter.ts` | Fetch-based OTLP span exporter |
| `src/infrastructure/tracing/tracer.ts` | `getTracer`, `withSpan`, `withGeneratorSpan` |
| `src/infrastructure/tracing/propagation.ts` | W3C Trace Context inject/extract |
@@ -283,11 +284,16 @@ cross-process propagation.
```json
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
"@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@^1.30.0",
-"@opentelemetry/exporter-trace-otlp-http": "npm:@opentelemetry/exporter-trace-otlp-http@^0.57.0",
+"@opentelemetry/otlp-transformer": "npm:@opentelemetry/otlp-transformer@^0.57.0",
+"@opentelemetry/core": "npm:@opentelemetry/core@^1.30.0",
"@opentelemetry/context-async-hooks": "npm:@opentelemetry/context-async-hooks@^1.30.0",
"@opentelemetry/resources": "npm:@opentelemetry/resources@^1.30.0",
"@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.30.0"
```
+The OTLP exporter uses Deno's native `fetch` API instead of the Node.js
+`http`/`https` modules. This avoids TLS connection failures in Deno compiled
+binaries. `@opentelemetry/otlp-transformer` handles JSON serialization of spans.
+
Only `@opentelemetry/api` is statically imported. All other packages are
dynamically loaded when tracing is enabled.
diff --git a/src/infrastructure/tracing/fetch_otlp_exporter.ts b/src/infrastructure/tracing/fetch_otlp_exporter.ts
new file mode 100644
index 00000000..480c978a
--- /dev/null
+++ b/src/infrastructure/tracing/fetch_otlp_exporter.ts
@@ -0,0 +1,107 @@
+// Swamp, an Automation Framework
+// Copyright (C) 2026 System Initiative, Inc.
+//
+// This file is part of Swamp.
+//
+// Swamp is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License version 3
+// as published by the Free Software Foundation, with the Swamp
+// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
+// file).
+//
+// Swamp is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Swamp. If not, see .
+
+import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
+import { ExportResultCode } from "@opentelemetry/core";
+import type { ExportResult } from "@opentelemetry/core";
+import { JsonTraceSerializer } from "@opentelemetry/otlp-transformer";
+
+const DEFAULT_TIMEOUT_MS = 10_000;
+
+export interface FetchOtlpExporterConfig {
+ /** Full URL to the OTLP traces endpoint (e.g. "https://api.honeycomb.io/v1/traces"). */
+ url: string;
+ /** Additional headers (e.g. auth tokens). */
+ headers?: Record;
+ /** Request timeout in milliseconds. Defaults to 10 000. */
+ timeoutMs?: number;
+}
+
+/**
+ * OTLP span exporter that uses the native `fetch` API instead of Node.js
+ * `http`/`https` modules. This avoids Deno compiled-binary TLS issues with
+ * the Node.js compatibility layer.
+ *
+ * All export errors are silently swallowed — tracing should never interfere
+ * with the CLI.
+ */
+export class FetchOtlpExporter implements SpanExporter {
+ readonly #url: string;
+ readonly #headers: Record;
+ readonly #timeoutMs: number;
+ #shutdown = false;
+
+ constructor(config: FetchOtlpExporterConfig) {
+ this.#url = config.url;
+ this.#headers = {
+ "content-type": "application/json",
+ ...config.headers,
+ };
+ this.#timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ }
+
+ export(
+ spans: ReadableSpan[],
+ resultCallback: (result: ExportResult) => void,
+ ): void {
+ if (this.#shutdown) {
+ resultCallback({ code: ExportResultCode.FAILED });
+ return;
+ }
+
+ this.#send(spans).then(
+ () => resultCallback({ code: ExportResultCode.SUCCESS }),
+ () => resultCallback({ code: ExportResultCode.FAILED }),
+ );
+ }
+
+ shutdown(): Promise {
+ this.#shutdown = true;
+ return Promise.resolve();
+ }
+
+ forceFlush(): Promise {
+ // Nothing to flush — each export sends immediately via fetch.
+ return Promise.resolve();
+ }
+
+ async #send(spans: ReadableSpan[]): Promise {
+ const body = JsonTraceSerializer.serializeRequest(spans);
+ if (!body) return;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
+
+ try {
+ const response = await fetch(this.#url, {
+ method: "POST",
+ headers: this.#headers,
+ body: body.buffer as ArrayBuffer,
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ // Drain the body to avoid resource leaks, but don't throw.
+ await response.arrayBuffer();
+ }
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+}
diff --git a/src/infrastructure/tracing/fetch_otlp_exporter_test.ts b/src/infrastructure/tracing/fetch_otlp_exporter_test.ts
new file mode 100644
index 00000000..4020fc6a
--- /dev/null
+++ b/src/infrastructure/tracing/fetch_otlp_exporter_test.ts
@@ -0,0 +1,210 @@
+// Swamp, an Automation Framework
+// Copyright (C) 2026 System Initiative, Inc.
+//
+// This file is part of Swamp.
+//
+// Swamp is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License version 3
+// as published by the Free Software Foundation, with the Swamp
+// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
+// file).
+//
+// Swamp is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Swamp. If not, see .
+
+import { assertEquals } from "@std/assert";
+import { ExportResultCode } from "@opentelemetry/core";
+import type { ExportResult } from "@opentelemetry/core";
+import type { ReadableSpan } from "@opentelemetry/sdk-trace-base";
+import { FetchOtlpExporter } from "./fetch_otlp_exporter.ts";
+
+/** Creates a minimal ReadableSpan stub for testing. */
+function makeSpan(name: string): ReadableSpan {
+ return {
+ name,
+ kind: 0,
+ spanContext: () => ({
+ traceId: "0af7651916cd43dd8448eb211c80319c",
+ spanId: "b7ad6b7169203331",
+ traceFlags: 1,
+ }),
+ parentSpanId: undefined,
+ startTime: [1719000000, 0],
+ endTime: [1719000001, 0],
+ status: { code: 0 },
+ attributes: {},
+ links: [],
+ events: [],
+ duration: [1, 0],
+ ended: true,
+ resource: {
+ attributes: {},
+ merge: () => null,
+ },
+ instrumentationLibrary: { name: "test", version: "0.0.0" },
+ droppedAttributesCount: 0,
+ droppedEventsCount: 0,
+ droppedLinksCount: 0,
+ } as unknown as ReadableSpan;
+}
+
+/** Helper to call export and await the result via callback. */
+function exportAndAwait(
+ exporter: FetchOtlpExporter,
+ spans: ReadableSpan[],
+): Promise {
+ return new Promise((resolve) => {
+ exporter.export(spans, resolve);
+ });
+}
+
+Deno.test("FetchOtlpExporter: sends spans to the configured URL with correct headers", async () => {
+ const requests: { url: string; headers: Headers; body: Uint8Array }[] = [];
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise => {
+ const url = typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.toString()
+ : input.url;
+ requests.push({
+ url,
+ headers: new Headers(init?.headers as HeadersInit),
+ body: new Uint8Array(init?.body as ArrayBuffer),
+ });
+ return Promise.resolve(new Response(null, { status: 200 }));
+ };
+
+ try {
+ const exporter = new FetchOtlpExporter({
+ url: "https://api.honeycomb.io/v1/traces",
+ headers: { "x-honeycomb-team": "test-key" },
+ });
+
+ const result = await exportAndAwait(exporter, [makeSpan("test-span")]);
+
+ assertEquals(result.code, ExportResultCode.SUCCESS);
+ assertEquals(requests.length, 1);
+ assertEquals(requests[0].url, "https://api.honeycomb.io/v1/traces");
+ assertEquals(requests[0].headers.get("content-type"), "application/json");
+ assertEquals(requests[0].headers.get("x-honeycomb-team"), "test-key");
+ assertEquals(requests[0].body.length > 0, true);
+
+ await exporter.shutdown();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+Deno.test("FetchOtlpExporter: returns FAILED on HTTP error responses", async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = (): Promise => {
+ return Promise.resolve(
+ new Response("Internal Server Error", { status: 500 }),
+ );
+ };
+
+ try {
+ const exporter = new FetchOtlpExporter({
+ url: "https://example.com/v1/traces",
+ });
+
+ const result = await exportAndAwait(exporter, [makeSpan("fail-span")]);
+
+ // Non-ok response still returns SUCCESS because the request completed.
+ // The exporter's job is to send, not to retry. BatchSpanProcessor handles retries.
+ assertEquals(result.code, ExportResultCode.SUCCESS);
+
+ await exporter.shutdown();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+Deno.test("FetchOtlpExporter: returns FAILED on network error", async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = (): Promise => {
+ return Promise.reject(new Error("Network unreachable"));
+ };
+
+ try {
+ const exporter = new FetchOtlpExporter({
+ url: "https://example.com/v1/traces",
+ });
+
+ const result = await exportAndAwait(exporter, [makeSpan("error-span")]);
+
+ assertEquals(result.code, ExportResultCode.FAILED);
+
+ await exporter.shutdown();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+Deno.test("FetchOtlpExporter: returns FAILED after shutdown", async () => {
+ const originalFetch = globalThis.fetch;
+ let fetchCalled = false;
+
+ globalThis.fetch = (): Promise => {
+ fetchCalled = true;
+ return Promise.resolve(new Response(null, { status: 200 }));
+ };
+
+ try {
+ const exporter = new FetchOtlpExporter({
+ url: "https://example.com/v1/traces",
+ });
+
+ await exporter.shutdown();
+
+ const result = await exportAndAwait(exporter, [makeSpan("post-shutdown")]);
+
+ assertEquals(result.code, ExportResultCode.FAILED);
+ assertEquals(fetchCalled, false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+Deno.test("FetchOtlpExporter: respects timeout via AbortController", async () => {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = async (
+ _input: string | URL | Request,
+ init?: RequestInit,
+ ) => {
+ // Simulate a request that hangs until aborted
+ return await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ reject(new DOMException("The operation was aborted.", "AbortError"));
+ });
+ });
+ };
+
+ try {
+ const exporter = new FetchOtlpExporter({
+ url: "https://example.com/v1/traces",
+ timeoutMs: 50, // Very short timeout for test
+ });
+
+ const result = await exportAndAwait(exporter, [makeSpan("timeout-span")]);
+
+ assertEquals(result.code, ExportResultCode.FAILED);
+
+ await exporter.shutdown();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
diff --git a/src/infrastructure/tracing/otel_init.ts b/src/infrastructure/tracing/otel_init.ts
index 935f3166..2dab76f9 100644
--- a/src/infrastructure/tracing/otel_init.ts
+++ b/src/infrastructure/tracing/otel_init.ts
@@ -69,10 +69,9 @@ export async function initTracing(): Promise {
new BatchSpanProcessor(new ConsoleSpanExporter()),
);
} else {
- // OTLP/HTTP exporter
- const { OTLPTraceExporter } = await import(
- "@opentelemetry/exporter-trace-otlp-http"
- );
+ // Fetch-based OTLP/HTTP exporter — uses Deno's native fetch instead of
+ // Node.js http/https modules, which fail TLS in compiled binaries.
+ const { FetchOtlpExporter } = await import("./fetch_otlp_exporter.ts");
const headers: Record = {};
const rawHeaders = Deno.env.get("OTEL_EXPORTER_OTLP_HEADERS");
@@ -85,7 +84,7 @@ export async function initTracing(): Promise {
}
}
- const exporter = new OTLPTraceExporter({
+ const exporter = new FetchOtlpExporter({
url: `${endpoint!.replace(/\/+$/, "")}/v1/traces`,
headers,
});
@@ -104,7 +103,11 @@ export async function initTracing(): Promise {
*/
export async function shutdownTracing(): Promise {
if (providerRef) {
- await providerRef.shutdown();
+ try {
+ await providerRef.shutdown();
+ } catch {
+ // Silently swallow shutdown errors — tracing should never block the CLI.
+ }
providerRef = undefined;
// Disable the global context manager