From 2657c09af59493309e9a75db23e8b934abe64080 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Feb 2026 21:47:40 +0000 Subject: [PATCH 1/5] feat(args): parse Sentry web URLs as CLI arguments Users can now paste Sentry web URLs directly as CLI arguments instead of manually extracting org/project/issue IDs. Supports both SaaS (sentry.io) and self-hosted instances. URL patterns supported: - /organizations/{org}/issues/{id}/ (issue view/explain/plan) - /organizations/{org}/issues/{id}/events/{eventId}/ (event view) - /settings/{org}/projects/{project}/ (project commands) - /organizations/{org}/traces/{traceId}/ (trace commands) - /organizations/{org}/ (org-scoped commands) For self-hosted URLs, automatically sets SENTRY_URL so that API calls, OAuth device flow, and token refresh target the correct instance. Also fixes frozen module-level constants in oauth.ts and sentry-client.ts that captured SENTRY_URL at import time, preventing dynamic URL detection from working. --- src/commands/event/view.ts | 30 ++- src/lib/arg-parsing.ts | 70 +++++ src/lib/oauth.ts | 20 +- src/lib/sentry-client.ts | 16 +- src/lib/sentry-url-parser.ts | 137 ++++++++++ test/commands/event/view.test.ts | 60 +++++ test/lib/arg-parsing.test.ts | 138 +++++++++- test/lib/sentry-url-parser.property.test.ts | 141 ++++++++++ test/lib/sentry-url-parser.test.ts | 274 ++++++++++++++++++++ 9 files changed, 871 insertions(+), 15 deletions(-) create mode 100644 src/lib/sentry-url-parser.ts create mode 100644 test/lib/sentry-url-parser.property.test.ts create mode 100644 test/lib/sentry-url-parser.test.ts diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index c257b5f6..6481e28a 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -19,6 +19,10 @@ import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../lib/sentry-url-parser.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -64,7 +68,16 @@ const USAGE_HINT = "sentry event view / "; /** * Parse positional arguments for event view. - * Handles: `` or ` ` + * + * Handles: + * - `` — event ID only (auto-detect org/project) + * - ` ` — explicit target + event ID + * - `` — extract eventId and org from a Sentry event URL + * (e.g., `https://sentry.example.com/organizations/my-org/issues/123/events/abc/`) + * + * For event URLs, the org is extracted and passed as `targetArg` so the + * downstream resolution logic can use it. The URL must contain an eventId + * segment — issue-only URLs are not valid for event view. * * @returns Parsed event ID and optional target arg */ @@ -81,6 +94,21 @@ export function parsePositionalArgs(args: string[]): { throw new ContextError("Event ID", USAGE_HINT); } + // URL detection — extract eventId and org from Sentry event URLs + const urlParsed = parseSentryUrl(first); + if (urlParsed) { + applySentryUrlContext(urlParsed.baseUrl); + if (urlParsed.eventId) { + // Event URL: use org as target, eventId from the URL + return { eventId: urlParsed.eventId, targetArg: `${urlParsed.org}/` }; + } + // URL recognized but no eventId — not valid for event view + throw new ContextError( + "Event ID in URL (use a URL like /issues/{id}/events/{eventId}/)", + USAGE_HINT + ); + } + if (args.length === 1) { // Single arg - must be event ID return { eventId: first, targetArg: undefined }; diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 1a6c073f..70fd28cc 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -6,6 +6,8 @@ * project list) and single-item commands (issue view, explain, plan). */ +import type { ParsedSentryUrl } from "./sentry-url-parser.js"; +import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; import { isAllDigits } from "./utils.js"; /** Default span depth when no value is provided */ @@ -96,11 +98,60 @@ export type ParsedOrgProject = | { type: typeof ProjectSpecificationType.ProjectSearch; projectSlug: string } | { type: typeof ProjectSpecificationType.AutoDetect }; +/** + * Map a parsed Sentry URL to a ParsedOrgProject. + * If the URL contains a project slug, returns explicit; otherwise org-all. + */ +function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject { + if (parsed.project) { + return { type: "explicit", org: parsed.org, project: parsed.project }; + } + return { type: "org-all", org: parsed.org }; +} + +/** + * Map a parsed Sentry URL to a ParsedIssueArg. + * Handles numeric group IDs and short IDs (e.g., "CLI-G") from the URL path. + */ +function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null { + const { issueId } = parsed; + if (!issueId) { + return null; + } + + // Numeric group ID (e.g., /issues/32886/) + if (isAllDigits(issueId)) { + return { + type: "explicit-org-numeric", + org: parsed.org, + numericId: issueId, + }; + } + + // Short ID with dash (e.g., /issues/CLI-G/ or /issues/SPOTLIGHT-ELECTRON-4Y/) + const dashIdx = issueId.lastIndexOf("-"); + if (dashIdx > 0) { + const project = issueId.slice(0, dashIdx); + const suffix = issueId.slice(dashIdx + 1).toUpperCase(); + if (project && suffix) { + return { type: "explicit", org: parsed.org, project, suffix }; + } + } + + // No dash — treat as suffix-only with org context + return { + type: "explicit-org-suffix", + org: parsed.org, + suffix: issueId.toUpperCase(), + }; +} + /** * Parse an org/project positional argument string. * * Supports the following patterns: * - `undefined` or empty → auto-detect from DSN/config + * - `https://sentry.io/organizations/org/...` → extract from Sentry URL * - `sentry/cli` → explicit org and project * - `sentry/` → org with all projects * - `/cli` → search for project across all orgs (leading slash) @@ -123,6 +174,13 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { const trimmed = arg.trim(); + // URL detection — extract org/project from Sentry web URLs + const urlParsed = parseSentryUrl(trimmed); + if (urlParsed) { + applySentryUrlContext(urlParsed.baseUrl); + return orgProjectFromUrl(urlParsed); + } + if (trimmed.includes("/")) { const slashIndex = trimmed.indexOf("/"); const org = trimmed.slice(0, slashIndex); @@ -287,6 +345,18 @@ function parseWithDash(arg: string): ParsedIssueArg { } export function parseIssueArg(arg: string): ParsedIssueArg { + // 0. URL detection — extract issue ID from Sentry web URLs + const urlParsed = parseSentryUrl(arg); + if (urlParsed) { + applySentryUrlContext(urlParsed.baseUrl); + const result = issueArgFromUrl(urlParsed); + if (result) { + return result; + } + // URL recognized but no issue ID — fall through to normal parsing + // (shouldn't happen for issue commands, but defensive) + } + // 1. Pure numeric → direct fetch by ID if (isAllDigits(arg)) { return { type: "numeric", id: arg }; diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 1ecf0087..988fae20 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -15,8 +15,16 @@ import { setAuthToken } from "./db/auth.js"; import { ApiError, AuthError, ConfigError, DeviceFlowError } from "./errors.js"; import { withHttpSpan } from "./telemetry.js"; -// Sentry instance URL (supports self-hosted via env override) -const SENTRY_URL = process.env.SENTRY_URL ?? "https://sentry.io"; +/** + * Get the Sentry instance URL for OAuth endpoints. + * + * Read lazily (not at module load) so that SENTRY_URL set after import + * (e.g., from URL argument parsing for self-hosted instances) is respected + * by the device flow and token refresh. + */ +function getSentryUrl(): string { + return process.env.SENTRY_URL ?? "https://sentry.io"; +} /** * OAuth client ID @@ -82,7 +90,7 @@ async function fetchWithConnectionError( if (isConnectionError) { throw new ApiError( - `Cannot connect to Sentry at ${SENTRY_URL}`, + `Cannot connect to Sentry at ${getSentryUrl()}`, 0, "Check your network connection and SENTRY_URL configuration" ); @@ -103,7 +111,7 @@ function requestDeviceCode() { return withHttpSpan("POST", "/oauth/device/code/", async () => { const response = await fetchWithConnectionError( - `${SENTRY_URL}/oauth/device/code/`, + `${getSentryUrl()}/oauth/device/code/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -146,7 +154,7 @@ function requestDeviceCode() { function pollForToken(deviceCode: string): Promise { return withHttpSpan("POST", "/oauth/token/", async () => { const response = await fetchWithConnectionError( - `${SENTRY_URL}/oauth/token/`, + `${getSentryUrl()}/oauth/token/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -332,7 +340,7 @@ export function refreshAccessToken( return withHttpSpan("POST", "/oauth/token/", async () => { const response = await fetchWithConnectionError( - `${SENTRY_URL}/oauth/token/`, + `${getSentryUrl()}/oauth/token/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index 00e46045..b2a3c885 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -12,11 +12,10 @@ import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js"; import { refreshToken } from "./db/auth.js"; import { withHttpSpan } from "./telemetry.js"; -/** - * Control silo URL - handles OAuth, user accounts, and region routing. - * This is always sentry.io for SaaS, or the base URL for self-hosted. - */ -const CONTROL_SILO_URL = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; +// CONTROL_SILO_URL was previously a module-level const that captured +// process.env.SENTRY_URL at import time. This broke self-hosted URL +// detection where SENTRY_URL is set after import. Now read lazily +// via getControlSiloUrl(). /** Request timeout in milliseconds */ const REQUEST_TIMEOUT_MS = 30_000; @@ -291,9 +290,12 @@ export function getApiBaseUrl(): string { /** * Get the control silo URL. * This is always sentry.io for SaaS, or the custom URL for self-hosted. + * + * Read lazily (not at module load) so that SENTRY_URL set after import + * (e.g., from URL argument parsing for self-hosted instances) is respected. */ export function getControlSiloUrl(): string { - return CONTROL_SILO_URL; + return process.env.SENTRY_URL || DEFAULT_SENTRY_URL; } /** @@ -339,7 +341,7 @@ export function getDefaultSdkConfig() { * Used for endpoints that are always on the control silo (OAuth, user accounts, regions). */ export function getControlSdkConfig() { - return getSdkConfig(CONTROL_SILO_URL); + return getSdkConfig(getControlSiloUrl()); } /** diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts new file mode 100644 index 00000000..b6a3dd47 --- /dev/null +++ b/src/lib/sentry-url-parser.ts @@ -0,0 +1,137 @@ +/** + * Sentry URL Parser + * + * Extracts org, project, issue, event, and trace identifiers from Sentry web URLs. + * Supports both SaaS (*.sentry.io) and self-hosted instances. + * + * For self-hosted URLs, also configures the SENTRY_URL environment variable + * so that subsequent API calls reach the correct instance. + */ + +import { isSentrySaasUrl } from "./sentry-urls.js"; + +/** + * Components extracted from a Sentry web URL. + * + * All fields except `baseUrl` and `org` are optional — presence depends + * on which URL pattern was matched. + */ +export type ParsedSentryUrl = { + /** Scheme + host of the Sentry instance (e.g., "https://sentry.io" or "https://sentry.example.com") */ + baseUrl: string; + /** Organization slug from the URL path */ + org: string; + /** Issue identifier — numeric group ID (e.g., "32886") or short ID (e.g., "CLI-G") */ + issueId?: string; + /** Event ID from /issues/{id}/events/{eventId}/ paths */ + eventId?: string; + /** Project slug from /settings/{org}/projects/{project}/ paths */ + project?: string; + /** Trace ID from /organizations/{org}/traces/{traceId}/ paths */ + traceId?: string; +}; + +/** + * Try to match /organizations/{org}/... path patterns. + * + * @returns Parsed result or null if pattern doesn't match + */ +function matchOrganizationsPath( + baseUrl: string, + segments: string[] +): ParsedSentryUrl | null { + if (segments[0] !== "organizations" || !segments[1]) { + return null; + } + + const org = segments[1]; + + // /organizations/{org}/issues/{id}/ (optionally with /events/{eventId}/) + if (segments[2] === "issues" && segments[3]) { + const eventId = + segments[4] === "events" && segments[5] ? segments[5] : undefined; + return { baseUrl, org, issueId: segments[3], eventId }; + } + + // /organizations/{org}/traces/{traceId}/ + if (segments[2] === "traces" && segments[3]) { + return { baseUrl, org, traceId: segments[3] }; + } + + // /organizations/{org}/ (org only) + return { baseUrl, org }; +} + +/** + * Try to match /settings/{org}/projects/{project}/ path pattern. + * + * @returns Parsed result or null if pattern doesn't match + */ +function matchSettingsPath( + baseUrl: string, + segments: string[] +): ParsedSentryUrl | null { + if ( + segments[0] !== "settings" || + !segments[1] || + segments[2] !== "projects" || + !segments[3] + ) { + return null; + } + + return { baseUrl, org: segments[1], project: segments[3] }; +} + +/** + * Parse a Sentry web URL and extract its components. + * + * Recognizes these path patterns (both SaaS and self-hosted): + * - `/organizations/{org}/issues/{id}/` + * - `/organizations/{org}/issues/{id}/events/{eventId}/` + * - `/settings/{org}/projects/{project}/` + * - `/organizations/{org}/traces/{traceId}/` + * - `/organizations/{org}/` + * + * @param input - Raw string that may or may not be a URL + * @returns Parsed components, or null if input is not a recognized Sentry URL + */ +export function parseSentryUrl(input: string): ParsedSentryUrl | null { + // Quick reject — must look like a URL + if (!(input.startsWith("http://") || input.startsWith("https://"))) { + return null; + } + + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + + const baseUrl = `${url.protocol}//${url.host}`; + const segments = url.pathname.split("/").filter(Boolean); + + return ( + matchOrganizationsPath(baseUrl, segments) ?? + matchSettingsPath(baseUrl, segments) + ); +} + +/** + * Configure `SENTRY_URL` for self-hosted instances detected from a parsed URL. + * + * Sets the env var when the URL is NOT a Sentry SaaS domain (*.sentry.io), + * since SaaS uses multi-region routing instead. + * + * The parsed URL always takes precedence over any existing `SENTRY_URL` value + * because an explicit URL argument is the strongest signal of user intent. + * + * @param baseUrl - The scheme + host extracted from the URL (e.g., "https://sentry.example.com") + */ +export function applySentryUrlContext(baseUrl: string): void { + if (isSentrySaasUrl(baseUrl)) { + return; + } + process.env.SENTRY_URL = baseUrl; +} diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index fb894d75..510d19d3 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -89,6 +89,66 @@ describe("parsePositionalArgs", () => { expect(result.eventId).toBe(""); }); }); + + // URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + + beforeEach(() => { + savedSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("event URL extracts eventId and org as target", () => { + const result = parsePositionalArgs([ + "https://sentry.io/organizations/my-org/issues/32886/events/abc123def456/", + ]); + expect(result.eventId).toBe("abc123def456"); + expect(result.targetArg).toBe("my-org/"); + }); + + test("self-hosted event URL extracts eventId and org", () => { + const result = parsePositionalArgs([ + "https://sentry.example.com/organizations/acme/issues/999/events/deadbeef/", + ]); + expect(result.eventId).toBe("deadbeef"); + expect(result.targetArg).toBe("acme/"); + }); + + test("issue URL without event ID throws ContextError", () => { + expect(() => + parsePositionalArgs([ + "https://sentry.io/organizations/my-org/issues/32886/", + ]) + ).toThrow(ContextError); + }); + + test("issue-only URL error mentions event ID", () => { + try { + parsePositionalArgs([ + "https://sentry.io/organizations/my-org/issues/32886/", + ]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Event ID"); + } + }); + + test("org-only URL throws ContextError", () => { + expect(() => + parsePositionalArgs(["https://sentry.io/organizations/my-org/"]) + ).toThrow(ContextError); + }); + }); }); describe("resolveProjectBySlug", () => { diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 1b8d66ab..50ce459e 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -6,7 +6,7 @@ * error messages and edge cases. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { parseIssueArg, parseOrgProjectArg, @@ -36,6 +36,67 @@ describe("parseOrgProjectArg", () => { 'Invalid format: "/" requires a project slug' ); }); + + // URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + + beforeEach(() => { + savedSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("issue URL returns org-all", () => { + expect( + parseOrgProjectArg( + "https://sentry.io/organizations/my-org/issues/12345/" + ) + ).toEqual({ + type: "org-all", + org: "my-org", + }); + }); + + test("project settings URL returns explicit", () => { + expect( + parseOrgProjectArg( + "https://sentry.io/settings/my-org/projects/backend/" + ) + ).toEqual({ + type: "explicit", + org: "my-org", + project: "backend", + }); + }); + + test("org-only URL returns org-all", () => { + expect( + parseOrgProjectArg("https://sentry.io/organizations/my-org/") + ).toEqual({ + type: "org-all", + org: "my-org", + }); + }); + + test("self-hosted URL extracts org", () => { + expect( + parseOrgProjectArg( + "https://sentry.example.com/organizations/acme-corp/issues/99/" + ) + ).toEqual({ + type: "org-all", + org: "acme-corp", + }); + }); + }); }); describe("parseIssueArg", () => { @@ -95,6 +156,81 @@ describe("parseIssueArg", () => { }); }); + // URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + + beforeEach(() => { + savedSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("issue URL with numeric ID returns explicit-org-numeric", () => { + expect( + parseIssueArg("https://sentry.io/organizations/my-org/issues/32886/") + ).toEqual({ + type: "explicit-org-numeric", + org: "my-org", + numericId: "32886", + }); + }); + + test("issue URL with short ID returns explicit", () => { + expect( + parseIssueArg("https://sentry.io/organizations/my-org/issues/CLI-G/") + ).toEqual({ + type: "explicit", + org: "my-org", + project: "CLI", + suffix: "G", + }); + }); + + test("issue URL with multi-part short ID returns explicit", () => { + expect( + parseIssueArg( + "https://sentry.io/organizations/my-org/issues/SPOTLIGHT-ELECTRON-4Y/" + ) + ).toEqual({ + type: "explicit", + org: "my-org", + project: "SPOTLIGHT-ELECTRON", + suffix: "4Y", + }); + }); + + test("self-hosted issue URL with query params", () => { + expect( + parseIssueArg( + "https://sentry.example.com/organizations/acme/issues/32886/?project=2" + ) + ).toEqual({ + type: "explicit-org-numeric", + org: "acme", + numericId: "32886", + }); + }); + + test("event URL extracts issue ID (ignores event part)", () => { + const result = parseIssueArg( + "https://sentry.io/organizations/my-org/issues/32886/events/abc123/" + ); + expect(result).toEqual({ + type: "explicit-org-numeric", + org: "my-org", + numericId: "32886", + }); + }); + }); + // Edge cases - document tricky behaviors describe("edge cases", () => { test("/suffix returns suffix-only", () => { diff --git a/test/lib/sentry-url-parser.property.test.ts b/test/lib/sentry-url-parser.property.test.ts new file mode 100644 index 00000000..1885e28d --- /dev/null +++ b/test/lib/sentry-url-parser.property.test.ts @@ -0,0 +1,141 @@ +/** + * Property-Based Tests for Sentry URL Parser + * + * Round-trip tests: verifies that URLs built by sentry-urls.ts builders + * can be correctly parsed back by parseSentryUrl(). + */ + +import { describe, expect, test } from "bun:test"; +import { + assert as fcAssert, + property, + stringMatching, + tuple, +} from "fast-check"; +import { parseSentryUrl } from "../../src/lib/sentry-url-parser.js"; +import { + buildOrgUrl, + buildProjectUrl, + buildTraceUrl, +} from "../../src/lib/sentry-urls.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** Generates valid org slugs (lowercase, alphanumeric with hyphens) */ +const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Generates valid project slugs (lowercase, alphanumeric with hyphens) */ +const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Generates valid 32-character hex trace IDs */ +const traceIdArb = stringMatching(/^[0-9a-f]{32}$/); + +/** Generates numeric issue IDs */ +const numericIdArb = stringMatching(/^[1-9][0-9]{0,10}$/); + +/** Generates hex-like event IDs (32 chars) */ +const eventIdArb = stringMatching(/^[0-9a-f]{32}$/); + +describe("parseSentryUrl round-trip properties", () => { + test("buildOrgUrl → parseSentryUrl extracts org", async () => { + await fcAssert( + property(orgSlugArb, (org) => { + const url = buildOrgUrl(org); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBeUndefined(); + expect(parsed?.project).toBeUndefined(); + expect(parsed?.traceId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buildProjectUrl → parseSentryUrl extracts org and project", async () => { + await fcAssert( + property(tuple(orgSlugArb, projectSlugArb), ([org, project]) => { + const url = buildProjectUrl(org, project); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.project).toBe(project); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buildTraceUrl → parseSentryUrl extracts org and traceId", async () => { + await fcAssert( + property(tuple(orgSlugArb, traceIdArb), ([org, traceId]) => { + const url = buildTraceUrl(org, traceId); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.traceId).toBe(traceId); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("issue URL round-trip: org and numeric issueId extracted", async () => { + await fcAssert( + property(tuple(orgSlugArb, numericIdArb), ([org, issueId]) => { + // Construct the URL pattern that Sentry uses for issues + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBe(issueId); + expect(parsed?.eventId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("event URL round-trip: org, issueId, and eventId extracted", async () => { + await fcAssert( + property( + tuple(orgSlugArb, numericIdArb, eventIdArb), + ([org, issueId, eventId]) => { + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/events/${eventId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBe(issueId); + expect(parsed?.eventId).toBe(eventId); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-URL strings always return null", async () => { + // Org slugs alone should never parse as URLs + await fcAssert( + property(orgSlugArb, (org) => { + expect(parseSentryUrl(org)).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("baseUrl always contains scheme and host", async () => { + await fcAssert( + property(tuple(orgSlugArb, numericIdArb), ([org, issueId]) => { + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.baseUrl).toMatch(/^https?:\/\/.+/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts new file mode 100644 index 00000000..bd9beedf --- /dev/null +++ b/test/lib/sentry-url-parser.test.ts @@ -0,0 +1,274 @@ +/** + * Sentry URL Parser Tests + * + * Unit tests for parseSentryUrl() and applySentryUrlContext(). + * Uses fictional domains (sentry.example.com, sentry.acme.internal) + * — never real customer data. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../src/lib/sentry-url-parser.js"; + +describe("parseSentryUrl", () => { + describe("non-URL inputs return null", () => { + test("plain text", () => { + expect(parseSentryUrl("sentry/cli-G")).toBeNull(); + }); + + test("numeric ID", () => { + expect(parseSentryUrl("12345")).toBeNull(); + }); + + test("short ID", () => { + expect(parseSentryUrl("CLI-4Y")).toBeNull(); + }); + + test("org/project format", () => { + expect(parseSentryUrl("my-org/my-project")).toBeNull(); + }); + + test("empty string", () => { + expect(parseSentryUrl("")).toBeNull(); + }); + + test("malformed URL", () => { + expect(parseSentryUrl("http://")).toBeNull(); + }); + }); + + describe("organization URLs", () => { + test("SaaS /organizations/{org}/", () => { + const result = parseSentryUrl("https://sentry.io/organizations/my-org/"); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + + test("self-hosted /organizations/{org}/", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + }); + }); + + test("strips trailing path segments after org", () => { + // /organizations/{org}/ with no further recognized segments → org only + const result = parseSentryUrl("https://sentry.io/organizations/my-org/"); + expect(result?.org).toBe("my-org"); + expect(result?.issueId).toBeUndefined(); + }); + }); + + describe("issue URLs", () => { + test("/organizations/{org}/issues/{numericId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/32886/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "32886", + }); + }); + + test("/organizations/{org}/issues/{shortId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/CLI-G/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "CLI-G", + }); + }); + + test("self-hosted issue URL with query params", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/issues/32886/?project=2" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + issueId: "32886", + }); + }); + + test("self-hosted issue URL with port", () => { + const result = parseSentryUrl( + "https://sentry.acme.internal:9000/organizations/devops/issues/100/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.acme.internal:9000", + org: "devops", + issueId: "100", + }); + }); + + test("HTTP (non-HTTPS) self-hosted URL", () => { + const result = parseSentryUrl( + "http://sentry.local:8080/organizations/dev/issues/42/" + ); + expect(result).toEqual({ + baseUrl: "http://sentry.local:8080", + org: "dev", + issueId: "42", + }); + }); + }); + + describe("event URLs", () => { + test("/organizations/{org}/issues/{id}/events/{eventId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/32886/events/abc123def456/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "32886", + eventId: "abc123def456", + }); + }); + + test("self-hosted event URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme/issues/999/events/deadbeef/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme", + issueId: "999", + eventId: "deadbeef", + }); + }); + + test("event URL without trailing slash", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/1/events/evt001" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "1", + eventId: "evt001", + }); + }); + }); + + describe("trace URLs", () => { + test("/organizations/{org}/traces/{traceId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/traces/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + + test("self-hosted trace URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/devops/traces/00112233445566778899aabbccddeeff/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "devops", + traceId: "00112233445566778899aabbccddeeff", + }); + }); + }); + + describe("project settings URLs", () => { + test("/settings/{org}/projects/{project}/", () => { + const result = parseSentryUrl( + "https://sentry.io/settings/my-org/projects/backend/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + project: "backend", + }); + }); + + test("self-hosted project settings URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/settings/acme/projects/web-frontend/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme", + project: "web-frontend", + }); + }); + }); + + describe("unrecognized paths return null", () => { + test("root URL", () => { + expect(parseSentryUrl("https://sentry.io/")).toBeNull(); + }); + + test("unknown path", () => { + expect(parseSentryUrl("https://sentry.io/auth/login/")).toBeNull(); + }); + + test("/settings without project segment", () => { + expect(parseSentryUrl("https://sentry.io/settings/my-org/")).toBeNull(); + }); + + test("/settings/{org}/projects/ without project slug", () => { + expect( + parseSentryUrl("https://sentry.io/settings/my-org/projects/") + ).toBeNull(); + }); + }); +}); + +describe("applySentryUrlContext", () => { + let originalSentryUrl: string | undefined; + + beforeEach(() => { + originalSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (originalSentryUrl !== undefined) { + process.env.SENTRY_URL = originalSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("sets SENTRY_URL for self-hosted instance", () => { + applySentryUrlContext("https://sentry.example.com"); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); + }); + + test("does not set SENTRY_URL for SaaS (sentry.io)", () => { + applySentryUrlContext("https://sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("does not set SENTRY_URL for SaaS subdomain (us.sentry.io)", () => { + applySentryUrlContext("https://us.sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("overrides existing SENTRY_URL (parsed URL takes precedence)", () => { + process.env.SENTRY_URL = "https://existing.example.com"; + applySentryUrlContext("https://sentry.other.com"); + expect(process.env.SENTRY_URL).toBe("https://sentry.other.com"); + }); + + test("sets SENTRY_URL for self-hosted with port", () => { + applySentryUrlContext("https://sentry.acme.internal:9000"); + expect(process.env.SENTRY_URL).toBe("https://sentry.acme.internal:9000"); + }); +}); From 71ddebebad89c721fc47f89752ae5744837855e3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Feb 2026 22:12:44 +0000 Subject: [PATCH 2/5] fix(args): address BugBot review issues in URL parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Event URLs no longer trigger OrgAll → ContextError; instead they auto-detect org/project after SENTRY_URL is set (HIGH) - SaaS URLs now clear stale SENTRY_URL so API calls use default routing instead of a leftover self-hosted value (MEDIUM) - Non-issue URLs (traces, project settings) in parseIssueArg now throw ValidationError instead of falling through to garbage slash-based parsing (LOW) --- src/commands/event/view.ts | 6 ++++-- src/lib/arg-parsing.ts | 8 ++++++-- src/lib/sentry-url-parser.ts | 3 +++ test/commands/event/view.test.ts | 9 ++++---- test/lib/arg-parsing.test.ts | 33 ++++++++++++++++++++++++++++++ test/lib/sentry-url-parser.test.ts | 12 +++++++++++ 6 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 6481e28a..ae06964c 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -99,8 +99,10 @@ export function parsePositionalArgs(args: string[]): { if (urlParsed) { applySentryUrlContext(urlParsed.baseUrl); if (urlParsed.eventId) { - // Event URL: use org as target, eventId from the URL - return { eventId: urlParsed.eventId, targetArg: `${urlParsed.org}/` }; + // Event URL: eventId from the URL, auto-detect org/project. + // SENTRY_URL is already set for self-hosted; org/project will be + // resolved via DSN detection or cached defaults. + return { eventId: urlParsed.eventId, targetArg: undefined }; } // URL recognized but no eventId — not valid for event view throw new ContextError( diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 70fd28cc..b2bbd3a3 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -6,6 +6,7 @@ * project list) and single-item commands (issue view, explain, plan). */ +import { ValidationError } from "./errors.js"; import type { ParsedSentryUrl } from "./sentry-url-parser.js"; import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; import { isAllDigits } from "./utils.js"; @@ -353,8 +354,11 @@ export function parseIssueArg(arg: string): ParsedIssueArg { if (result) { return result; } - // URL recognized but no issue ID — fall through to normal parsing - // (shouldn't happen for issue commands, but defensive) + // URL recognized but no issue ID (e.g., trace or project settings URL) + throw new ValidationError( + "This Sentry URL does not contain an issue ID. Use an issue URL like:\n" + + " https://sentry.io/organizations/{org}/issues/{id}/" + ); } // 1. Pure numeric → direct fetch by ID diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index b6a3dd47..a607f068 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -131,6 +131,9 @@ export function parseSentryUrl(input: string): ParsedSentryUrl | null { */ export function applySentryUrlContext(baseUrl: string): void { if (isSentrySaasUrl(baseUrl)) { + // Clear any self-hosted URL so API calls fall back to default SaaS routing. + // Without this, a stale SENTRY_URL would route SaaS requests to the wrong host. + process.env.SENTRY_URL = undefined; return; } process.env.SENTRY_URL = baseUrl; diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 510d19d3..b6319630 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -107,20 +107,21 @@ describe("parsePositionalArgs", () => { } }); - test("event URL extracts eventId and org as target", () => { + test("event URL extracts eventId, auto-detects org/project", () => { const result = parsePositionalArgs([ "https://sentry.io/organizations/my-org/issues/32886/events/abc123def456/", ]); expect(result.eventId).toBe("abc123def456"); - expect(result.targetArg).toBe("my-org/"); + expect(result.targetArg).toBeUndefined(); }); - test("self-hosted event URL extracts eventId and org", () => { + test("self-hosted event URL extracts eventId, sets SENTRY_URL", () => { const result = parsePositionalArgs([ "https://sentry.example.com/organizations/acme/issues/999/events/deadbeef/", ]); expect(result.eventId).toBe("deadbeef"); - expect(result.targetArg).toBe("acme/"); + expect(result.targetArg).toBeUndefined(); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); }); test("issue URL without event ID throws ContextError", () => { diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 50ce459e..95d05d03 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -11,6 +11,7 @@ import { parseIssueArg, parseOrgProjectArg, } from "../../src/lib/arg-parsing.js"; +import { ValidationError } from "../../src/lib/errors.js"; describe("parseOrgProjectArg", () => { // Representative examples for documentation (invariants covered by property tests) @@ -229,6 +230,38 @@ describe("parseIssueArg", () => { numericId: "32886", }); }); + + test("trace URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg( + "https://sentry.io/organizations/my-org/traces/a4d1aae7216b47ff/" + ) + ).toThrow(ValidationError); + }); + + test("org-only URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg("https://sentry.io/organizations/my-org/") + ).toThrow(ValidationError); + }); + + test("project settings URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg("https://sentry.io/settings/my-org/projects/backend/") + ).toThrow(ValidationError); + }); + + test("non-issue URL error mentions issue URL format", () => { + try { + parseIssueArg("https://sentry.io/organizations/my-org/traces/abc/"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain( + "does not contain an issue ID" + ); + } + }); }); // Edge cases - document tricky behaviors diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index bd9beedf..bf2a0267 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -271,4 +271,16 @@ describe("applySentryUrlContext", () => { applySentryUrlContext("https://sentry.acme.internal:9000"); expect(process.env.SENTRY_URL).toBe("https://sentry.acme.internal:9000"); }); + + test("clears existing SENTRY_URL when SaaS URL is detected", () => { + process.env.SENTRY_URL = "https://sentry.example.com"; + applySentryUrlContext("https://sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("clears existing SENTRY_URL when SaaS subdomain is detected", () => { + process.env.SENTRY_URL = "https://sentry.example.com"; + applySentryUrlContext("https://us.sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); }); From a6f9ca5e862206580764cd730a7663217116683b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Feb 2026 22:29:32 +0000 Subject: [PATCH 3/5] fix(url): use delete to clear SENTRY_URL for Node.js compatibility process.env.SENTRY_URL = undefined sets the string "undefined" in Node.js (Bun handles it correctly). Use delete with a biome-ignore to truly unset the env var when a SaaS URL is detected. --- src/lib/sentry-url-parser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index a607f068..9f20a417 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -133,7 +133,8 @@ export function applySentryUrlContext(baseUrl: string): void { if (isSentrySaasUrl(baseUrl)) { // Clear any self-hosted URL so API calls fall back to default SaaS routing. // Without this, a stale SENTRY_URL would route SaaS requests to the wrong host. - process.env.SENTRY_URL = undefined; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset; assignment coerces to string in Node.js + delete process.env.SENTRY_URL; return; } process.env.SENTRY_URL = baseUrl; From ad8d70644a5bc3a53fc5f8338663d19f679be8e2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Feb 2026 22:46:02 +0000 Subject: [PATCH 4/5] fix(event): preserve org from event URL via OrgAll fallback Instead of discarding the org extracted from event URLs, pass it as OrgAll target and handle OrgAll in viewCommand by falling through to auto-detect. This keeps the org context available while resolving the project via DSN detection or config defaults. --- src/commands/event/view.ts | 22 +++++++++++++--------- test/commands/event/view.test.ts | 8 ++++---- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index ae06964c..faa72a43 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -75,9 +75,10 @@ const USAGE_HINT = "sentry event view / "; * - `` — extract eventId and org from a Sentry event URL * (e.g., `https://sentry.example.com/organizations/my-org/issues/123/events/abc/`) * - * For event URLs, the org is extracted and passed as `targetArg` so the - * downstream resolution logic can use it. The URL must contain an eventId - * segment — issue-only URLs are not valid for event view. + * For event URLs, the org is returned as `targetArg` in `"{org}/"` format + * (OrgAll). Since event URLs don't contain a project slug, the caller + * must fall back to auto-detection for the project. The URL must contain + * an eventId segment — issue-only URLs are not valid for event view. * * @returns Parsed event ID and optional target arg */ @@ -99,10 +100,10 @@ export function parsePositionalArgs(args: string[]): { if (urlParsed) { applySentryUrlContext(urlParsed.baseUrl); if (urlParsed.eventId) { - // Event URL: eventId from the URL, auto-detect org/project. - // SENTRY_URL is already set for self-hosted; org/project will be - // resolved via DSN detection or cached defaults. - return { eventId: urlParsed.eventId, targetArg: undefined }; + // Event URL: pass org as OrgAll target ("{org}/"). + // Event URLs don't contain a project slug, so viewCommand falls + // back to auto-detect for the project while keeping the org context. + return { eventId: urlParsed.eventId, targetArg: `${urlParsed.org}/` }; } // URL recognized but no eventId — not valid for event view throw new ContextError( @@ -211,8 +212,11 @@ export const viewCommand = buildCommand({ } case ProjectSpecificationType.OrgAll: - throw new ContextError("Specific project", USAGE_HINT); - + // Org-only (e.g., from event URL that has no project slug). + // Fall through to auto-detect — SENTRY_URL is already set for + // self-hosted, and auto-detect will resolve the project from + // DSN, config defaults, or directory name inference. + // falls through case ProjectSpecificationType.AutoDetect: target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); break; diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index b6319630..19eb9562 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -107,20 +107,20 @@ describe("parsePositionalArgs", () => { } }); - test("event URL extracts eventId, auto-detects org/project", () => { + test("event URL extracts eventId and passes org as OrgAll target", () => { const result = parsePositionalArgs([ "https://sentry.io/organizations/my-org/issues/32886/events/abc123def456/", ]); expect(result.eventId).toBe("abc123def456"); - expect(result.targetArg).toBeUndefined(); + expect(result.targetArg).toBe("my-org/"); }); - test("self-hosted event URL extracts eventId, sets SENTRY_URL", () => { + test("self-hosted event URL extracts eventId, passes org, sets SENTRY_URL", () => { const result = parsePositionalArgs([ "https://sentry.example.com/organizations/acme/issues/999/events/deadbeef/", ]); expect(result.eventId).toBe("deadbeef"); - expect(result.targetArg).toBeUndefined(); + expect(result.targetArg).toBe("acme/"); expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); }); From 63d05309a97cbf1c036b1583f5cb4c536ae21449 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Feb 2026 23:08:07 +0000 Subject: [PATCH 5/5] remove redundant comment --- src/lib/sentry-client.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index b2a3c885..34b47dbb 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -12,11 +12,6 @@ import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js"; import { refreshToken } from "./db/auth.js"; import { withHttpSpan } from "./telemetry.js"; -// CONTROL_SILO_URL was previously a module-level const that captured -// process.env.SENTRY_URL at import time. This broke self-hosted URL -// detection where SENTRY_URL is set after import. Now read lazily -// via getControlSiloUrl(). - /** Request timeout in milliseconds */ const REQUEST_TIMEOUT_MS = 30_000;