Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/sdk/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,22 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
// Priority: auth token (lowest) → DECO_OTEL_HEADERS env → otlpHeaders option (highest).
const otlpHeaders: Record<string, string> = { ...authHeader, ...parsedEnvHeaders, ...(opts.otlpHeaders ?? {}) };

// Compression: opt-in via `DECO_OTEL_COMPRESSION=gzip|zstd`. Default
// `"none"` preserves the pre-flag wire behavior for sites whose collector
// isn't configured for `Content-Encoding`. `zstd` silently falls back to
// gzip if the runtime lacks `node:zlib.zstdCompress` (workerd without
// `nodejs_compat`, Node <22, etc.); the request always advertises the
// actual encoding via `Content-Encoding`.
const otlpCompressionRaw = (
(env["DECO_OTEL_COMPRESSION"] as string | undefined) ?? ""
).toLowerCase();
const otlpCompression: "zstd" | "gzip" | "none" =
otlpCompressionRaw === "zstd"
? "zstd"
: otlpCompressionRaw === "gzip"
? "gzip"
: "none";

const errorPromotionEnvVar =
opts.otlpTracesErrorPromotionEnvVar ?? "DECO_OTEL_ERROR_PROMOTION";
const errorPromotionEnabled =
Expand All @@ -744,6 +760,7 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
scopeVersion: decoRuntimeVersion,
minLevel: otlpLogsMinLevel,
headers: otlpHeaders,
compression: otlpCompression,
fetchImpl: opts.otlpLogsFetchImpl,
promoteTrace: errorPromotionEnabled
? (traceId) => state.otlpTracer?.promoteTrace(traceId)
Expand Down Expand Up @@ -813,6 +830,7 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
resourceAttributes: floor,
scopeVersion: decoRuntimeVersion,
headers: otlpHeaders,
compression: otlpCompression,
fetchImpl: opts.otlpMetricsFetchImpl,
metricMetadata: METRIC_METADATA,
onError: (kind, err) => {
Expand Down Expand Up @@ -883,6 +901,7 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
headSamplingRate: samplingRateOverride ?? opts.otlpTracesSamplingRate ?? 0.01,
errorPromotionRate,
headers: otlpHeaders,
compression: otlpCompression,
fetchImpl: opts.otlpTracesFetchImpl,
getActiveSpanForParent: () => getActiveSpan(),
getRequestTraceContext,
Expand Down
18 changes: 16 additions & 2 deletions src/sdk/otelHttpLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

import { getActiveSpan } from "./observability";
import type { LoggerAdapter, LogLevel } from "./logger";
import { compressJsonPayload, type OtlpCompression } from "./otlpCompression";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -86,6 +87,12 @@ export interface OtlpHttpLogOptions {
* `Content-Type: application/json` is always set and cannot be overridden here.
*/
headers?: Record<string, string>;
/**
* Payload compression. `"gzip"` uses the runtime's native `CompressionStream`
* and sets `Content-Encoding: gzip`. Defaults to `"none"` — the caller
* (`bootObservability`) opts into gzip via `DECO_OTEL_COMPRESSION`.
*/
compression?: OtlpCompression;
/** Test seam — override fetch. */
fetchImpl?: typeof fetch;
/** Test seam — override Date.now(). */
Expand Down Expand Up @@ -154,6 +161,7 @@ export function createOtlpHttpLogAdapter(options: OtlpHttpLogOptions): OtlpHttpL
const refillPerMinute = options.rateLimitRefillPerMinute ?? 1000;
const minSeverity = SEVERITY_NUMBER[options.minLevel ?? "error"];
const extraHeaders = options.headers ?? {};
const compression: OtlpCompression = options.compression ?? "none";
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.nowMs ?? (() => Date.now());
const onError = options.onError;
Expand Down Expand Up @@ -301,10 +309,16 @@ export function createOtlpHttpLogAdapter(options: OtlpHttpLogOptions): OtlpHttpL
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
try {
const { body, contentEncoding } = await compressJsonPayload(payload, compression);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The flush timeout timer now covers compressJsonPayload in addition to the network request. Before this PR, JSON.stringify was synchronous and effectively instant, so starting the timer before body preparation was fine. Now that compressJsonPayload is async — involving CompressionStream or node:zlib.zstdCompress — the timeout can fire before fetchImpl ever starts. This causes unnecessary aborts, which restore the snapshot to the buffer, and repeated failures can push the buffer to its cap and drop subsequent logs. Consider starting the AbortController and timer only after compression completes, so flushTimeoutMs bounds the network I/O alone.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sdk/otelHttpLog.ts, line 312:

<comment>The flush timeout timer now covers `compressJsonPayload` in addition to the network request. Before this PR, `JSON.stringify` was synchronous and effectively instant, so starting the timer before body preparation was fine. Now that `compressJsonPayload` is async — involving `CompressionStream` or `node:zlib.zstdCompress` — the timeout can fire before `fetchImpl` ever starts. This causes unnecessary aborts, which restore the snapshot to the buffer, and repeated failures can push the buffer to its cap and drop subsequent logs. Consider starting the `AbortController` and timer only after compression completes, so `flushTimeoutMs` bounds the network I/O alone.</comment>

<file context>
@@ -301,10 +309,16 @@ export function createOtlpHttpLogAdapter(options: OtlpHttpLogOptions): OtlpHttpL
     const controller = new AbortController();
     const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
     try {
+      const { body, contentEncoding } = await compressJsonPayload(payload, compression);
+      const headers: Record<string, string> = {
+        ...extraHeaders,
</file context>

const headers: Record<string, string> = {
...extraHeaders,
"Content-Type": "application/json",
};
if (contentEncoding) headers["Content-Encoding"] = contentEncoding;
const res = await fetchImpl(endpoint, {
method: "POST",
headers: { ...extraHeaders, "Content-Type": "application/json" },
body: JSON.stringify(payload),
headers,
body,
signal: controller.signal,
});
if (!res.ok) {
Expand Down
17 changes: 15 additions & 2 deletions src/sdk/otelHttpMeter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
*/

import type { MeterAdapter } from "./observability";
import { compressJsonPayload, type OtlpCompression } from "./otlpCompression";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -65,6 +66,11 @@ export interface OtlpHttpMeterOptions {
* `Content-Type: application/json` is always set and cannot be overridden here.
*/
headers?: Record<string, string>;
/**
* Payload compression. `"gzip"` uses the runtime's native `CompressionStream`
* and sets `Content-Encoding: gzip`. Defaults to `"none"`.
*/
compression?: OtlpCompression;
/**
* Test seam — override fetch for the flush path so unit tests can
* inspect the OTLP payload without going to the network.
Expand Down Expand Up @@ -153,6 +159,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
const minFlushIntervalMs = options.minFlushIntervalMs ?? 5000;
const flushTimeoutMs = options.flushTimeoutMs ?? 5000;
const extraHeaders = options.headers ?? {};
const compression: OtlpCompression = options.compression ?? "none";
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.nowMs ?? (() => Date.now());
const onError = options.onError;
Expand Down Expand Up @@ -317,10 +324,16 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
try {
const { body, contentEncoding } = await compressJsonPayload(payload, compression);
const headers: Record<string, string> = {
...extraHeaders,
"Content-Type": "application/json",
};
if (contentEncoding) headers["Content-Encoding"] = contentEncoding;
const res = await fetchImpl(endpoint, {
method: "POST",
headers: { ...extraHeaders, "Content-Type": "application/json" },
body: JSON.stringify(payload),
headers,
body,
signal: controller.signal,
});
if (!res.ok) {
Expand Down
17 changes: 15 additions & 2 deletions src/sdk/otelHttpTracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
*/

import type { Span, TracerAdapter } from "../middleware/observability";
import { compressJsonPayload, type OtlpCompression } from "./otlpCompression";

// ---------------------------------------------------------------------------
// W3C traceparent parsing
Expand Down Expand Up @@ -201,6 +202,11 @@ export interface OtlpHttpTracerOptions {
* `Content-Type: application/json` is always set and cannot be overridden here.
*/
headers?: Record<string, string>;
/**
* Payload compression. `"gzip"` uses the runtime's native `CompressionStream`
* and sets `Content-Encoding: gzip`. Defaults to `"none"`.
*/
compression?: OtlpCompression;
/**
* Test seam — override `fetch` for the flush path. Same role as in
* `otelHttpMeter.ts`.
Expand Down Expand Up @@ -259,6 +265,7 @@ export function createOtlpHttpTracerAdapter(options: OtlpHttpTracerOptions): Otl
const minFlushIntervalMs = options.minFlushIntervalMs ?? 5000;
const flushTimeoutMs = options.flushTimeoutMs ?? 5000;
const extraHeaders = options.headers ?? {};
const compression: OtlpCompression = options.compression ?? "none";
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.nowMs ?? (() => Date.now());
const onError = options.onError;
Expand Down Expand Up @@ -412,10 +419,16 @@ export function createOtlpHttpTracerAdapter(options: OtlpHttpTracerOptions): Otl
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
try {
const { body, contentEncoding } = await compressJsonPayload(payload, compression);
const headers: Record<string, string> = {
...extraHeaders,
"Content-Type": "application/json",
};
if (contentEncoding) headers["Content-Encoding"] = contentEncoding;
const res = await fetchImpl(endpoint, {
method: "POST",
headers: { ...extraHeaders, "Content-Type": "application/json" },
body: JSON.stringify(payload),
headers,
body,
signal: controller.signal,
});
if (!res.ok) {
Expand Down
82 changes: 82 additions & 0 deletions src/sdk/otlpCompression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* OTLP/HTTP payload compression.
*
* Two algorithms:
* - `gzip`: native `CompressionStream("gzip")`. Zero deps, works in every
* runtime (workerd, Node ≥18, Deno, Bun).
* - `zstd`: `node:zlib` `zstdCompress`. Requires `nodejs_compat` on workerd
* (zstd landed in Mar 2026 via cloudflare/workerd#4013) and Node ≥22 on k8s.
* ~15-25% smaller than gzip on OTLP JSON batches and cheaper for the
* collector to decode. `CompressionStream("zstd")` is still non-standard
* (Bun-only, Feb 2026) — revisit when workerd ships it too, then this
* file collapses to a single line per format.
*
* When `compression: "zstd"` is requested but the runtime doesn't provide
* `node:zlib.zstdCompress` (no `nodejs_compat`, Node <22, etc.), we silently
* fall back to gzip. The response header always reflects the actual encoding
* used, so the collector doesn't need to know which transport was picked.
*/

export type OtlpCompression = "zstd" | "gzip" | "none";

export async function compressJsonPayload(
payload: unknown,
compression: OtlpCompression,
): Promise<{ body: BodyInit; contentEncoding?: string }> {
const json = JSON.stringify(payload);
if (compression === "none") return { body: json };

const bytes = new TextEncoder().encode(json);

if (compression === "zstd") {
const zstdBytes = await tryZstd(bytes);
if (zstdBytes) return { body: zstdBytes, contentEncoding: "zstd" };
// Fall through to gzip when zstd is unavailable in this runtime.
}

const stream = new Response(bytes).body!.pipeThrough(
new CompressionStream("gzip"),
);
const gzipBytes = new Uint8Array(await new Response(stream).arrayBuffer());
return { body: gzipBytes, contentEncoding: "gzip" };
}

// Lazy, cached probe of `node:zlib` zstd. `undefined` = not yet probed,
// `null` = probed and unavailable, function = probed and usable.
let zstdImpl: ((bytes: Uint8Array) => Promise<Uint8Array>) | null | undefined;

async function tryZstd(bytes: Uint8Array): Promise<Uint8Array | null> {
if (zstdImpl === undefined) {
zstdImpl = await probeZstd();
}
if (!zstdImpl) return null;
try {
return await zstdImpl(bytes);
} catch {
return null;
Comment on lines +55 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the probed zstd implementation is present but fails at runtime (e.g. memory pressure, runtime bugs, partially supported node:zlib), the catch block in tryZstd returns null without invalidating zstdImpl. That means every subsequent OTLP export request re-enters the failing path, throws again, and then falls back to gzip — creating repeated exception overhead on every payload instead of degrading once. After the first runtime failure, consider setting zstdImpl = null so the exporter skips straight to gzip on all subsequent calls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sdk/otlpCompression.ts, line 55:

<comment>When the probed zstd implementation is present but fails at runtime (e.g. memory pressure, runtime bugs, partially supported `node:zlib`), the catch block in `tryZstd` returns `null` without invalidating `zstdImpl`. That means every subsequent OTLP export request re-enters the failing path, throws again, and then falls back to gzip — creating repeated exception overhead on every payload instead of degrading once. After the first runtime failure, consider setting `zstdImpl = null` so the exporter skips straight to gzip on all subsequent calls.</comment>

<file context>
@@ -0,0 +1,82 @@
+  if (!zstdImpl) return null;
+  try {
+    return await zstdImpl(bytes);
+  } catch {
+    return null;
+  }
</file context>
Suggested change
} catch {
return null;
} catch {
zstdImpl = null;
return null;
}

}
}

async function probeZstd(): Promise<((bytes: Uint8Array) => Promise<Uint8Array>) | null> {
try {
// Namespace import so bundlers don't try to resolve `node:zlib` at build
// time in browser/edge builds that never call this path.
const zlib = await import(/* @vite-ignore */ "node:zlib");
const zstdCompress = (zlib as unknown as {
zstdCompress?: (
buf: Uint8Array,
cb: (err: Error | null, result: Buffer) => void,
) => void;
}).zstdCompress;
if (typeof zstdCompress !== "function") return null;
return (buf) =>
new Promise((resolve, reject) => {
zstdCompress(buf, (err, result) => {
if (err) reject(err);
else resolve(new Uint8Array(result));
});
});
} catch {
return null;
}
}