From 88196a3dd4ea4707510c3d6120b4203a13b26e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 21 Sep 2026 17:36:31 +0200 Subject: [PATCH] fix(telemetry): tag sentry.org on api and event view sentry api never goes through target resolve, so org usage is invisible in Discover. Parse the slug from the path. event view skips resolve-target on explicit/URL paths; tag after resolve. Co-authored-by: Cursor --- packages/cli/src/commands/api.ts | 66 +++++++++++++++++++ packages/cli/src/commands/event/view.ts | 28 ++++++-- packages/cli/test/commands/api.test.ts | 54 ++++++++++++++- packages/cli/test/commands/event/view.test.ts | 7 ++ 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index 2f47439ea..e9ba50443 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -24,6 +24,7 @@ import { terminalPixelWidth, } from "../lib/sixel.js"; import { imageBytesToSixel } from "../lib/sixel-image.js"; +import { setOrgProjectContext } from "../lib/telemetry.js"; const log = logger.withTag("api"); @@ -227,6 +228,70 @@ function resolveApiTarget(endpoint: string): { }; } +/** + * Org/project slugs or numeric IDs in API paths. Rejects schema placeholders + * (`{organization_id_or_slug}`) and empty/dot segments so we never tag junk. + */ +const API_PATH_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function isApiPathSlug(segment: string | undefined): segment is string { + return segment !== undefined && API_PATH_SLUG_RE.test(segment); +} + +/** + * Parse org (and project when present) from a Sentry API endpoint path. + * + * `sentry api` never goes through target resolve, so the slug lives only in + * the URL. `organizations/`, `projects/`, and `teams/` prefixes carry the + * org; `projects/` and `organizations/{org}/projects/{project}/` also carry + * a project when that segment is a real slug. + * + * @param endpoint - Path relative to `/api/0/`, optionally with a query string + * or a leading `/api/0/` prefix + * @returns Org (and project when present), or `undefined` when the path is + * unscoped, a list endpoint, or a schema placeholder + * @internal Exported for testing + */ +export function parseOrgProjectFromApiPath( + endpoint: string +): { org: string; project?: string } | undefined { + const path = endpoint.split("?", 1)[0] ?? ""; + const segments = path.split("/").filter((segment) => segment.length > 0); + + if (segments[0] === "api" && segments[1] === "0") { + segments.splice(0, 2); + } + + if (segments[0] === "organizations" && isApiPathSlug(segments[1])) { + const org = segments[1]; + if (segments[2] === "projects" && isApiPathSlug(segments[3])) { + return { org, project: segments[3] }; + } + return { org }; + } + + if (segments[0] === "projects" && isApiPathSlug(segments[1])) { + const org = segments[1]; + if (isApiPathSlug(segments[2])) { + return { org, project: segments[2] }; + } + return { org }; + } + + if (segments[0] === "teams" && isApiPathSlug(segments[1])) { + return { org: segments[1] }; + } + + return; +} + +function tagOrgFromApiPath(endpoint: string): void { + const parsed = parseOrgProjectFromApiPath(endpoint); + if (parsed) { + setOrgProjectContext([parsed.org], parsed.project ? [parsed.project] : []); + } +} + /** * Parse a field value, attempting JSON parse first. * @@ -1560,6 +1625,7 @@ export const apiCommand = buildCommand({ const { normalizedEndpoint, requestBaseUrl, strippedApiPrefix } = resolveApiTarget(endpoint); + tagOrgFromApiPath(normalizedEndpoint); if (strippedApiPrefix) { // Silent auto-fix — not a warning. Users commonly copy/paste URLs // that include the /api/0/ prefix; we strip it transparently and diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index aa80c5c9a..5c451e101 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -70,6 +70,7 @@ import { } from "../../lib/sentry-url-parser.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; +import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; import { EventViewOutputSchema, type SentryEvent } from "../../types/index.js"; @@ -571,6 +572,8 @@ type ResolveTargetOptions = { * * Handles all target types (explicit, search, org-all, auto-detect) * including cross-project fallback via the eventids endpoint. + * Tags `sentry.org` / `sentry.project` on success because several of + * those paths skip `resolve-target`'s telemetry helper. * * @internal Exported for testing */ @@ -579,15 +582,17 @@ export async function resolveEventTarget( ): Promise { const { parsed, eventId, cwd } = options; + let target: ResolvedEventTarget | null; switch (parsed.type) { case ProjectSpecificationType.Explicit: { const org = await resolveEffectiveOrg(parsed.org); - return { + target = { org, project: parsed.project, orgDisplay: parsed.org, projectDisplay: parsed.project, }; + break; } case ProjectSpecificationType.ProjectSearch: { @@ -597,25 +602,36 @@ export async function resolveEventTarget( `sentry event view /${parsed.projectSlug} ${eventId}`, parsed.originalSlug ); - return { + target = { org: resolved.org, project: resolved.project, orgDisplay: resolved.org, projectDisplay: resolved.project, }; + break; } case ProjectSpecificationType.OrgAll: { const org = await resolveEffectiveOrg(parsed.org); - return resolveOrgAllTarget(org, eventId, cwd); + target = await resolveOrgAllTarget(org, eventId, cwd); + break; } case ProjectSpecificationType.AutoDetect: - return resolveAutoDetectTarget(eventId, cwd); + target = await resolveAutoDetectTarget(eventId, cwd); + break; + + default: { + const _exhaustiveCheck: never = parsed; + target = null; + break; + } + } - default: - return null; + if (target) { + setOrgProjectContext([target.org], [target.project]); } + return target; } /** diff --git a/packages/cli/test/commands/api.test.ts b/packages/cli/test/commands/api.test.ts index cba975154..bba18c3b6 100644 --- a/packages/cli/test/commands/api.test.ts +++ b/packages/cli/test/commands/api.test.ts @@ -7,7 +7,9 @@ import { writeFile } from "node:fs/promises"; import { Readable } from "node:stream"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { apiCommand, buildBodyFromFields, @@ -27,6 +29,7 @@ import { parseFieldKey, parseFields, parseHeaders, + parseOrgProjectFromApiPath, prepareRequestOptions, readStdin, resolveApiResponseOutput, @@ -108,6 +111,55 @@ describe("normalizeEndpoint: api/0/ prefix stripping (CLI-K1)", () => { }); }); +describe("parseOrgProjectFromApiPath", () => { + test.each([ + ["organizations/acme/issues/", { org: "acme" }], + ["projects/acme/web/events/", { org: "acme", project: "web" }], + ["teams/acme/engineering/", { org: "acme" }], + ["organizations/", undefined], + ["organizations/{organization_id_or_slug}/issues/", undefined], + ["issues/", undefined], + ] as const)("%s", (path, expected) => { + expect(parseOrgProjectFromApiPath(path)).toEqual(expected); + }); +}); + +describe("apiCommand tags org from the path", () => { + let setTagSpy: ReturnType; + + beforeEach(() => { + setTagSpy = vi.spyOn(Sentry, "setTag"); + }); + + afterEach(() => { + setTagSpy.mockRestore(); + }); + + test("dry-run of a projects path sets sentry.org and sentry.project", async () => { + const func = await apiCommand.loader(); + const context = { + stdin: createMockStdin(""), + stdout: { write: vi.fn(() => true), isTTY: false }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }; + await func.call( + context, + { + method: "GET", + silent: false, + verbose: false, + "dry-run": true, + json: false, + }, + "projects/acme/web/events/" + ); + + expect(setTagSpy).toHaveBeenCalledWith("sentry.org", "acme"); + expect(setTagSpy).toHaveBeenCalledWith("sentry.project", "web"); + }); +}); + describe("normalizeEndpoint: path traversal hardening (#350)", () => { test("rejects bare .. traversal", () => { expect(() => normalizeEndpoint("..")).toThrow(/path traversal/); diff --git a/packages/cli/test/commands/event/view.test.ts b/packages/cli/test/commands/event/view.test.ts index df7e9ef28..36cbbf222 100644 --- a/packages/cli/test/commands/event/view.test.ts +++ b/packages/cli/test/commands/event/view.test.ts @@ -5,6 +5,8 @@ * and viewCommand func() body in src/commands/event/view.ts */ +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/node-core/light"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { collectEventIds, @@ -729,12 +731,14 @@ describe("resolveEventTarget", () => { let findEventAcrossOrgsSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; let resolveProjectBySlugSpy: ReturnType; + let setTagSpy: ReturnType; beforeEach(async () => { resolveEventInOrgSpy = vi.spyOn(apiClient, "resolveEventInOrg"); findEventAcrossOrgsSpy = vi.spyOn(apiClient, "findEventAcrossOrgs"); resolveOrgAndProjectSpy = vi.spyOn(resolveTarget, "resolveOrgAndProject"); resolveProjectBySlugSpy = vi.spyOn(resolveTarget, "resolveProjectBySlug"); + setTagSpy = vi.spyOn(Sentry, "setTag"); setOrgRegion("acme", DEFAULT_SENTRY_URL); }); @@ -743,6 +747,7 @@ describe("resolveEventTarget", () => { findEventAcrossOrgsSpy.mockRestore(); resolveOrgAndProjectSpy.mockRestore(); resolveProjectBySlugSpy.mockRestore(); + setTagSpy.mockRestore(); }); test("returns explicit target directly", async () => { @@ -803,6 +808,8 @@ describe("resolveEventTarget", () => { expect(result?.org).toBe("acme"); expect(result?.project).toBe("backend"); expect(result?.prefetchedEvent).toBeDefined(); + expect(setTagSpy).toHaveBeenCalledWith("sentry.org", "acme"); + expect(setTagSpy).toHaveBeenCalledWith("sentry.project", "backend"); }); test("delegates AutoDetect to resolveAutoDetectTarget", async () => {