diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index e6dc259c8..b9072ef30 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,14 @@ 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 }; + } + 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); @@ -165,6 +170,51 @@ function matchSubdomainTailPath( return null; } +/** + * Match a trace path, canonical or legacy. + * + * 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; + const hasExplorePrefix = segments[index] === "explore"; + if (hasExplorePrefix) { + index += 1; + } + if (segments[index] !== "traces") { + return { status: "absent" }; + } + index += 1; + + 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]; + if (!traceId) { + return { status: "list" }; + } + + return { status: "detail", traceId }; +} + function matchReplayPath( segments: string[], startIndex: number @@ -256,7 +306,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}/` @@ -265,7 +316,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}/` diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 219e1613f..08346941e 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 18031a891..96ede2b47 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 c2d8b1139..922551a2a 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/" @@ -183,6 +205,37 @@ 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. 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).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", () => { + 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", () => { diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index 264dc4b89..4a4bb4963 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/` ); });