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
60 changes: 44 additions & 16 deletions packages/cli/src/lib/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import {
setCachedProjectByDsnKey,
} from "../db/project-cache.js";
import { getCachedOrganizations } from "../db/regions.js";
import { reportCliError } from "../error-reporting.js";
import { type AuthGuardSuccess, withAuthGuard } from "../errors.js";
import { logger } from "../logger.js";
import { getApiBaseUrl } from "../sentry-client.js";
import { buildProjectUrl } from "../sentry-urls.js";
import { isAllDigits } from "../utils.js";
Expand All @@ -46,6 +48,20 @@ import {
} from "./infrastructure.js";
import { getUserRegions, listOrganizations } from "./organizations.js";

const log = logger.withTag("api.projects");

/**
* Surface a swallowed best-effort failure without failing the caller.
*
* `log.debug` is for `--verbose` local diagnosis. {@link reportCliError}
* is what makes unexpected failures visible as Sentry issues for users
* who never pass `--verbose`.
*/
function reportBestEffortFailure(message: string, error: unknown): void {
log.debug(message, error);
reportCliError(error);
}

/**
* List all projects in an organization.
* Automatically paginates through all API pages to return the complete list.
Expand Down Expand Up @@ -74,13 +90,15 @@ export async function listProjects(orgSlug: string): Promise<SentryProject[]> {

// Populate project cache for shell completions (best-effort).
// Mirrors how listOrganizations() calls setOrgRegions().
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
const orgs = getCachedOrganizations();
const orgName = orgs.find((o) => o.slug === orgSlug)?.name ?? orgSlug;
cacheProjectsForOrg(orgSlug, orgName, allResults);
} catch {
// Cache population is best-effort — never fail the command
} catch (error) {
reportBestEffortFailure(
`Failed to cache projects for org '${orgSlug}'`,
error
);
}

return allResults;
Expand Down Expand Up @@ -192,7 +210,7 @@ export type CreatedProjectDetails = {
/**
* Seed both project caches after a successful creation.
*
* Best-effort: cache failures are silently swallowed so they never break
* Best-effort: cache failures are reported to Sentry but never break
* project creation. Called by both `createProjectWithDsn` (team-scoped)
* and `createProjectWithAutoTeam` (org-scoped) to keep cache behaviour
* consistent across both creation paths.
Expand All @@ -202,17 +220,18 @@ function seedProjectCaches(
project: SentryProject,
dsn: string | null
): void {
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
const orgName = resolveOrgDisplayName(orgSlug, project.organization?.name);
cacheProjectsForOrg(orgSlug, orgName, [
{ id: project.id, slug: project.slug, name: project.name },
]);
} catch {
// Best-effort — don't let cache failures break project creation
} catch (error) {
reportBestEffortFailure(
`Failed to seed project cache for '${project.slug}'`,
error
);
}
if (dsn) {
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
const publicKey = extractPublicKeyFromDsn(dsn);
if (publicKey) {
Expand All @@ -224,8 +243,11 @@ function seedProjectCaches(
projectId: project.id,
});
}
} catch {
// Best-effort — don't let cache failures break project creation
} catch (error) {
reportBestEffortFailure(
`Failed to seed DSN key cache for '${project.slug}'`,
error
);
}
}
}
Expand Down Expand Up @@ -532,7 +554,6 @@ export async function findProjectByDsnKey(
const results = await Promise.all(
regions.map((region) =>
limit(async () => {
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
// Same `?query=dsn:` escape hatch as above (see the region-fallback
// branch) — internal search param, no typed SDK operation yet.
Expand All @@ -542,7 +563,11 @@ export async function findProjectByDsnKey(
{ params: { query: `dsn:${publicKey}` } }
);
return data;
} catch {
} catch (error) {
reportBestEffortFailure(
`DSN key lookup failed in region '${region.url}'`,
error
);
return [];
}
})
Expand Down Expand Up @@ -649,8 +674,8 @@ export async function getProjectKeys(
* Fetch the primary DSN for a project.
* Returns the public DSN of the first active key, or null on any error.
*
* Best-effort: failures are silently swallowed so callers can treat
* DSN display as optional (e.g., after project creation or in views).
* Best-effort: failures are reported to Sentry but callers can treat DSN
* display as optional (e.g., after project creation or in views).
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug
Expand All @@ -660,12 +685,15 @@ export async function tryGetPrimaryDsn(
orgSlug: string,
projectSlug: string
): Promise<string | null> {
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
const keys = await getProjectKeys(orgSlug, projectSlug);
const activeKey = keys.find((k) => k.isActive);
return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null;
} catch {
} catch (error) {
reportBestEffortFailure(
`Failed to fetch DSN for '${orgSlug}/${projectSlug}'`,
error
);
return null;
}
}
8 changes: 8 additions & 0 deletions packages/cli/src/lib/db/dsn-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ export function getCachedDsn(directory: string): CachedDsnEntry | undefined {
return;
}

// Rows written by setCachedDetection() with an empty allDsns array store
// dsn="" and project_id="". Treat these as cache misses for the single-DSN
// path — callers expect a usable DSN when the result is defined.
if (!row.dsn) {
recordCacheHit("dsn", false);
return;
}

recordCacheHit("dsn", true);
touchCacheEntry("dsn_cache", "directory", directory);
return rowToCachedDsnEntry(row);
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,15 @@ function setCliErrorContext(scope: Sentry.Scope, error: unknown): void {
// ---------------------------------------------------------------------------

/**
* Report a command-level error to Sentry.
* Report an error to Sentry without rethrowing.
*
* - Silenced errors emit a metric and return without calling `captureException`.
* Call this at the command boundary for thrown failures, and in best-effort
* catch blocks that swallow the error so the command can continue. A debug
* log is not a substitute: users do not run `--verbose`, so swallowed
* unexpected failures must become Sentry issues.
*
* - Silenced errors (network, expected auth, 4xx) emit a metric and return
* without calling `captureException`.
* - Captured errors get grouping tags + structured context on a fresh scope.
*/
export function reportCliError(error: unknown): void {
Expand Down
20 changes: 14 additions & 6 deletions packages/cli/src/lib/region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,24 +211,32 @@ export async function resolveEffectiveOrg(orgSlug: string): Promise<string> {
// Normal slug: try a single resolveOrgRegion() call (1 API request)
// instead of the heavy listOrganizationsUncached() fan-out (1+N requests).
// If it succeeds, the slug is valid and the region is now cached.
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
await resolveOrgRegion(orgSlug);
return orgSlug;
} catch {
// Org not found or auth error — fall through to return the original
// slug. The downstream API call will produce a relevant error.
} catch (error) {
// AuthError (and similar) — the downstream API call produces the
// user-facing error and reportCliError already runs at the command
// boundary. Log here so --verbose shows why we used the raw slug.
logger.debug(
`resolveOrgRegion failed for '${orgSlug}', using raw slug`,
error
);
return orgSlug;
}
}

