Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions src/lib/sentry-url-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Comment thread
sentry[bot] marked this conversation as resolved.
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);
Expand Down Expand Up @@ -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 };
Comment thread
jared-outpost[bot] marked this conversation as resolved.
}

function matchReplayPath(
segments: string[],
startIndex: number
Expand Down Expand Up @@ -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}/`
Expand All @@ -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}/`
Expand Down
4 changes: 2 additions & 2 deletions src/lib/sentry-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/lib/formatters/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
53 changes: 53 additions & 0 deletions test/lib/sentry-url-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand All @@ -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", () => {
Expand Down
10 changes: 6 additions & 4 deletions test/lib/sentry-urls.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Expand Down Expand Up @@ -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 }
);
Expand Down Expand Up @@ -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/`
);
});

Expand Down
Loading