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
73 changes: 68 additions & 5 deletions packages/cli/src/lib/trace-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand All @@ -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/<trace-id>/` splits the same as `org/project/<trace-id>`.
*
* 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/<trace-id=project>`.
*
* @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.
*
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions packages/cli/test/lib/trace-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -264,3 +268,122 @@ describe("parseTraceTarget", () => {
);
});
});

describe("trailing slashes and Sentry trace URLs (CLI-13F)", () => {
const HINT = "sentry trace view [<org>/<project>/]<trace-id>";
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/<trace-id>/ → 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("<trace-id>/ → 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/<trace-id>/ as trace mode", () => {
const result = parseDualModeArgs([`my-org/${VALID_TRACE_ID}/`], HINT);
expect(result.mode).toBe("trace");
});
});
Loading