-
Notifications
You must be signed in to change notification settings - Fork 2
feat(sdk): opt-in OTLP payload compression (gzip + zstd) #304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Suggested change
|
||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
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
compressJsonPayloadin addition to the network request. Before this PR,JSON.stringifywas synchronous and effectively instant, so starting the timer before body preparation was fine. Now thatcompressJsonPayloadis async — involvingCompressionStreamornode:zlib.zstdCompress— the timeout can fire beforefetchImplever 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 theAbortControllerand timer only after compression completes, soflushTimeoutMsbounds the network I/O alone.Prompt for AI agents