All files / src/lib/telemetry zstd-transport.ts

100% Statements 71/71
89.09% Branches 49/55
100% Functions 17/17
100% Lines 70/70

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330                                                                                                                                  216x                   216x           216x   216x 216x                           52x 52x   1x     51x 1x     50x 50x 52x 52x         52x 52x 52x 52x     52x   52x 27x 27x 27x                               52x                                             59x 59x 59x 59x   59x 59x 50x   9x                                   27x   27x 27x   14x 27x 3x     14x 14x                         7x 7x 7x 7x 7x 7x                         14x 14x                               233x 130x   103x                                     281x 281x 88x     193x 167x     154x           26x 26x         54x                                     22x 22x 8x   14x   26x 26x 14x 4x   10x 13x      
/**
 * Custom Sentry SDK transport factory with zstd-first compression.
 *
 * Wraps `createTransport` from @sentry/core with a custom request executor
 * that compresses outgoing envelope bodies with zstd level 3 (libzstd
 * default). When running on a host without zstd support (Node < 22.15
 * without the polyfill installed), falls back to gzip — matches the
 * SDK's default behavior byte-for-byte so there's no regression.
 *
 * Codec selection is one-shot, performed at factory-construction time.
 * No per-request branching: if `node:zlib` zstd support is available
 * when the transport is created, every envelope uses zstd; otherwise
 * every envelope uses gzip.
 *
 * This mirrors `@sentry/node-core/transports/http.js` `makeNodeTransport`
 * — URL parsing, `no_proxy` handling, proxy agent, CA certs, keepAlive,
 * IPv6 hostname unwrapping, rate-limit response header normalization —
 * but swaps out the gzip-via-stream-pipe for an async one-shot compress
 * that sets the correct `Content-Encoding`.
 *
 * Why not patch the SDK:
 *   `Sentry.init({ transport })` is a first-class extension point on
 *   the Client. Going via a custom factory avoids patch-file maintenance
 *   across SDK upgrades and avoids the gzip→gunzip→zstd waste that an
 *   `httpModule` shim would incur (the SDK has already piped the body
 *   through `createGzip()` by the time `httpModule.request()` runs).
 */
 
// biome-ignore lint/performance/noNamespaceImport: http module must be passed as a namespace object (matches SDK's HTTPModule interface)
import * as http from "node:http";
// biome-ignore lint/performance/noNamespaceImport: same as above for https
import * as https from "node:https";
import { Readable } from "node:stream";
import { promisify } from "node:util";
import {
  gzip as gzipCb,
  constants as zlibConstants,
  zstdCompress as zstdCompressCb,
} from "node:zlib";
import {
  createTransport,
  suppressTracing,
  type Transport,
  type TransportMakeRequestResponse,
  type TransportRequest,
  type TransportRequestExecutor,
} from "@sentry/core";
import {
  makeNodeTransport,
  type NodeTransportOptions,
} from "@sentry/node-core/light";
 
/** Codec actually applied to a given envelope. */
type AppliedEncoding = "zstd" | "gzip" | "none";
 
/** Codec the transport will attempt; "none" only happens under threshold. */
type SelectedEncoding = "zstd" | "gzip";
 
/**
 * zstd compression level. L3 is libzstd's default and was confirmed
 * optimal for telemetry-sized payloads (1–30 KiB) by an offline
 * benchmark before merge: L3–L6 sit on the same ratio-vs-time curve,
 * and L3 wins on compress time without losing ratio. Higher levels
 * (≥9) regress compress time without meaningful ratio gains.
 */
const ZSTD_LEVEL = 3;
 
/**
 * Minimum body length above which we attempt compression.
 *
 * For zstd we lower this from the SDK's 32 KiB gzip threshold to 1 KiB
 * — Bun's zstd worker is cheap to dispatch and most error envelopes
 * (5–15 KiB) would miss the 32 KiB cutoff and ship uncompressed
 * otherwise.
 */
const ZSTD_THRESHOLD = 1024;
 
/**
 * Matches the SDK default. Kept identical to avoid any byte-level
 * regression when the zstd fast path is unavailable.
 */