// DSN numeric ID: refresh the full org list to populate ID → slug mapping.
// listOrganizationsUncached() populates org_regions with slug, region, org_id, and name.
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
const { listOrganizationsUncached } = await import("./api-client.js");
await listOrganizationsUncached();
} catch {
} catch (error) {
// Same as above: the command fails downstream and is reported there.
logger.debug(
`Failed to refresh org list for numeric ID '${orgSlug}'`,
error
);
return orgSlug;
}

Expand Down
49 changes: 46 additions & 3 deletions packages/cli/test/lib/api-client.coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
* pattern as api-client.seer.test.ts.
*/

// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as Sentry from "@sentry/node-core/light";
import { number, object, string } from "valibot";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { resolveEventInOrg } from "../../src/lib/api/events.js";
import { unwrapResult } from "../../src/lib/api/infrastructure.js";
import {
Expand Down Expand Up @@ -964,8 +966,49 @@ describe("projects.ts", () => {
})
);

const dsn = await tryGetPrimaryDsn("test-org", "test-project");
expect(dsn).toBeNull();
const captureSpy = vi.spyOn(Sentry, "captureException");
try {
const dsn = await tryGetPrimaryDsn("test-org", "test-project");
expect(dsn).toBeNull();
// 404 is expected user/API noise — silenced, not an issue.
expect(captureSpy).not.toHaveBeenCalled();
} finally {
captureSpy.mockRestore();
}
});

test("reports unexpected DSN fetch failures to Sentry", async () => {
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify({ detail: "Internal error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
})
);

const captureSpy = vi.spyOn(Sentry, "captureException");
const withScopeSpy = vi.spyOn(Sentry, "withScope");
withScopeSpy.mockImplementation((fn: (scope: unknown) => void) => {
fn({
setTag() {
/* noop */
},
setContext() {
/* noop */
},
setFingerprint() {
/* noop */
},
});
});
try {
const dsn = await tryGetPrimaryDsn("test-org", "test-project");
expect(dsn).toBeNull();
expect(captureSpy).toHaveBeenCalledTimes(1);
} finally {
captureSpy.mockRestore();
withScopeSpy.mockRestore();
}
});
});
});
Expand Down
34 changes: 30 additions & 4 deletions packages/cli/test/lib/api-client.multiregion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
* Covers region discovery, fan-out, and region-aware routing.
*/

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 {
findProjectByDsnKey,
getUserRegions,
Expand Down Expand Up @@ -647,10 +649,34 @@ describe("findProjectByDsnKey (multi-region)", () => {
),
});

const project = await findProjectByDsnKey("abc123");
const captureSpy = vi.spyOn(Sentry, "captureException");
const withScopeSpy = vi.spyOn(Sentry, "withScope");
withScopeSpy.mockImplementation((fn: (scope: unknown) => void) => {
fn({
setTag() {
/* noop */
},
setContext() {
/* noop */
},
setFingerprint() {
/* noop */
},
});
});
try {
const project = await findProjectByDsnKey("abc123");

// Should find project despite US region failing
expect(project?.slug).toBe("found-project");
// Should find project despite US region failing
expect(project?.slug).toBe("found-project");
expect(captureSpy).toHaveBeenCalledTimes(1);
expect(captureSpy.mock.calls[0]?.[0]).toMatchObject({
message: "Network error",
});
} finally {
captureSpy.mockRestore();
withScopeSpy.mockRestore();
}
});
});

Expand Down
Loading
Loading