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
110 changes: 50 additions & 60 deletions AGENTS.md

Large diffs are not rendered by default.

151 changes: 132 additions & 19 deletions src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import {
tryGetIssueByShortId,
} from "../../lib/api-client.js";
import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js";
import {
clearCachedIssueOrg,
getCachedIssueOrg,
setCachedIssueOrg,
} from "../../lib/db/issue-org-cache.js";
import { getProjectByAlias } from "../../lib/db/project-aliases.js";
import { detectAllDsns } from "../../lib/dsn/index.js";
import {
Expand Down Expand Up @@ -487,23 +492,40 @@ async function resolveShareIssue(
// Fetch full issue via authenticated API
if (org) {
const resolvedOrg = await resolveEffectiveOrg(org);
const issue = await getIssueInOrg(resolvedOrg, groupId, {
const orgScopedIssue = await getIssueInOrg(resolvedOrg, groupId, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { org: resolvedOrg, issue };
return { org: resolvedOrg, issue: orgScopedIssue };
}

// No org from URL — try env/DSN context, then fall back to unscoped fetch
// No org from URL — try env/DSN context, then the issue-id → org cache,
// then fall back to the unscoped fetch. See resolveNumericIssue for the
// full rationale behind the cache.
const resolvedOrg = await resolveOrg({ cwd });
const issue = resolvedOrg
? await getIssueInOrg(resolvedOrg.org, groupId, {
collapse: ISSUE_DETAIL_COLLAPSE,
})
: await getIssue(groupId, { collapse: ISSUE_DETAIL_COLLAPSE });
return {
org: resolvedOrg?.org ?? extractOrgFromPermalink(issue.permalink),
issue,
};
const cachedOrg = resolvedOrg ? null : getCachedIssueOrg(groupId);
const { issue, cacheEvicted } = await fetchIssueByNumericId(
groupId,
resolvedOrg?.org,
cachedOrg
);
// When `cacheEvicted` is true, the cached org was stale (404'd) — do NOT
// let it win the `??` chain; re-derive from the permalink instead.
const effectiveCachedOrg = cacheEvicted ? null : cachedOrg;
const resolvedOrgSlug =
resolvedOrg?.org ??
effectiveCachedOrg ??
extractOrgFromPermalink(issue.permalink);
if (resolvedOrgSlug && !resolvedOrg && !effectiveCachedOrg) {
// Best-effort — a broken/read-only DB must not fail a successful lookup.
try {
setCachedIssueOrg(groupId, resolvedOrgSlug);
} catch (cacheErr) {
log.debug(
`Failed to cache issue-org mapping for ${groupId}: ${String(cacheErr)}`
);
}
}
Comment thread
BYK marked this conversation as resolved.
return { org: resolvedOrgSlug, issue };
}

/**
Expand Down Expand Up @@ -538,14 +560,76 @@ function extractOrgFromPermalink(
return parseSentryUrl(permalink)?.org;
}

/**
* Result of {@link fetchIssueByNumericId}.
*
* `cacheEvicted` is true when the helper invalidated a stale `cachedOrg`
* entry after a 404 and fell through to the legacy unscoped endpoint.
* Callers MUST treat their local `cachedOrg` as stale when this flag is
* set and re-derive the org from `issue.permalink` instead — otherwise
* a stale slug leaks into downstream API calls (issue events, traces).
*/
type FetchIssueByNumericIdResult = {
issue: SentryIssue;
cacheEvicted: boolean;
};

/**
* Fetch an issue by numeric ID, preferring an org-scoped endpoint when
* the caller has explicit or cached org context. Falls back to the legacy
* unscoped `/api/0/issues/{id}/` endpoint when no org is known, and also
* when a cached org yields a 404 (stale mapping).
*
* Extracted from {@link resolveNumericIssue} to keep its cognitive
* complexity below the project's lint threshold.
*/
async function fetchIssueByNumericId(
id: string,
explicitOrg: string | undefined,
cachedOrg: string | null | undefined
): Promise<FetchIssueByNumericIdResult> {
if (explicitOrg) {
const issue = await getIssueInOrg(explicitOrg, id, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { issue, cacheEvicted: false };
}
if (cachedOrg) {
try {
const issue = await getIssueInOrg(cachedOrg, id, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { issue, cacheEvicted: false };
} catch (orgErr) {
if (orgErr instanceof ApiError && orgErr.status === 404) {
// Stale mapping (issue moved / deleted / access revoked). Evict the
// cache entry and fall through to the legacy unscoped endpoint.
clearCachedIssueOrg(id);
const issue = await getIssue(id, { collapse: ISSUE_DETAIL_COLLAPSE });
return { issue, cacheEvicted: true };
}
throw orgErr;
}
}
const issue = await getIssue(id, { collapse: ISSUE_DETAIL_COLLAPSE });
return { issue, cacheEvicted: false };
}

/**
* Resolve a bare numeric issue ID.
*
* Attempts org-scoped resolution with region routing when org context can be
* derived from the working directory (DSN / env vars / config defaults).
* derived from the working directory (DSN / env vars / config defaults), or
* from the issue-id → org cache populated on previous runs.
* Falls back to the legacy unscoped endpoint otherwise.
* Extracts the org slug from the response permalink so callers like
* {@link resolveOrgAndIssueId} can proceed without explicit org context.
*
* Caching: after a successful permalink-based org extraction, records the
* numeric-id → org mapping so future runs skip the unscoped fallback and
* route directly via the regional API. This addresses the
* `sentry.issue.view` "Consecutive HTTP" fan-out pattern for bare numeric
* IDs (Pattern D in the Sentry issue triage).
*/
async function resolveNumericIssue(
id: string,
Expand All @@ -554,23 +638,52 @@ async function resolveNumericIssue(
commandBase = "sentry issue"
): Promise<ResolvedIssueResult> {
const resolvedOrg = await resolveOrg({ cwd });
// Prefer explicit context over the cache — `resolveOrg()` already factors
// in env vars and config defaults that may point at a different org.
const cachedOrg = resolvedOrg ? null : getCachedIssueOrg(id);
try {
const issue = resolvedOrg
? await getIssueInOrg(resolvedOrg.org, id, {
collapse: ISSUE_DETAIL_COLLAPSE,
})
: await getIssue(id, { collapse: ISSUE_DETAIL_COLLAPSE });
const { issue, cacheEvicted } = await fetchIssueByNumericId(
id,
resolvedOrg?.org,
cachedOrg
);
// When `cacheEvicted` is true, the cached org slug was stale (404'd) and
// the helper fell through to the unscoped endpoint. Do NOT let the stale
// `cachedOrg` participate in the `??` chain — re-derive from permalink.
const effectiveCachedOrg = cacheEvicted ? null : cachedOrg;
// Extract org from the response permalink as a fallback so that callers
// like resolveOrgAndIssueId (used by explain/plan) get the org slug even
// when no org context was available before the fetch.
const org = resolvedOrg?.org ?? extractOrgFromPermalink(issue.permalink);
const org =
resolvedOrg?.org ??
effectiveCachedOrg ??
extractOrgFromPermalink(issue.permalink);
// Best-effort: remember the numeric-id → org mapping so the next run
// skips the unscoped fallback. Skipped when the org came from a still-
// valid cache hit (already stored). When the cache was evicted we SHOULD
// re-write the corrected mapping derived from the permalink.
if (org && !resolvedOrg && !effectiveCachedOrg) {
// Best-effort — a broken/read-only DB must not fail a successful lookup.
try {
setCachedIssueOrg(id, org);
} catch (cacheErr) {
log.debug(
`Failed to cache issue-org mapping for ${id}: ${String(cacheErr)}`
);
}
}
return { org, issue };
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
// Improve on the generic "Issue not found" message by including the ID
// and suggesting the short-ID format, since users often confuse numeric
// group IDs with short-ID suffixes. When org context is available, use
// the real org slug instead of <org> placeholder (CLI-BT, 18 users).
//
// Skip `cachedOrg` here: if the unscoped legacy endpoint 404'd too,
// the helper has already evicted the (proven-stale) cache entry, so
// suggesting the old slug in the hint would mislead the user. Only
// explicit `resolvedOrg` is worth preserving.
const orgHint = resolvedOrg?.org ?? "<org>";
const hint = `${commandBase} ${command} ${orgHint}/${id}`;
throw new ResolutionError(`Issue ${id}`, "not found", hint, [
Expand Down
6 changes: 5 additions & 1 deletion src/lib/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getEnv } from "../env.js";
import { clearResponseCache } from "../response-cache.js";
import { withDbSpan } from "../telemetry.js";
import { getDatabase } from "./index.js";
import { clearAllIssueOrgCache } from "./issue-org-cache.js";
import { runUpsert } from "./utils.js";

/** Refresh when less than 10% of token lifetime remains */
Expand Down Expand Up @@ -226,10 +227,13 @@ export async function clearAuth(): Promise<void> {
withDbSpan("clearAuth", () => {
const db = getDatabase();
db.query("DELETE FROM auth WHERE id = 1").run();
// Also clear user info, org region cache, and pagination cursors when logging out
// Also clear user info, org region cache, pagination cursors, and the
// issue-id → org cache (scoped to the current user's permissions) when
// logging out.
db.query("DELETE FROM user_info WHERE id = 1").run();
db.query("DELETE FROM org_regions").run();
db.query("DELETE FROM pagination_cursors").run();
clearAllIssueOrgCache();
});

// Clear cached API responses — they are tied to the current user's permissions.
Expand Down
109 changes: 109 additions & 0 deletions src/lib/db/issue-org-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Cache for numeric-issue-ID → organization-slug mappings.
*
* When a user runs `sentry issue view 123456789` without an org context,
* the CLI must fall back to the legacy unscoped `GET /api/0/issues/{id}/`
* endpoint (which does not support region routing) and then extract the
* org from the response `permalink`. Follow-up fetches (events/latest,
* trace/...) require the org slug, so without a cache every subsequent
* command run repeats the same unscoped lookup.
*
* This module records the resolved numeric-id → org-slug mapping so
* future runs can skip straight to the org-scoped endpoint. It addresses
* the `sentry.issue.view` "Consecutive HTTP" pattern for Pattern D in
* the issue triage (numeric-ID org discovery fan-out).
*
* Storage: dedicated `issue_org_cache` SQLite table (schema v15). Entries
* are best-effort — a stale mapping (issue deleted, access revoked, or
* moved) causes a single 404 on the cached org call which the caller
* falls back from and evicts the entry. Cleared on logout since
* mappings are scoped to the authenticated user's permissions.
*
* Values are not TTL'd because issues are owned by a single org for
* their entire lifetime — the mapping cannot change except by issue
* deletion, which we already handle via 404 eviction.
*/

import { recordCacheHit } from "../telemetry.js";
import { getDatabase } from "./index.js";
import { runUpsert } from "./utils.js";

type IssueOrgRow = {
issue_id: string;
org_slug: string;
cached_at: number;
};

/**
* Look up the cached organization slug for a numeric issue ID.
*
* @param numericId - Numeric issue group ID (e.g., "7413562541")
* @returns Org slug if cached, undefined otherwise
*/
export function getCachedIssueOrg(numericId: string): string | undefined {
if (!numericId) {
recordCacheHit("issue_org", false);
return;
}
const db = getDatabase();
const row = db
.query("SELECT org_slug FROM issue_org_cache WHERE issue_id = ?")
.get(numericId) as Pick<IssueOrgRow, "org_slug"> | undefined;

recordCacheHit("issue_org", !!row);
return row?.org_slug;
}

/**
* Remember the organization slug for a numeric issue ID.
*
* Silently no-ops when either argument is empty. Best-effort — callers
* should not await this as a critical step; the DB layer already wraps
* writes to be fault-tolerant.
*
* @param numericId - Numeric issue group ID (e.g., "7413562541")
* @param orgSlug - Organization slug that owns the issue
*/
export function setCachedIssueOrg(numericId: string, orgSlug: string): void {
if (!(numericId && orgSlug)) {
return;
}
const db = getDatabase();
runUpsert(
db,
"issue_org_cache",
{
issue_id: numericId,
org_slug: orgSlug,
cached_at: Date.now(),
},
["issue_id"]
);
}

/**
* Drop the cached mapping for a numeric issue ID.
*
* Called when an org-scoped fetch 404s so subsequent runs re-resolve
* the org via the legacy unscoped endpoint.
*
* @param numericId - Numeric issue group ID
*/
export function clearCachedIssueOrg(numericId: string): void {
if (!numericId) {
return;
}
const db = getDatabase();
db.query("DELETE FROM issue_org_cache WHERE issue_id = ?").run(numericId);
}

/**
* Drop ALL issue-id → org mappings.
*
* Called from auth logout handlers so signing out with one account does
* not leak mappings into a different account's session.
*/
export function clearAllIssueOrgCache(): void {
const db = getDatabase();
db.query("DELETE FROM issue_org_cache").run();
}
Comment thread
BYK marked this conversation as resolved.
40 changes: 40 additions & 0 deletions src/lib/db/project-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,46 @@ export function setCachedProjectByDsnKey(
setByKey(dsnCacheKey(publicKey), info);
}

/**
* Look up a cached project by organization and project slug.
*
* Returns the most recently cached entry matching the (org_slug, project_slug)
* pair across ALL cache key shapes (`orgId:projectId`, `dsn:publicKey`,
* `list:{org}/{project}`). This is useful for slug-based lookups where the
* caller doesn't know the numeric org/project IDs upfront — e.g., when
* resolving `<org>/<project>` from CLI arguments in `fetchProjectId`.
*
* Uses `MAX(cached_at)` with a covariant SELECT: SQLite guarantees the other
* columns come from the row that produced the MAX value. This avoids the
* ambiguity that would arise from a plain `LIMIT 1` without an ORDER BY.
*
* @param orgSlug - Organization slug (case-sensitive)
* @param projectSlug - Project slug (case-sensitive)
* @returns Cached project entry, or undefined if no match
*/
export function getCachedProjectBySlug(
orgSlug: string,
projectSlug: string
): CachedProject | undefined {
const db = getDatabase();
const row = db
.query(
"SELECT cache_key, org_slug, org_name, project_slug, project_name, project_id, MAX(cached_at) AS cached_at, last_accessed FROM project_cache WHERE org_slug = ? AND project_slug = ?"
)
.get(orgSlug, projectSlug) as ProjectCacheRow | undefined;

// When no rows match, SQLite still returns a single row with NULL columns
// because of the MAX aggregate — guard on cache_key being populated.
if (!row?.cache_key) {
recordCacheHit("project", false);
return;
}

recordCacheHit("project", true);
touchCacheEntry("project_cache", "cache_key", row.cache_key);
return rowToCachedProject(row);
}

/**
* Get cached project slugs for a specific organization.
*
Expand Down
Loading
Loading