const GZIP_THRESHOLD = 1024 * 32;
 
const gzipAsync = promisify(gzipCb);
const zstdCompressAsync = promisify(zstdCompressCb);
 
/**
 * Factory for the SDK's `Sentry.init({ transport })` option.
 *
 * Falls back to `makeNodeTransport` when a proxy is configured (the SDK
 * owns CONNECT tunneling) or when the DSN URL is unparseable. Otherwise
 * picks a one-shot codec — zstd if available, gzip otherwise — and
 * wires up an executor.
 */
export function makeCompressedTransport(
  options: NodeTransportOptions
): Transport {
  let urlSegments: URL;
  try {
    urlSegments = new URL(options.url);
  } catch {
    return createTransport(options, () => Promise.resolve({}));
  }
 
  if (shouldFallbackToDefault(urlSegments, options)) {
    return makeNodeTransport(options);
  }
 
  const isHttps = urlSegments.protocol === "https:";
  const nativeHttpModule = isHttps ? https : http;
  const keepAlive = options.keepAlive ?? false;
  const agent = new nativeHttpModule.Agent({
    keepAlive,
    maxSockets: 30,
    timeout: 2000,
  });
  const httpModule = options.httpModule ?? nativeHttpModule;
  const encoding: SelectedEncoding = hasZstdSupport() ? "zstd" : "gzip";
  const hostnameIsIPv6 = urlSegments.hostname.startsWith("[");
  const hostname = hostnameIsIPv6
    ? urlSegments.hostname.slice(1, -1)
    : urlSegments.hostname;
  const path = `${urlSegments.pathname}${urlSegments.search}`;
 
  const executor: TransportRequestExecutor = (request: TransportRequest) =>
    new Promise<TransportMakeRequestResponse>((resolve, reject) => {
      suppressTracing(() => {
        performRequest({
          request,
          options,
          httpModule,
          agent,
          encoding,
          hostname,
          path,
          port: urlSegments.port,
          protocol: urlSegments.protocol,
        })
          .then(resolve)
          .catch(reject);
      });
    });
 
  return createTransport(options, executor);
}
 
/**
 * True iff a proxy is configured for this URL and not exempted by
 * no_proxy. When true, the caller falls back to the SDK's default
 * transport (which handles CONNECT tunneling).
 *
 * Mirrors `@sentry/node-core/transports/http.js` `applyNoProxyOption`'s
 * proxy-resolution priority:
 *   - http  → `options.proxy` | `http_proxy`
 *   - https → `options.proxy` | `https_proxy` | `http_proxy`
 *
 * Both upper- and lowercase env vars are recognized so behavior matches
 * cURL / Node ecosystem convention. Lowercase wins when both are set,
 * staying consistent with the SDK and {@link isNoProxyExempt}.
 *
 * @internal Exported for tests.
 */
export function shouldFallbackToDefault(
  url: URL,
  options: NodeTransportOptions
): boolean {
  const isHttps = url.protocol === "https:";
  const httpProxy = process.env.http_proxy ?? process.env.HTTP_PROXY;
  const httpsProxy = process.env.https_proxy ?? process.env.HTTPS_PROXY;
  const envProxy = isHttps ? httpsProxy : httpProxy;
  // SDK precedent: HTTPS falls back to http_proxy as a last resort.
  const proxy = options.proxy || envProxy || httpProxy;
  if (!proxy) {
    return false;
  }
  return !isNoProxyExempt(url);
}
 
type PerformRequestArgs = {
  request: TransportRequest;
  options: NodeTransportOptions;
  httpModule: NonNullable<NodeTransportOptions["httpModule"]>;
  agent: http.Agent;
  encoding: SelectedEncoding;
  hostname: string;
  path: string;
  port: string;
  protocol: string;
};
 
