Skip to content

Commit 86ba28a

Browse files
committed
feat: add distributed tracing to connect CLI invocations to Sentry backend
Inject sentry-trace and baggage headers into all outgoing Sentry API requests so CLI traces are connected to backend traces. Manual injection in prepareHeaders() is required because Bun's fetch does not fire undici diagnostics channels, so the SDK's nativeNodeFetchIntegration auto-injection cannot work. Changes: - telemetry.ts: Add getSentryTracePropagationTargets() matching *.sentry.io, sentry.io, and self-hosted URLs. Update tracePropagationTargets from [] to use the new function. - sentry-client.ts: Import getTraceData from @sentry/bun and inject sentry-trace/baggage headers in prepareHeaders(). When telemetry is disabled, getTraceData() returns {} so no headers are injected. - telemetry.test.ts: Add tests for URL pattern matching (SaaS regional, bare sentry.io, non-sentry URLs, self-hosted, no SaaS duplication). Closes #389
1 parent 011a0dc commit 86ba28a

File tree

3 files changed

+164
-3
lines changed

3 files changed

+164
-3
lines changed

src/lib/sentry-client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* through the SDK function options (baseUrl, fetch, headers).
99
*/
1010

11+
import { getTraceData } from "@sentry/bun";
1112
import {
1213
DEFAULT_SENTRY_URL,
1314
getConfiguredSentryUrl,
@@ -80,6 +81,19 @@ function prepareHeaders(
8081
if (!headers.has("User-Agent")) {
8182
headers.set("User-Agent", getUserAgent());
8283
}
84+
85+
// Inject distributed tracing headers to connect CLI spans to backend traces.
86+
// Manual injection is required because Bun's fetch doesn't fire undici
87+
// diagnostics channels, so the SDK's nativeNodeFetchIntegration cannot work.
88+
// When telemetry is disabled, getTraceData() returns {} — no headers injected.
89+
const traceData = getTraceData();
90+
if (traceData["sentry-trace"]) {
91+
headers.set("sentry-trace", traceData["sentry-trace"]);
92+
}
93+
if (traceData.baggage) {
94+
headers.set("baggage", traceData.baggage);
95+
}
96+
8397
return headers;
8498
}
8599

src/lib/telemetry.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
import { chmodSync, statSync } from "node:fs";
1313
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
1414
import * as Sentry from "@sentry/bun";
15-
import { CLI_VERSION, SENTRY_CLI_DSN } from "./constants.js";
15+
import {
16+
CLI_VERSION,
17+
getConfiguredSentryUrl,
18+
SENTRY_CLI_DSN,
19+
} from "./constants.js";
1620
import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js";
1721
import { ApiError, AuthError } from "./errors.js";
1822
import { attachSentryReporter } from "./logger.js";
@@ -245,6 +249,37 @@ const EXCLUDED_INTEGRATIONS = new Set([
245249
/** Current beforeExit handler, tracked so it can be replaced on re-init */
246250
let currentBeforeExitHandler: (() => void) | null = null;
247251

252+
/** Match all SaaS regional URLs (us.sentry.io, de.sentry.io, o1234.ingest.us.sentry.io, etc.) */
253+
const SENTRY_SAAS_SUBDOMAIN_RE = /^https:\/\/[^/]*\.sentry\.io(\/|$)/;
254+
255+
/** Match the bare sentry.io domain itself */
256+
const SENTRY_SAAS_ROOT_RE = /^https:\/\/sentry\.io(\/|$)/;
257+
258+
/**
259+
* Build trace propagation targets for Sentry API URLs.
260+
*
261+
* Matches:
262+
* - SaaS: *.sentry.io (all regional URLs like us.sentry.io, de.sentry.io)
263+
* - SaaS: sentry.io itself
264+
* - Self-hosted: the configured SENTRY_HOST/SENTRY_URL if non-SaaS
265+
*
266+
* @internal Exported for testing
267+
*/
268+
export function getSentryTracePropagationTargets(): (string | RegExp)[] {
269+
const targets: (string | RegExp)[] = [
270+
SENTRY_SAAS_SUBDOMAIN_RE,
271+
SENTRY_SAAS_ROOT_RE,
272+
];
273+
274+
// Also match self-hosted Sentry instances
275+
const customUrl = getConfiguredSentryUrl();
276+
if (customUrl && !isSentrySaasUrl(customUrl)) {
277+
targets.push(customUrl);
278+
}
279+
280+
return targets;
281+
}
282+
248283
/**
249284
* Initialize Sentry for telemetry.
250285
*
@@ -272,8 +307,8 @@ export function initSentry(enabled: boolean): Sentry.BunClient | undefined {
272307
tracesSampleRate: 1,
273308
sampleRate: 1,
274309
release: CLI_VERSION,
275-
// Don't propagate traces to external services
276-
tracePropagationTargets: [],
310+
// Propagate traces to Sentry API for distributed tracing
311+
tracePropagationTargets: getSentryTracePropagationTargets(),
277312

278313
beforeSendTransaction: (event) => {
279314
// Remove server_name which may contain hostname (PII)

test/lib/telemetry.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as Sentry from "@sentry/bun";
1212
import { ApiError, AuthError } from "../../src/lib/errors.js";
1313
import {
1414
createTracedDatabase,
15+
getSentryTracePropagationTargets,
1516
initSentry,
1617
isClientApiError,
1718
recordApiErrorOnSpan,
@@ -927,3 +928,114 @@ describe("createTracedDatabase", () => {
927928
});
928929
});
929930
});
931+
932+
describe("getSentryTracePropagationTargets", () => {
933+
const SENTRY_URL_ENV = "SENTRY_URL";
934+
const SENTRY_HOST_ENV = "SENTRY_HOST";
935+
let originalSentryUrl: string | undefined;
936+
let originalSentryHost: string | undefined;
937+
938+
beforeEach(() => {
939+
originalSentryUrl = process.env[SENTRY_URL_ENV];
940+
originalSentryHost = process.env[SENTRY_HOST_ENV];
941+
delete process.env[SENTRY_URL_ENV];
942+
delete process.env[SENTRY_HOST_ENV];
943+
});
944+
945+
afterEach(() => {
946+
if (originalSentryUrl === undefined) {
947+
delete process.env[SENTRY_URL_ENV];
948+
} else {
949+
process.env[SENTRY_URL_ENV] = originalSentryUrl;
950+
}
951+
if (originalSentryHost === undefined) {
952+
delete process.env[SENTRY_HOST_ENV];
953+
} else {
954+
process.env[SENTRY_HOST_ENV] = originalSentryHost;
955+
}
956+
});
957+
958+
test("matches SaaS regional URLs", () => {
959+
const targets = getSentryTracePropagationTargets();
960+
const regexTargets = targets.filter(
961+
(t): t is RegExp => t instanceof RegExp
962+
);
963+
expect(
964+
regexTargets.some((r) => r.test("https://us.sentry.io/api/0/"))
965+
).toBe(true);
966+
expect(
967+
regexTargets.some((r) => r.test("https://de.sentry.io/api/0/"))
968+
).toBe(true);
969+
expect(
970+
regexTargets.some((r) =>
971+
r.test("https://o1234.ingest.us.sentry.io/api/0/")
972+
)
973+
).toBe(true);
974+
});
975+
976+
test("matches bare sentry.io", () => {
977+
const targets = getSentryTracePropagationTargets();
978+
const regexTargets = targets.filter(
979+
(t): t is RegExp => t instanceof RegExp
980+
);
981+
expect(regexTargets.some((r) => r.test("https://sentry.io/api/0/"))).toBe(
982+
true
983+
);
984+
});
985+
986+
test("does not match non-sentry URLs", () => {
987+
const targets = getSentryTracePropagationTargets();
988+
const regexTargets = targets.filter(
989+
(t): t is RegExp => t instanceof RegExp
990+
);
991+
expect(regexTargets.some((r) => r.test("https://example.com/api/0/"))).toBe(
992+
false
993+
);
994+
expect(
995+
regexTargets.some((r) => r.test("https://not-sentry.io/api/0/"))
996+
).toBe(false);
997+
});
998+
999+
test("does not match domains beyond sentry.io TLD boundary", () => {
1000+
const targets = getSentryTracePropagationTargets();
1001+
const regexTargets = targets.filter(
1002+
(t): t is RegExp => t instanceof RegExp
1003+
);
1004+
// sentry.io.evil.com should NOT match
1005+
expect(
1006+
regexTargets.some((r) => r.test("https://sentry.io.evil.com/api/0/"))
1007+
).toBe(false);
1008+
// us.sentry.io.evil.com should NOT match
1009+
expect(
1010+
regexTargets.some((r) => r.test("https://us.sentry.io.evil.com/api/0/"))
1011+
).toBe(false);
1012+
});
1013+
1014+
test("includes self-hosted URL when configured", () => {
1015+
process.env[SENTRY_URL_ENV] = "https://sentry.mycompany.com";
1016+
const targets = getSentryTracePropagationTargets();
1017+
const stringTargets = targets.filter(
1018+
(t): t is string => typeof t === "string"
1019+
);
1020+
expect(stringTargets).toContain("https://sentry.mycompany.com");
1021+
});
1022+
1023+
test("does not include SaaS URL as string target", () => {
1024+
// Default (no SENTRY_URL) → only RegExp targets, no string targets
1025+
const targets = getSentryTracePropagationTargets();
1026+
const stringTargets = targets.filter(
1027+
(t): t is string => typeof t === "string"
1028+
);
1029+
expect(stringTargets).toHaveLength(0);
1030+
});
1031+
1032+
test("does not duplicate SaaS URL when SENTRY_URL is sentry.io", () => {
1033+
process.env[SENTRY_URL_ENV] = "https://sentry.io";
1034+
const targets = getSentryTracePropagationTargets();
1035+
// SaaS URLs are already covered by regex — no string target should be added
1036+
const stringTargets = targets.filter(
1037+
(t): t is string => typeof t === "string"
1038+
);
1039+
expect(stringTargets).toHaveLength(0);
1040+
});
1041+
});

0 commit comments

Comments
 (0)