From 70ec42c6b596fa4c980eef56bc6f99cc8936752f Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 29 Jul 2026 19:01:02 -0400 Subject: [PATCH 1/4] fix(local): Point `trace view --web` at the trace detail route `buildTraceUrl` emitted `/traces/{traceId}/`, which matches no route. The trace detail view is mounted at `trace/:traceSlug/` under both `traces/` and `explore/traces/`, so the `trace/` segment is what distinguishes a detail URL from the traces list. Without it Sentry falls back to the list and drops the trace ID, so `sentry trace view --web ` never opened the trace. Switch to the canonical `/explore/traces/trace/{traceId}/`, matching the `explore/` namespace `buildLogsUrl` and `buildReplayUrl` already use. `parseSentryUrl` gains `matchTracePath`, mirroring `matchReplayPath`: it accepts the canonical path and still parses legacy `traces/{id}/` links so previously-copied URLs keep resolving. Affects `trace view --web`, `trace logs --web`, and the `Link` row in `formatLogDetails`. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/sentry-url-parser.ts | 44 +++++++++++++++++++++++---- src/lib/sentry-urls.ts | 4 +-- test/lib/formatters/log.test.ts | 2 +- test/lib/sentry-url-parser.test.ts | 22 ++++++++++++++ test/lib/sentry-urls.property.test.ts | 10 +++--- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index e6dc259c8c..fb662e3b89 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -66,9 +66,9 @@ function matchOrganizationsPath( return { baseUrl, org, issueId: segments[3], eventId }; } - // /organizations/{org}/traces/{traceId}/ - if (segments[2] === "traces" && segments[3]) { - return { baseUrl, org, traceId: segments[3] }; + const tracePath = matchTracePath(segments, 2); + if (tracePath.status === "detail") { + return { baseUrl, org, traceId: tracePath.traceId }; } const replayPath = matchReplayPath(segments, 2); @@ -125,9 +125,9 @@ function matchSubdomainPath( segments[2] === "events" && segments[3] ? segments[3] : undefined; return { issueId: segments[1], eventId }; } - // /traces/{traceId}/ - if (segments[0] === "traces" && segments[1]) { - return { traceId: segments[1] }; + const tracePath = matchTracePath(segments, 0); + if (tracePath.status === "detail") { + return { traceId: tracePath.traceId }; } const replayPath = matchReplayPath(segments, 0); @@ -165,6 +165,38 @@ function matchSubdomainTailPath( return null; } +/** + * Match a trace path, canonical or legacy. + * + * The trace detail view is mounted at `trace/:traceSlug/` under both `traces/` + * and `explore/traces/`, so the `trace/` segment is what distinguishes a detail + * URL from the traces list. Legacy `traces/{id}/` (no `trace/` segment) is still + * accepted so previously-copied links keep resolving. + */ +function matchTracePath( + segments: string[], + startIndex: number +): { status: "absent" | "list" } | { status: "detail"; traceId: string } { + let index = startIndex; + if (segments[index] === "explore") { + index += 1; + } + if (segments[index] !== "traces") { + return { status: "absent" }; + } + index += 1; + if (segments[index] === "trace") { + index += 1; + } + + const traceId = segments[index]; + if (!traceId) { + return { status: "list" }; + } + + return { status: "detail", traceId }; +} + function matchReplayPath( segments: string[], startIndex: number diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 219e1613fb..08346941e2 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -321,9 +321,9 @@ export function buildDashboardUrl( */ export function buildTraceUrl(orgSlug: string, traceId: string): string { if (isSaaS()) { - return `${getOrgBaseUrl(orgSlug)}/traces/${traceId}/`; + return `${getOrgBaseUrl(orgSlug)}/explore/traces/trace/${traceId}/`; } - return `${getSentryBaseUrl()}/organizations/${orgSlug}/traces/${traceId}/`; + return `${getSentryBaseUrl()}/organizations/${orgSlug}/explore/traces/trace/${traceId}/`; } // Alert URLs diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 18031a8919..96ede2b476 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -388,7 +388,7 @@ describe("formatLogDetails", () => { expect(result).toContain("Span ID"); expect(result).toContain("span-abc-123"); expect(result).toContain("Link"); - expect(result).toContain("my-org.sentry.io/traces/trace123abc456def789"); + expect(result).toContain("my-org.sentry.io/explore/traces/trace/"); }); test("shows Source Location when code.function present", () => { diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index c2d8b1139e..db8d99ccfa 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -162,6 +162,28 @@ describe("parseSentryUrl", () => { }); describe("trace URLs", () => { + test("/organizations/{org}/explore/traces/trace/{traceId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/traces/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + + test("org-domain /explore/traces/trace/{traceId}/", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/traces/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + test("/organizations/{org}/traces/{traceId}/", () => { const result = parseSentryUrl( "https://sentry.io/organizations/my-org/traces/a4d1aae7216b47ff8117cf4e09ce9d0a/" diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index 264dc4b894..4a4bb4963a 100644 --- a/test/lib/sentry-urls.property.test.ts +++ b/test/lib/sentry-urls.property.test.ts @@ -428,11 +428,11 @@ describe("buildLogsUrl properties", () => { }); describe("buildTraceUrl properties", () => { - test("output contains /traces/ path with trace ID", async () => { + test("output contains /explore/traces/trace/ path with trace ID", async () => { await fcAssert( property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { const result = buildTraceUrl(orgSlug, traceId); - expect(result).toContain(`/traces/${traceId}/`); + expect(result).toContain(`/explore/traces/trace/${traceId}/`); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -462,7 +462,9 @@ describe("buildTraceUrl properties", () => { await fcAssert( property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { const result = buildTraceUrl(orgSlug, traceId); - expect(result).toBe(`${getOrgBaseUrl(orgSlug)}/traces/${traceId}/`); + expect(result).toBe( + `${getOrgBaseUrl(orgSlug)}/explore/traces/trace/${traceId}/` + ); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -584,7 +586,7 @@ describe("self-hosted URLs", () => { test("buildTraceUrl uses path-based pattern", () => { expect(buildTraceUrl("my-org", "abc123def456abc123def456abc123de")).toBe( - `${SELF_HOSTED_URL}/organizations/my-org/traces/abc123def456abc123def456abc123de/` + `${SELF_HOSTED_URL}/organizations/my-org/explore/traces/trace/abc123def456abc123def456abc123de/` ); }); From e79954735a7a9afcd346ac9fdc06859be55bd56f Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 30 Jul 2026 10:01:26 +0000 Subject: [PATCH 2/4] docs(local): list canonical explore/traces/trace path in parser JSDoc --- src/lib/sentry-url-parser.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index fb662e3b89..d75ae7d8c6 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -288,7 +288,8 @@ function matchSharePath( * - `/organizations/{org}/issues/{id}/` * - `/organizations/{org}/issues/{id}/events/{eventId}/` * - `/settings/{org}/projects/{project}/` - * - `/organizations/{org}/traces/{traceId}/` + * - `/organizations/{org}/explore/traces/trace/{traceId}/` (canonical) + * - `/organizations/{org}/traces/{traceId}/` (legacy) * - `/organizations/{org}/explore/replays/{replayId}/` * - `/organizations/{org}/replays/{replayId}/` * - `/organizations/{org}/dashboard/{id}/` @@ -297,7 +298,8 @@ function matchSharePath( * * Also recognizes SaaS subdomain-style URLs: * - `https://{org}.sentry.io/issues/{id}/` - * - `https://{org}.sentry.io/traces/{traceId}/` + * - `https://{org}.sentry.io/explore/traces/trace/{traceId}/` (canonical) + * - `https://{org}.sentry.io/traces/{traceId}/` (legacy) * - `https://{org}.sentry.io/explore/replays/{replayId}/` * - `https://{org}.sentry.io/replays/{replayId}/` * - `https://{org}.sentry.io/issues/{id}/events/{eventId}/` From 5d1a5d3a24d1c64f8ee7b01ce72539ae0cc939d4 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 30 Jul 2026 10:05:51 +0000 Subject: [PATCH 3/4] fix(local): require trace/ segment under explore/ prefix in URL parser Bugbot: matchTracePath treated any segment after explore/traces/ as a trace ID even without the trace/ segment, so explore/traces/{slug}/ yielded a bogus traceId. Only the legacy non-explore traces/{id}/ form should match without trace/; require it under explore/. --- src/lib/sentry-url-parser.ts | 25 +++++++++++++++++++------ test/lib/sentry-url-parser.test.ts | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index d75ae7d8c6..c4f605aec8 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -168,25 +168,38 @@ function matchSubdomainTailPath( /** * Match a trace path, canonical or legacy. * - * The trace detail view is mounted at `trace/:traceSlug/` under both `traces/` - * and `explore/traces/`, so the `trace/` segment is what distinguishes a detail - * URL from the traces list. Legacy `traces/{id}/` (no `trace/` segment) is still - * accepted so previously-copied links keep resolving. + * The trace detail view is mounted at `trace/:traceSlug/`. Canonical URLs are + * `explore/traces/trace/{id}/`; the `trace/` segment is what distinguishes a + * detail URL from the traces list. Legacy `traces/{id}/` (no `explore/` prefix, + * no `trace/` segment) is still accepted so previously-copied links keep + * resolving. + * + * The `trace/` segment is only optional in the legacy non-`explore/` form. + * Under the `explore/` prefix the `trace/` segment is required — otherwise a + * URL like `explore/traces/{something}/` (e.g. a future sub-route) would be + * misread as a trace detail with a bogus ID. */ function matchTracePath( segments: string[], startIndex: number ): { status: "absent" | "list" } | { status: "detail"; traceId: string } { let index = startIndex; - if (segments[index] === "explore") { + const hasExplorePrefix = segments[index] === "explore"; + if (hasExplorePrefix) { index += 1; } if (segments[index] !== "traces") { return { status: "absent" }; } index += 1; - if (segments[index] === "trace") { + + const hasTraceSegment = segments[index] === "trace"; + if (hasTraceSegment) { index += 1; + } else if (hasExplorePrefix) { + // `explore/traces/` without the `trace/` segment is the list route (or an + // unrelated sub-route), never a detail — don't treat the next segment as an ID. + return { status: "list" }; } const traceId = segments[index]; diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index db8d99ccfa..23d2c5ffa2 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -205,6 +205,26 @@ describe("parseSentryUrl", () => { traceId: "00112233445566778899aabbccddeeff", }); }); + + test("explore/traces without the trace segment is not a detail", () => { + // `explore/traces/{slug}/` (no `trace/` segment) is the list route or an + // unrelated sub-route — the slug must not be captured as a trace ID. The + // subdomain matcher has no fallback for this shape, so it does not match. + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/traces/some-subroute/" + ); + expect(result).toBeNull(); + }); + + test("organizations explore/traces without trace segment is org-only", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/traces/some-subroute/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); }); describe("replay URLs", () => { From d6d609a6e245e688d828f909f6407d2386967ff7 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 30 Jul 2026 10:09:57 +0000 Subject: [PATCH 4/4] fix(local): resolve subdomain trace-list URLs to the org Seer: matchSubdomainPath handled matchReplayPath's list status (org-only) but not the equivalent trace list status, so a bare subdomain trace list URL (explore/traces/) returned null. Mirror the replay handling. --- src/lib/sentry-url-parser.ts | 5 +++++ test/lib/sentry-url-parser.test.ts | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index c4f605aec8..b9072ef304 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -129,6 +129,11 @@ function matchSubdomainPath( if (tracePath.status === "detail") { return { traceId: tracePath.traceId }; } + if (tracePath.status === "list") { + // A bare trace list URL (e.g. `/explore/traces/`) resolves to the org, + // matching the replay-list behavior below. + return {}; + } const replayPath = matchReplayPath(segments, 0); if (replayPath.status === "detail") { diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index 23d2c5ffa2..922551a2a0 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -208,12 +208,23 @@ describe("parseSentryUrl", () => { test("explore/traces without the trace segment is not a detail", () => { // `explore/traces/{slug}/` (no `trace/` segment) is the list route or an - // unrelated sub-route — the slug must not be captured as a trace ID. The - // subdomain matcher has no fallback for this shape, so it does not match. + // unrelated sub-route — the slug must not be captured as a trace ID. It + // resolves to the org (like the replay list), not a trace detail. const result = parseSentryUrl( "https://my-org.sentry.io/explore/traces/some-subroute/" ); - expect(result).toBeNull(); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); + + test("subdomain trace list resolves to the org", () => { + const result = parseSentryUrl("https://my-org.sentry.io/explore/traces/"); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); }); test("organizations explore/traces without trace segment is org-only", () => {