From 94ad7cb6d7f26e94a7a8c7d4aabe2aa6b5d7d81e Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:07:35 +0000 Subject: [PATCH] fix(cli): parse trace IDs from Sentry URLs and with trailing slashes --- packages/cli/src/lib/trace-target.ts | 73 +++++++++++- packages/cli/test/lib/trace-target.test.ts | 123 +++++++++++++++++++++ 2 files changed, 191 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index 097fa4417..849fedf7c 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -32,6 +32,7 @@ import { resolveOrgOptionalTarget, resolveProjectBoundTarget, } from "./resolve-target.js"; +import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; import { setOrgProjectContext } from "./telemetry.js"; import { isTraceId, validateTraceId } from "./trace-id.js"; @@ -136,6 +137,14 @@ export function parseTraceTarget( throw new ContextError("Trace ID", usageHint, []); } + // Sentry trace URLs (often copy-pasted with a trailing slash) carry the + // trace ID and org in the path; slash-splitting them yields an empty or + // bogus trace ID segment (CLI-13F). + const urlTarget = traceTargetFromUrl(first); + if (urlTarget) { + return urlTarget; + } + // Warn about extra positional args that will be ignored if (args.length > 2) { log.warn( @@ -157,6 +166,51 @@ export function parseTraceTarget( return targetArgToTraceTarget(first, traceId); } +/** + * Build a trace target from a Sentry trace detail URL. + * + * Returns `null` when the input is not a Sentry URL or the URL has no trace + * ID. Side effect: configures `SENTRY_URL` for self-hosted hosts via + * {@link applySentryUrlContext} (throws `HostScopeError` for untrusted hosts). + * + * @internal Exported for testing + */ +export function traceTargetFromUrl(input: string): ParsedTraceTarget | null { + const urlParsed = parseSentryUrl(input.trim()); + if (!urlParsed?.traceId) { + return null; + } + applySentryUrlContext(urlParsed.baseUrl); + const traceId = validateTraceId(urlParsed.traceId); + if (urlParsed.org) { + return { type: "org-scoped", traceId, org: urlParsed.org }; + } + return { type: "auto-detect", traceId }; +} + +/** + * Remove trailing `/` characters when the segment before them is a trace ID, + * so `org/project//` splits the same as `org/project/`. + * + * Inputs like `org/project/` are left untouched: the trailing slash there + * means the trace ID was omitted, which must stay a "Trace ID is required" + * error rather than being misread as `org/`. + * + * @internal Exported for testing + */ +export function stripTrailingSlashesAfterTraceId(input: string): string { + let end = input.length; + while (end > 0 && input[end - 1] === "/") { + end -= 1; + } + if (end === input.length) { + return input; + } + const stripped = input.slice(0, end); + const tail = stripped.slice(stripped.lastIndexOf("/") + 1); + return isTraceId(tail) ? stripped : input; +} + /** * Parse a single slash-separated argument into a trace target. * @@ -167,9 +221,10 @@ export function parseTraceTarget( * @internal Exported for testing */ export function parseSlashSeparatedTraceTarget( - input: string, + rawInput: string, usageHint: string, ): ParsedTraceTarget { + const input = stripTrailingSlashesAfterTraceId(rawInput); const lastSlash = input.lastIndexOf("/"); if (lastSlash === -1) { @@ -298,10 +353,11 @@ export function extractRawTraceId( return null; } if (args.length === 1) { - const first = args[0]; - if (!first) { + const rawFirst = args[0]; + if (!rawFirst) { return null; } + const first = stripTrailingSlashesAfterTraceId(rawFirst); const lastSlash = first.lastIndexOf("/"); if (lastSlash === -1) { return { rawTraceId: first }; @@ -650,9 +706,16 @@ export function parseDualModeArgs( } } + // Trace detail URL → trace mode (the URL itself carries the trace ID) + const urlTarget = traceTargetFromUrl(first); + if (urlTarget) { + return { mode: "trace", parsed: urlTarget }; + } + // Single arg: check the tail segment (last part after "/", or entire arg) - const lastSlash = first.lastIndexOf("/"); - const tail = lastSlash === -1 ? first : first.slice(lastSlash + 1); + const stripped = stripTrailingSlashesAfterTraceId(first); + const lastSlash = stripped.lastIndexOf("/"); + const tail = lastSlash === -1 ? stripped : stripped.slice(lastSlash + 1); if (isTraceId(tail)) { return { mode: "trace", diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index df6b05a17..8e105a803 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -26,12 +26,16 @@ import { ValidationError, } from "../../src/lib/errors.js"; import { + extractRawTraceId, + parseDualModeArgs, parseSlashSeparatedTraceTarget, parseTraceTarget, resolveTraceOrg, resolveTraceOrgOptionalProject, resolveTraceOrgProject, + stripTrailingSlashesAfterTraceId, targetArgToTraceTarget, + traceTargetFromUrl, } from "../../src/lib/trace-target.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -264,3 +268,122 @@ describe("parseTraceTarget", () => { ); }); }); + +describe("trailing slashes and Sentry trace URLs (CLI-13F)", () => { + const HINT = "sentry trace view [//]"; + let savedUrl: string | undefined; + let savedHost: string | undefined; + + beforeEach(() => { + savedUrl = process.env.SENTRY_URL; + savedHost = process.env.SENTRY_HOST; + }); + + afterEach(() => { + if (savedUrl === undefined) { + delete process.env.SENTRY_URL; + } else { + process.env.SENTRY_URL = savedUrl; + } + if (savedHost === undefined) { + delete process.env.SENTRY_HOST; + } else { + process.env.SENTRY_HOST = savedHost; + } + }); + + test("canonical trace URL with trailing slash → org-scoped", () => { + const result = parseTraceTarget( + [`https://my-org.sentry.io/explore/traces/trace/${VALID_TRACE_ID}/`], + HINT, + ); + expect(result).toEqual({ + type: "org-scoped", + traceId: VALID_TRACE_ID, + org: "my-org", + }); + }); + + test("trace URL without trailing slash and with query → org-scoped", () => { + const result = parseTraceTarget( + [ + `https://my-org.sentry.io/explore/traces/trace/${VALID_TRACE_ID}?statsPeriod=24h`, + ], + HINT, + ); + expect(result).toEqual({ + type: "org-scoped", + traceId: VALID_TRACE_ID, + org: "my-org", + }); + }); + + test("legacy /organizations/ trace URL → org-scoped", () => { + const result = parseTraceTarget( + [`https://sentry.io/organizations/my-org/traces/${VALID_TRACE_ID}/`], + HINT, + ); + expect(result.type).toBe("org-scoped"); + expect(result.traceId).toBe(VALID_TRACE_ID); + }); + + test("traceTargetFromUrl returns null for non-URL input", () => { + expect(traceTargetFromUrl(VALID_TRACE_ID)).toBeNull(); + expect(traceTargetFromUrl(`my-org/${VALID_TRACE_ID}`)).toBeNull(); + }); + + test("org/project// → explicit", () => { + const result = parseSlashSeparatedTraceTarget( + `my-org/my-project/${VALID_TRACE_ID}/`, + HINT, + ); + expect(result).toEqual({ + type: "explicit", + traceId: VALID_TRACE_ID, + org: "my-org", + project: "my-project", + }); + }); + + test("/ → auto-detect", () => { + const result = parseSlashSeparatedTraceTarget(`${VALID_TRACE_ID}/`, HINT); + expect(result).toEqual({ type: "auto-detect", traceId: VALID_TRACE_ID }); + }); + + test("org/project/ still throws ContextError (trace ID omitted)", () => { + expect(() => + parseSlashSeparatedTraceTarget("my-org/my-project/", HINT), + ).toThrow(ContextError); + }); + + test("stripTrailingSlashesAfterTraceId leaves non-trace tails alone", () => { + expect(stripTrailingSlashesAfterTraceId("my-org/my-project/")).toBe( + "my-org/my-project/", + ); + expect(stripTrailingSlashesAfterTraceId(`org/${VALID_TRACE_ID}//`)).toBe( + `org/${VALID_TRACE_ID}`, + ); + }); + + test("extractRawTraceId ignores a trailing slash after the trace ID", () => { + expect(extractRawTraceId([`my-org/my-project/${VALID_TRACE_ID}/`])).toEqual( + { rawTraceId: VALID_TRACE_ID, targetArg: "my-org/my-project" }, + ); + }); + + test("parseDualModeArgs treats a trace URL as trace mode", () => { + const result = parseDualModeArgs( + [`https://my-org.sentry.io/explore/traces/trace/${VALID_TRACE_ID}/`], + HINT, + ); + expect(result).toEqual({ + mode: "trace", + parsed: { type: "org-scoped", traceId: VALID_TRACE_ID, org: "my-org" }, + }); + }); + + test("parseDualModeArgs treats org// as trace mode", () => { + const result = parseDualModeArgs([`my-org/${VALID_TRACE_ID}/`], HINT); + expect(result.mode).toBe("trace"); + }); +});