async function performRequest(
  args: PerformRequestArgs
): Promise<TransportMakeRequestResponse> {
  const { request, options, httpModule, agent, encoding } = args;
 
  const rawBuffer = normalizeBody(request.body);
  const { payload, encodingApplied } = await maybeCompress(rawBuffer, encoding);
 
  const headers: Record<string, string> = { ...(options.headers ?? {}) };
  if (encodingApplied !== "none") {
    headers["content-encoding"] = encodingApplied;
  }
 
  return new Promise<TransportMakeRequestResponse>((resolve, reject) => {
    const req = httpModule.request(
      {
        method: "POST",
        agent,
        headers,
        hostname: args.hostname,
        path: args.path,
        port: args.port,
        protocol: args.protocol,
        ca: options.caCerts,
      },
      (res) => {
        // Drain the response body
        res.on("data", drain);
        res.on("end", drain);
        res.setEncoding("utf8");
        const retryAfterHeader = res.headers["retry-after"] ?? null;
        const rateLimitsHeader = res.headers["x-sentry-rate-limits"] ?? null;
        resolve({
          statusCode: res.statusCode,
          headers: {
            "retry-after": Array.isArray(retryAfterHeader)
              ? (retryAfterHeader[0] ?? null)
              : retryAfterHeader,
            "x-sentry-rate-limits": Array.isArray(rateLimitsHeader)
              ? (rateLimitsHeader[0] ?? null)
              : rateLimitsHeader,
          },
        });
      }
    );
    req.on("error", reject);
    Readable.from(payload).pipe(req);
  });
}
 
/** No-op used to drain HTTP response bodies. */
function drain(): void {
  // intentionally empty
}
 
/**
 * Coerce `string | Uint8Array` into a contiguous Buffer (zero-copy for
 * Uint8Array; UTF-8 encoded for strings).
 *
 * @internal Exported for tests.
 */
export function normalizeBody(body: string | Uint8Array): Buffer {
  if (typeof body === "string") {
    return Buffer.from(body, "utf-8");
  }
  return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
}
 
type CompressResult = {
  payload: Buffer;
  encodingApplied: AppliedEncoding;
};
 
/**
 * Apply the pre-selected codec iff the body is large enough to benefit.
 * Under threshold → passthrough with `encoding: "none"` (matches SDK
 * default behavior).
 *
 * @internal Exported for tests.
 */
export async function maybeCompress(
  buf: Buffer,
  encoding: SelectedEncoding
): Promise<CompressResult> {
  const threshold = encoding === "zstd" ? ZSTD_THRESHOLD : GZIP_THRESHOLD;
  if (buf.length <= threshold) {
    return { payload: buf, encodingApplied: "none" };
  }
 
  if (encoding === "zstd") {
    const out = await zstdCompressAsync(buf, {
      params: { [zlibConstants.ZSTD_c_compressionLevel]: ZSTD_LEVEL },
    });
    return {
      payload: Buffer.from(out.buffer, out.byteOffset, out.byteLength),
      encodingApplied: "zstd",
    };
  }
 
  const gz = await gzipAsync(buf);
  return { payload: gz, encodingApplied: "gzip" };
}
 
/** Feature-detect zstd support on the current runtime. */
export function hasZstdSupport(): boolean {
  return typeof zstdCompressCb === "function";
}
 
/**
 * Mirror the SDK's `applyNoProxyOption`: returns true iff the target
 * URL matches an entry in `NO_PROXY` / `no_proxy`, in which case the
 * proxy should be ignored.
 *
 * Slightly more permissive than the SDK:
 *   - Whitespace around comma-separated entries is trimmed
 *     (`"a.com, b.com"` is common; SDK does not trim).
 *   - The `"*"` wildcard means "bypass proxy for all hosts" — a
 *     convention from cURL / Go tooling that the SDK currently
 *     ignores (would route through the proxy regardless). We honor
 *     it so users with `no_proxy="*"` keep the zstd path.
 *
 * @internal Exported for tests.
 */
export function isNoProxyExempt(urlSegments: URL): boolean {
  const noProxy = process.env.no_proxy ?? process.env.NO_PROXY;
  if (!noProxy) {
    return false;
  }
  const entries = noProxy
    .split(",")
    .map((ex) => ex.trim())
    .filter((ex) => ex.length > 0);
  if (entries.includes("*")) {
    return true;
  }
  return entries.some(
    (ex) => urlSegments.host.endsWith(ex) || urlSegments.hostname.endsWith(ex)
  );
}