From b9f7bc71db394914225c0af42640b62fca32796b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 02:22:30 +0000 Subject: [PATCH 01/12] feat: add response caching for read-only API calls Implement RFC 7234/9111-aware HTTP response caching for GET requests to the Sentry API. Caching is transparent and integrated at the fetch level inside createAuthenticatedFetch(). Cache design: - Filesystem-based at ~/.sentry/cache/responses/ with JSON files keyed by SHA-256 hash of normalized URL + Authorization header - Uses http-cache-semantics for standards-compliant freshness evaluation - Tiered TTL system: immutable (1hr), stable (5min), volatile (60sec), no-cache (0sec) based on URL pattern matching - Probabilistic cleanup (10% chance per write) with 500-file LRU cap - Only successful (2xx) responses are cached Integration: - --refresh / -r flag added to all read commands (issue list/view/explain/ plan, project list/view, org list/view, event view, trace list/view/logs, log list/view, auth whoami/status, team list, repo list) - SENTRY_NO_CACHE=1 env var for global cache bypass - Cache cleared on login and logout for security - Autofix/root-cause polling endpoints excluded from caching Testing: - 18 unit tests for cache operations, TTL tiers, cleanup, and edge cases - 15 property-based tests for URL classification, key normalization, TTL bounds, and round-trip invariants - 4 additional integration-style tests Closes #318 --- bun.lock | 6 + package.json | 2 + src/commands/auth/login.ts | 11 + src/commands/auth/status.ts | 7 + src/commands/auth/whoami.ts | 7 + src/commands/event/view.ts | 7 + src/commands/issue/explain.ts | 7 + src/commands/issue/list.ts | 7 + src/commands/issue/plan.ts | 7 + src/commands/issue/view.ts | 7 + src/commands/log/list.ts | 9 +- src/commands/log/view.ts | 7 + src/commands/org/list.ts | 12 +- src/commands/org/view.ts | 7 + src/commands/project/list.ts | 7 + src/commands/project/view.ts | 8 +- src/commands/trace/list.ts | 7 + src/commands/trace/logs.ts | 7 + src/commands/trace/view.ts | 7 + src/lib/db/auth.ts | 6 + src/lib/list-command.ts | 16 + src/lib/response-cache.ts | 572 +++++++++++++++++++++++ src/lib/sentry-client.ts | 72 ++- test/lib/response-cache.property.test.ts | 246 ++++++++++ test/lib/response-cache.test.ts | 338 ++++++++++++++ 25 files changed, 1377 insertions(+), 12 deletions(-) create mode 100644 src/lib/response-cache.ts create mode 100644 test/lib/response-cache.property.test.ts create mode 100644 test/lib/response-cache.test.ts diff --git a/bun.lock b/bun.lock index 18eaaff05..470f832a7 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@stricli/auto-complete": "^1.2.4", "@stricli/core": "^1.2.4", "@types/bun": "latest", + "@types/http-cache-semantics": "^4.2.0", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", @@ -22,6 +23,7 @@ "consola": "^3.4.2", "esbuild": "^0.25.0", "fast-check": "^4.5.3", + "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", "marked": "^15", "p-limit": "^7.2.0", @@ -276,6 +278,8 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], @@ -386,6 +390,8 @@ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/package.json b/package.json index e24f76087..ee00c1a32 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@stricli/auto-complete": "^1.2.4", "@stricli/core": "^1.2.4", "@types/bun": "latest", + "@types/http-cache-semantics": "^4.2.0", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", @@ -23,6 +24,7 @@ "consola": "^3.4.2", "esbuild": "^0.25.0", "fast-check": "^4.5.3", + "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", "marked": "^15", "p-limit": "^7.2.0", diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 67fabd693..404f10d2d 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -14,6 +14,7 @@ import { AuthError } from "../../lib/errors.js"; import { muted, success } from "../../lib/formatters/colors.js"; import { formatUserIdentity } from "../../lib/formatters/human.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; +import { clearResponseCache } from "../../lib/response-cache.js"; type LoginFlags = { readonly token?: string; @@ -66,6 +67,11 @@ export const loginCommand = buildCommand({ // Token-based authentication if (flags.token) { + // Clear stale cached responses from a previous session + await clearResponseCache().catch(() => { + // Non-fatal: cache directory may not exist + }); + // Save token first, then validate by fetching user regions await setAuthToken(flags.token); @@ -104,6 +110,11 @@ export const loginCommand = buildCommand({ return; } + // Clear stale cached responses from a previous session + await clearResponseCache().catch(() => { + // Non-fatal: cache directory may not exist + }); + // Device Flow OAuth const loginSuccess = await runInteractiveLogin( stdout, diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 6e0381919..01fa37117 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -27,10 +27,13 @@ import { formatUserIdentity, maskToken, } from "../../lib/formatters/human.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import type { Writer } from "../../types/index.js"; type StatusFlags = { readonly "show-token": boolean; + readonly refresh: boolean; }; /** @@ -148,9 +151,13 @@ export const statusCommand = buildCommand({ brief: "Show the stored token (masked by default)", default: false, }, + refresh: REFRESH_FLAG, }, }, async func(this: SentryContext, flags: StatusFlags): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, stderr } = this; const auth = await getAuthConfig(); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index 678d89647..d03bb017d 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -13,9 +13,12 @@ import { isAuthenticated } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; import { AuthError } from "../../lib/errors.js"; import { formatUserIdentity, writeJson } from "../../lib/formatters/index.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; type WhoamiFlags = { readonly json: boolean; + readonly refresh: boolean; }; export const whoamiCommand = buildCommand({ @@ -33,9 +36,13 @@ export const whoamiCommand = buildCommand({ brief: "Output as JSON", default: false, }, + refresh: REFRESH_FLAG, }, }, async func(this: SentryContext, flags: WhoamiFlags): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout } = this; if (!(await isAuthenticated())) { diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index bcb8d5fb4..af95d55fb 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,12 +23,14 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { applySentryUrlContext, parseSentryUrl, @@ -41,6 +43,7 @@ type ViewFlags = { readonly json: boolean; readonly web: boolean; readonly spans: number; + readonly refresh: boolean; }; type HumanOutputOptions = { @@ -318,6 +321,7 @@ export const viewCommand = buildCommand({ default: false, }, ...spansFlag, + refresh: REFRESH_FLAG, }, aliases: { w: "web" }, }, @@ -326,6 +330,9 @@ export const viewCommand = buildCommand({ flags: ViewFlags, ...args: string[] ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd } = this; const log = logger.withTag("event.view"); diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index ac091b539..303e3478e 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -12,6 +12,8 @@ import { formatRootCauseList, handleSeerApiError, } from "../../lib/formatters/seer.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { extractRootCauses } from "../../types/seer.js"; import { ensureRootCauseAnalysis, @@ -22,6 +24,7 @@ import { type ExplainFlags = { readonly json: boolean; readonly force: boolean; + readonly refresh: boolean; }; export const explainCommand = buildCommand({ @@ -61,6 +64,7 @@ export const explainCommand = buildCommand({ brief: "Force new analysis even if one exists", default: false, }, + refresh: REFRESH_FLAG, }, }, async func( @@ -68,6 +72,9 @@ export const explainCommand = buildCommand({ flags: ExplainFlags, issueArg: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, stderr, cwd } = this; // Declare org outside try block so it's accessible in catch for error messages diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 7f6904160..cf8ca0dd0 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -49,6 +49,7 @@ import { LIST_JSON_FLAG, LIST_TARGET_POSITIONAL, parseCursorFlag, + REFRESH_FLAG, targetPatternExplanation, } from "../../lib/list-command.js"; import { @@ -63,6 +64,7 @@ import { resolveAllTargets, toNumericId, } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { ProjectAliasEntry, @@ -80,6 +82,7 @@ type ListFlags = { readonly period: string; readonly json: boolean; readonly cursor?: string; + readonly refresh: boolean; }; /** @internal */ export type SortValue = "date" | "new" | "freq" | "user"; @@ -1193,6 +1196,7 @@ export const listCommand = buildListCommand("issue", { 'Pagination cursor for / or multi-target modes (use "last" to continue)', optional: true, }, + refresh: REFRESH_FLAG, }, aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort", t: "period" }, }, @@ -1201,6 +1205,9 @@ export const listCommand = buildListCommand("issue", { flags: ListFlags, target?: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, stderr, cwd, setContext } = this; const parsed = parseOrgProjectArg(target); diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 20d55e0cd..a46d17dea 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -15,6 +15,8 @@ import { formatSolution, handleSeerApiError, } from "../../lib/formatters/seer.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import type { Writer } from "../../types/index.js"; import { type AutofixState, @@ -34,6 +36,7 @@ type PlanFlags = { readonly cause?: number; readonly json: boolean; readonly force: boolean; + readonly refresh: boolean; }; /** @@ -174,6 +177,7 @@ export const planCommand = buildCommand({ brief: "Force new plan even if one exists", default: false, }, + refresh: REFRESH_FLAG, }, }, async func( @@ -181,6 +185,9 @@ export const planCommand = buildCommand({ flags: PlanFlags, issueArg: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, stderr, cwd } = this; // Declare org outside try block so it's accessible in catch for error messages diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 744ec0c22..7a10e3a75 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -16,6 +16,8 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, SentryIssue, Writer } from "../../types/index.js"; import { issueIdPositional, resolveIssue } from "./utils.js"; @@ -24,6 +26,7 @@ type ViewFlags = { readonly json: boolean; readonly web: boolean; readonly spans: number; + readonly refresh: boolean; }; /** @@ -100,6 +103,7 @@ export const viewCommand = buildCommand({ default: false, }, ...spansFlag, + refresh: REFRESH_FLAG, }, aliases: { w: "web" }, }, @@ -108,6 +112,9 @@ export const viewCommand = buildCommand({ flags: ViewFlags, issueArg: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd, setContext } = this; // Resolve issue using shared resolution logic diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index d060d5b72..1791cf24b 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -27,12 +27,14 @@ import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { buildListCommand, + REFRESH_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { resolveOrg, resolveOrgProjectFromArg, } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { validateTraceId } from "../../lib/trace-id.js"; import { getUpdateNotification } from "../../lib/version-check.js"; import type { Writer } from "../../types/index.js"; @@ -43,7 +45,8 @@ type ListFlags = { readonly follow?: number; readonly json: boolean; readonly trace?: string; -}; + readonly refresh: boolean; +} /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -439,6 +442,7 @@ export const listCommand = buildListCommand("log", { brief: "Output as JSON", default: false, }, + refresh: REFRESH_FLAG, }, aliases: { n: "limit", @@ -451,6 +455,9 @@ export const listCommand = buildListCommand("log", { flags: ListFlags, target?: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, stderr, cwd, setContext } = this; if (flags.trace) { diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index a1aafe1c5..5227e09f5 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -18,11 +18,13 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; import { validateHexId } from "../../lib/hex-id.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { buildLogsUrl } from "../../lib/sentry-urls.js"; import type { DetailedSentryLog } from "../../types/index.js"; @@ -31,6 +33,7 @@ const log = logger.withTag("log-view"); type ViewFlags = { readonly json: boolean; readonly web: boolean; + readonly refresh: boolean; }; /** Usage hint for ContextError messages */ @@ -333,6 +336,7 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + refresh: REFRESH_FLAG, }, aliases: { w: "web" }, }, @@ -341,6 +345,9 @@ export const viewCommand = buildCommand({ flags: ViewFlags, ...args: string[] ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd, setContext } = this; const cmdLog = logger.withTag("log.view"); diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index cada4194e..276dbde6b 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -12,11 +12,17 @@ import { getAllOrgRegions } from "../../lib/db/regions.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; -import { buildListLimitFlag, LIST_JSON_FLAG } from "../../lib/list-command.js"; +import { + buildListLimitFlag, + LIST_JSON_FLAG, + REFRESH_FLAG, +} from "../../lib/list-command.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; type ListFlags = { readonly limit: number; readonly json: boolean; + readonly refresh: boolean; }; /** @@ -69,11 +75,15 @@ export const listCommand = buildCommand({ flags: { limit: buildListLimitFlag("organizations"), json: LIST_JSON_FLAG, + refresh: REFRESH_FLAG, }, // Only -n for --limit; no -c since org list has no --cursor flag aliases: { n: "limit" }, }, async func(this: SentryContext, flags: ListFlags): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout } = this; const orgs = await listOrganizations(); diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index 8a7c39e6d..e04abcc9d 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -10,12 +10,15 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails, writeOutput } from "../../lib/formatters/index.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { buildOrgUrl } from "../../lib/sentry-urls.js"; type ViewFlags = { readonly json: boolean; readonly web: boolean; + readonly refresh: boolean; }; export const viewCommand = buildCommand({ @@ -51,6 +54,7 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + refresh: REFRESH_FLAG, }, aliases: { w: "web" }, }, @@ -59,6 +63,9 @@ export const viewCommand = buildCommand({ flags: ViewFlags, orgSlug?: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd } = this; const resolved = await resolveOrg({ org: orgSlug, cwd }); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 2279a88c1..6fe858276 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -41,6 +41,7 @@ import { LIST_CURSOR_FLAG, LIST_JSON_FLAG, LIST_TARGET_POSITIONAL, + REFRESH_FLAG, targetPatternExplanation, } from "../../lib/list-command.js"; import { @@ -51,6 +52,7 @@ import { type ResolvedTarget, resolveAllTargets, } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryProject, Writer } from "../../types/index.js"; @@ -62,6 +64,7 @@ type ListFlags = { readonly json: boolean; readonly cursor?: string; readonly platform?: string; + readonly refresh: boolean; }; /** @@ -583,6 +586,7 @@ export const listCommand = buildListCommand("project", { brief: "Filter by platform (e.g., javascript, python)", optional: true, }, + refresh: REFRESH_FLAG, }, aliases: { ...LIST_BASE_ALIASES, p: "platform" }, }, @@ -591,6 +595,9 @@ export const listCommand = buildListCommand("project", { flags: ListFlags, target?: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd } = this; const parsed = parseOrgProjectArg(target); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index e874086a2..53423c4c7 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -20,18 +20,20 @@ import { writeJson, writeOutput, } from "../../lib/formatters/index.js"; -import { TARGET_PATTERN_NOTE } from "../../lib/list-command.js"; +import { REFRESH_FLAG, TARGET_PATTERN_NOTE } from "../../lib/list-command.js"; import { type ResolvedTarget, resolveAllTargets, resolveProjectBySlug, } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; import type { SentryProject } from "../../types/index.js"; type ViewFlags = { readonly json: boolean; readonly web: boolean; + readonly refresh: boolean; }; /** Usage hint for ContextError messages */ @@ -197,6 +199,7 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + refresh: REFRESH_FLAG, }, aliases: { w: "web" }, }, @@ -205,6 +208,9 @@ export const viewCommand = buildCommand({ flags: ViewFlags, targetArg?: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd } = this; const parsed = parseOrgProjectArg(targetArg); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 908d2d1eb..16c1217f9 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -21,9 +21,11 @@ import { import { buildListCommand, LIST_CURSOR_FLAG, + REFRESH_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; type ListFlags = { readonly limit: number; @@ -31,6 +33,7 @@ type ListFlags = { readonly sort: "date" | "duration"; readonly json: boolean; readonly cursor?: string; + readonly refresh: boolean; }; type SortValue = "date" | "duration"; @@ -141,6 +144,7 @@ export const listCommand = buildListCommand("trace", { brief: "Output as JSON", default: false, }, + refresh: REFRESH_FLAG, }, aliases: { n: "limit", q: "query", s: "sort", c: "cursor" }, }, @@ -149,6 +153,9 @@ export const listCommand = buildListCommand("trace", { flags: ListFlags, target?: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd, setContext } = this; // Resolve org/project from positional arg, config, or DSN auto-detection diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 741e4938d..4491d728b 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -11,7 +11,9 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { displayTraceLogs } from "../../lib/formatters/index.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import { validateTraceId } from "../../lib/trace-id.js"; @@ -21,6 +23,7 @@ type LogsFlags = { readonly period: string; readonly limit: number; readonly query?: string; + readonly refresh: boolean; }; /** Maximum allowed value for --limit flag */ @@ -163,6 +166,7 @@ export const logsCommand = buildCommand({ brief: "Additional filter query (Sentry search syntax)", optional: true, }, + refresh: REFRESH_FLAG, }, aliases: { w: "web", t: "period", n: "limit", q: "query" }, }, @@ -171,6 +175,9 @@ export const logsCommand = buildCommand({ flags: LogsFlags, ...args: string[] ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd, setContext } = this; const { traceId, orgArg } = parsePositionalArgs(args); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 8ba02e251..3f762364f 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -23,11 +23,13 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; +import { REFRESH_FLAG } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; +import { disableResponseCache } from "../../lib/response-cache.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import type { Writer } from "../../types/index.js"; @@ -35,6 +37,7 @@ type ViewFlags = { readonly json: boolean; readonly web: boolean; readonly spans: number; + readonly refresh: boolean; }; /** Usage hint for ContextError messages */ @@ -160,6 +163,7 @@ export const viewCommand = buildCommand({ default: false, }, ...spansFlag, + refresh: REFRESH_FLAG, }, aliases: { w: "web" }, }, @@ -168,6 +172,9 @@ export const viewCommand = buildCommand({ flags: ViewFlags, ...args: string[] ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd, setContext } = this; const log = logger.withTag("trace.view"); diff --git a/src/lib/db/auth.ts b/src/lib/db/auth.ts index 71e1a54bf..ff2fdf53f 100644 --- a/src/lib/db/auth.ts +++ b/src/lib/db/auth.ts @@ -2,6 +2,7 @@ * Authentication credential storage (single-row table pattern). */ +import { clearResponseCache } from "../response-cache.js"; import { withDbSpan } from "../telemetry.js"; import { getDatabase } from "./index.js"; import { runUpsert } from "./utils.js"; @@ -162,6 +163,11 @@ export function clearAuth(): void { db.query("DELETE FROM org_regions").run(); db.query("DELETE FROM pagination_cursors").run(); }); + + // Clear cached API responses — they are tied to the current user's permissions + clearResponseCache().catch(() => { + // Non-fatal: cache directory may not exist yet + }); } export async function isAuthenticated(): Promise { diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 9de81818c..9d141e6ae 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -24,6 +24,7 @@ import { parseOrgProjectArg } from "./arg-parsing.js"; import { buildCommand, numberParser } from "./command.js"; import { warning } from "./formatters/colors.js"; import { dispatchOrgScopedList, type OrgListConfig } from "./org-list.js"; +import { disableResponseCache } from "./response-cache.js"; // --------------------------------------------------------------------------- // Level A: shared parameter / flag definitions @@ -83,6 +84,16 @@ export const LIST_JSON_FLAG = { default: false, } as const; +/** + * The `--refresh` flag shared by read-only commands. + * Bypasses the response cache and fetches fresh data from the API. + */ +export const REFRESH_FLAG = { + kind: "boolean" as const, + brief: "Bypass cache and fetch fresh data", + default: false, +} as const; + /** Matches strings that are all digits — used to detect invalid cursor values */ const ALL_DIGITS_RE = /^\d+$/; @@ -346,6 +357,7 @@ export function buildOrgListCommand( limit: buildListLimitFlag(config.entityPlural), json: LIST_JSON_FLAG, cursor: LIST_CURSOR_FLAG, + refresh: REFRESH_FLAG, }, aliases: LIST_BASE_ALIASES, }, @@ -355,9 +367,13 @@ export function buildOrgListCommand( readonly limit: number; readonly json: boolean; readonly cursor?: string; + readonly refresh: boolean; }, target?: string ): Promise { + if (flags.refresh) { + disableResponseCache(); + } const { stdout, cwd } = this; const parsed = parseOrgProjectArg(target); await dispatchOrgScopedList({ config, stdout, cwd, flags, parsed }); diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts new file mode 100644 index 000000000..169c6219d --- /dev/null +++ b/src/lib/response-cache.ts @@ -0,0 +1,572 @@ +/** + * Filesystem-based HTTP response cache for read-only API calls. + * + * Uses `http-cache-semantics` (RFC 7234/9111) to make correct caching decisions. + * When the server provides `Cache-Control` / `ETag` / `Expires` headers, they + * are respected automatically. When the server sends no cache headers (Sentry's + * current behavior), a URL-based fallback TTL is applied. + * + * Cache entries are stored as individual JSON files under `~/.sentry/cache/responses/`. + * This keeps the response data separate from the config SQLite database, which + * stores small structured data (tokens, org slugs, cursors). API responses can + * be 50–500 KB each, so a dedicated cache directory avoids bloating the DB. + * + * @module + */ + +import { createHash } from "node:crypto"; +import { + mkdir, + readdir, + readFile, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import CachePolicy from "http-cache-semantics"; + +import { getConfigDir } from "./db/index.js"; + +// --------------------------------------------------------------------------- +// TTL tiers — used as fallback when the server sends no cache headers +// --------------------------------------------------------------------------- + +/** + * TTL tier classification for URLs. + * + * - `immutable`: data that never changes once created (events, traces) + * - `stable`: data that changes infrequently (orgs, projects, teams) + * - `volatile`: data that changes often (issue lists, log lists) + * - `no-cache`: never cache (polling endpoints like autofix state) + */ +type TtlTier = "immutable" | "stable" | "volatile" | "no-cache"; + +/** Fallback TTL durations by tier (milliseconds). `no-cache` uses 0 as a sentinel. */ +const FALLBACK_TTL_MS: Record = { + immutable: 60 * 60 * 1000, // 1 hour + stable: 5 * 60 * 1000, // 5 minutes + volatile: 60 * 1000, // 60 seconds + "no-cache": 0, +}; + +/** + * URL patterns → TTL tier (checked in order, first match wins). + * Patterns match against the full URL string. + */ +const URL_TIER_PATTERNS: ReadonlyArray<{ pattern: RegExp; tier: TtlTier }> = [ + // No-cache: polling endpoints where state changes rapidly + { pattern: /\/autofix\//, tier: "no-cache" }, + { pattern: /\/root-cause\//, tier: "no-cache" }, + + // Immutable: specific resources by ID (events, traces) + { pattern: /\/events\/[^/?]+\/?(?:\?|$)/, tier: "immutable" }, + { pattern: /\/trace\/[0-9a-f]{32}\//, tier: "immutable" }, + + // Volatile: list endpoints with high churn + { pattern: /\/issues\/?\?/, tier: "volatile" }, + { pattern: /\/issues\/?$/, tier: "volatile" }, + { pattern: /[?&]dataset=logs/, tier: "volatile" }, + { pattern: /[?&]dataset=transactions/, tier: "volatile" }, + { pattern: /\/trace-logs\//, tier: "volatile" }, + + // Everything else falls through to "stable" (default) +]; + +/** + * Classify a URL into a TTL tier for fallback caching. + * + * @param url - Full URL string (with query params) + * @returns The TTL tier + * @internal Exported for testing + */ +export function classifyUrl(url: string): TtlTier { + for (const { pattern, tier } of URL_TIER_PATTERNS) { + if (pattern.test(url)) { + return tier; + } + } + return "stable"; +} + +// --------------------------------------------------------------------------- +// Cache key generation +// --------------------------------------------------------------------------- + +/** + * Build a deterministic cache key from an HTTP method and URL. + * + * Query parameters are sorted alphabetically so that `?a=1&b=2` and `?b=2&a=1` + * produce the same key. The key is then SHA-256 hashed to produce a fixed-length + * filename-safe string. + * + * @param method - HTTP method (e.g., "GET") + * @param url - Full URL string + * @returns Hex-encoded SHA-256 hash suitable for use as a filename + * @internal Exported for testing + */ +export function buildCacheKey(method: string, url: string): string { + const normalized = normalizeUrl(method, url); + return createHash("sha256").update(normalized).digest("hex"); +} + +/** + * Normalize method + URL into a stable string for cache key derivation. + * Sorts query params alphabetically for deterministic key generation. + * + * @internal Exported for testing + */ +export function normalizeUrl(method: string, url: string): string { + try { + const parsed = new URL(url); + const sortedParams = new URLSearchParams( + [...parsed.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)) + ); + parsed.search = sortedParams.toString() + ? `?${sortedParams.toString()}` + : ""; + return `${method.toUpperCase()}|${parsed.toString()}`; + } catch { + // Malformed URL — use as-is + return `${method.toUpperCase()}|${url}`; + } +} + +// --------------------------------------------------------------------------- +// Cache storage types and constants +// --------------------------------------------------------------------------- + +/** Shape of a serialized cache entry on disk */ +type CacheEntry = { + /** Serialized CachePolicy object (via policy.toObject()) */ + policy: CachePolicy.CachePolicyObject; + /** Response body (already parsed JSON) */ + body: unknown; + /** HTTP status code */ + status: number; + /** Selected response headers (e.g., Link for pagination) */ + headers: Record; + /** Original URL, used for TTL tier classification during cleanup */ + url: string; + /** When this entry was created (epoch ms) */ + createdAt: number; +}; + +/** CachePolicy options for a single-user CLI cache */ +const POLICY_OPTIONS: CachePolicy.Options = { + shared: false, + cacheHeuristic: 0.1, + immutableMinTimeToLive: 3_600_000, +}; + +/** Maximum number of cache files to retain */ +const MAX_CACHE_ENTRIES = 500; + +/** Probability of running cleanup on each cache write */ +const CLEANUP_PROBABILITY = 0.1; + +/** + * Headers that should be preserved in the cache for consumers. + * Only includes headers that affect API client behavior (e.g., pagination). + */ +const PRESERVED_HEADERS = ["link"]; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Get the response cache directory path */ +function getCacheDir(): string { + return join(getConfigDir(), "cache", "responses"); +} + +/** Get the full file path for a cache key */ +function cacheFilePath(key: string): string { + return join(getCacheDir(), `${key}.json`); +} + +/** Check if an error is an ENOENT (file/directory not found) */ +function isNotFound(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +/** Extract the subset of response headers worth caching */ +function pickHeaders(headers: Headers): Record { + const result: Record = {}; + for (const name of PRESERVED_HEADERS) { + const value = headers.get(name); + if (value) { + result[name] = value; + } + } + return result; +} + +/** Convert Headers to a plain object for http-cache-semantics */ +function headersToObject(headers: Headers): Record { + const obj: Record = {}; + headers.forEach((value, key) => { + obj[key] = value; + }); + return obj; +} + +/** + * Check whether a cache entry is still fresh. + * + * Uses the server-provided TTL (via CachePolicy) when available. Falls back + * to URL-based TTL tiers when the server sends no cache headers. + */ +function isEntryFresh( + policy: CachePolicy, + entry: CacheEntry, + requestHeaders: Record, + url: string +): boolean { + const newRequest = { url, method: "GET", headers: requestHeaders }; + if (policy.satisfiesWithoutRevalidation(newRequest)) { + return true; + } + + // CachePolicy says stale — check if we should override with fallback TTL + const serverTtl = policy.timeToLive(); + if (serverTtl > 0) { + // Server provided a TTL and it expired — respect the server + return false; + } + + // No server TTL — use our fallback tier + const tier = classifyUrl(url); + const fallbackTtl = FALLBACK_TTL_MS[tier]; + const age = Date.now() - entry.createdAt; + return age <= fallbackTtl; +} + +/** + * Build the response headers for a cached entry. + * Merges CachePolicy's computed headers with our preserved headers. + * Flattens multi-value headers into comma-separated strings for the Response API. + */ +function buildResponseHeaders( + policy: CachePolicy, + entry: CacheEntry +): Record { + const policyHeaders = policy.responseHeaders(); + const result: Record = {}; + + for (const [name, value] of Object.entries(policyHeaders)) { + if (value === undefined) { + continue; + } + result[name] = Array.isArray(value) ? value.join(", ") : value; + } + + // Merge preserved headers (like Link for pagination) + for (const [name, value] of Object.entries(entry.headers)) { + if (!(name in result)) { + result[name] = value; + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Cache bypass control +// --------------------------------------------------------------------------- + +let cacheDisabledFlag = false; + +/** + * Disable the response cache for the current process. + * Called when `--refresh` flag is passed to a command. + */ +export function disableResponseCache(): void { + cacheDisabledFlag = true; +} + +/** + * Check if response caching is disabled. + * Cache is disabled when: + * - `disableResponseCache()` was called (--refresh flag) + * - `SENTRY_NO_CACHE=1` environment variable is set + */ +export function isCacheDisabled(): boolean { + return cacheDisabledFlag || process.env.SENTRY_NO_CACHE === "1"; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Attempt to serve a cached response for a GET request. + * + * Reads the cache file directly and handles ENOENT (cache miss) without a + * separate existence check. Reconstructs the `CachePolicy` from the stored + * metadata and verifies the cached response still satisfies the new request. + * + * @param method - HTTP method (only "GET" is cached) + * @param url - Full request URL + * @param requestHeaders - Headers from the new request + * @returns A synthetic Response if cache hit, or undefined on miss/expired + */ +export async function getCachedResponse( + method: string, + url: string, + requestHeaders: Record +): Promise { + if ( + method !== "GET" || + isCacheDisabled() || + classifyUrl(url) === "no-cache" + ) { + return; + } + + const key = buildCacheKey(method, url); + const entry = await readCacheEntry(key); + if (!entry) { + return; + } + + const policy = CachePolicy.fromObject(entry.policy); + if (!isEntryFresh(policy, entry, requestHeaders, url)) { + return; + } + + const responseHeaders = buildResponseHeaders(policy, entry); + return new Response(JSON.stringify(entry.body), { + status: entry.status, + headers: responseHeaders, + }); +} + +/** + * Read and parse a cache entry from disk. + * Returns undefined on ENOENT or parse errors. + */ +async function readCacheEntry(key: string): Promise { + const filePath = cacheFilePath(key); + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch { + // ENOENT = cache miss; other read errors = treat as miss + return; + } + + try { + return JSON.parse(raw) as CacheEntry; + } catch { + // Corrupted cache file — delete it + await unlink(filePath).catch(() => { + // Best-effort cleanup of corrupted file + }); + return; + } +} + +/** + * Store a response in the cache. + * + * Only caches successful (2xx) GET responses. Uses `http-cache-semantics` to + * determine if the response is storable per RFC 7234. If the server explicitly + * sends `Cache-Control: no-store`, the response is not cached. + * + * This function is fire-and-forget — errors are silently swallowed to avoid + * slowing down the response path. + * + * @param method - HTTP method + * @param url - Full request URL + * @param requestHeaders - Request headers + * @param response - The fetch Response to cache (must be cloned before passing) + */ +export async function storeCachedResponse( + method: string, + url: string, + requestHeaders: Record, + response: Response +): Promise { + if ( + method !== "GET" || + isCacheDisabled() || + !response.ok || + classifyUrl(url) === "no-cache" + ) { + return; + } + + try { + await writeResponseToCache(method, url, requestHeaders, response); + } catch { + // Cache write failures are non-fatal — silently ignore + } +} + +/** Core cache write logic, separated for complexity management */ +async function writeResponseToCache( + method: string, + url: string, + requestHeaders: Record, + response: Response +): Promise { + const responseHeadersObj = headersToObject(response.headers); + + const policy = new CachePolicy( + { url, method, headers: requestHeaders }, + { status: response.status, headers: responseHeadersObj }, + POLICY_OPTIONS + ); + + if (!policy.storable()) { + return; + } + + const body: unknown = await response.json(); + const key = buildCacheKey(method, url); + + const entry: CacheEntry = { + policy: policy.toObject(), + body, + status: response.status, + headers: pickHeaders(response.headers), + url, + createdAt: Date.now(), + }; + + await mkdir(getCacheDir(), { recursive: true, mode: 0o700 }); + await writeFile(cacheFilePath(key), JSON.stringify(entry), "utf-8"); + + // Probabilistic cleanup to avoid unbounded cache growth + if (Math.random() < CLEANUP_PROBABILITY) { + cleanupCache().catch(() => { + // Non-fatal: cleanup failure doesn't affect cache correctness + }); + } +} + +/** + * Remove all cached responses. + * Called on `auth logout` and `auth login` since cached data is tied to the user. + */ +export async function clearResponseCache(): Promise { + try { + await rm(getCacheDir(), { recursive: true, force: true }); + } catch { + // Ignore errors — directory may not exist + } +} + +// --------------------------------------------------------------------------- +// Cache cleanup +// --------------------------------------------------------------------------- + +/** + * Clean up expired and excess cache entries. + * + * Deletes entries that have expired (based on server TTL or fallback TTL), + * then enforces a maximum entry count by evicting the oldest entries. + */ +async function cleanupCache(): Promise { + const cacheDir = getCacheDir(); + let files: string[]; + try { + files = await readdir(cacheDir); + } catch (error) { + if (isNotFound(error)) { + return; + } + throw error; + } + + const jsonFiles = files.filter((f) => f.endsWith(".json")); + if (jsonFiles.length === 0) { + return; + } + + const entries = await collectEntryMetadata(cacheDir, jsonFiles); + + // Delete expired entries + await deleteExpiredEntries(cacheDir, entries); + + // Enforce max entry count — evict oldest first + await evictExcessEntries(cacheDir, entries); +} + +/** Metadata for a cache entry, used for cleanup decisions */ +type EntryMetadata = { file: string; createdAt: number; expired: boolean }; + +/** Read all cache files and determine which are expired */ +async function collectEntryMetadata( + cacheDir: string, + jsonFiles: string[] +): Promise { + const entries: EntryMetadata[] = []; + const now = Date.now(); + + for (const file of jsonFiles) { + const filePath = join(cacheDir, file); + try { + const raw = await readFile(filePath, "utf-8"); + const entry = JSON.parse(raw) as CacheEntry; + const policy = CachePolicy.fromObject(entry.policy); + + const serverTtl = policy.timeToLive(); + let expired: boolean; + if (serverTtl > 0) { + expired = false; + } else { + // Use the entry's stored URL for accurate tier classification + const tier = classifyUrl(entry.url ?? ""); + expired = now - entry.createdAt > FALLBACK_TTL_MS[tier]; + } + + entries.push({ file, createdAt: entry.createdAt, expired }); + } catch { + // Unparseable file — delete it + await unlink(filePath).catch(() => { + // Best-effort cleanup of corrupted file + }); + } + } + + return entries; +} + +/** Delete cache files that have expired */ +async function deleteExpiredEntries( + cacheDir: string, + entries: EntryMetadata[] +): Promise { + for (const entry of entries) { + if (entry.expired) { + await unlink(join(cacheDir, entry.file)).catch(() => { + // Best-effort: file may have been deleted by another process + }); + } + } +} + +/** Evict the oldest entries when over the max count */ +async function evictExcessEntries( + cacheDir: string, + entries: EntryMetadata[] +): Promise { + const remaining = entries.filter((e) => !e.expired); + if (remaining.length <= MAX_CACHE_ENTRIES) { + return; + } + + remaining.sort((a, b) => a.createdAt - b.createdAt); + const toEvict = remaining.slice(0, remaining.length - MAX_CACHE_ENTRIES); + for (const entry of toEvict) { + await unlink(join(cacheDir, entry.file)).catch(() => { + // Best-effort eviction + }); + } +} diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index d71116ada..f4d86e95d 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -10,6 +10,7 @@ import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js"; import { isEnvTokenActive, refreshToken } from "./db/auth.js"; +import { getCachedResponse, storeCachedResponse } from "./response-cache.js"; import { withHttpSpan } from "./telemetry.js"; /** Request timeout in milliseconds */ @@ -191,16 +192,20 @@ function handleFetchError( return { action: "retry" }; } -/** Extract the URL pathname for span naming */ -function extractUrlPath(input: Request | string | URL): string { - let raw: string; +/** Extract the full URL string from a fetch input */ +function extractFullUrl(input: Request | string | URL): string { if (typeof input === "string") { - raw = input; - } else if (input instanceof URL) { - raw = input.href; - } else { - raw = input.url; + return input; } + if (input instanceof URL) { + return input.href; + } + return input.url; +} + +/** Extract the URL pathname for span naming */ +function extractUrlPath(input: Request | string | URL): string { + const raw = extractFullUrl(input); try { return new URL(raw).pathname; } catch { @@ -209,9 +214,45 @@ function extractUrlPath(input: Request | string | URL): string { } /** - * Create a fetch function with authentication, timeout, retry, and 401 refresh. + * Attempt to serve a GET request from the response cache. + * Returns the cached Response if valid, or undefined on miss. + */ +async function tryCacheHit( + method: string, + fullUrl: string +): Promise { + if (method !== "GET") { + return; + } + return await getCachedResponse(method, fullUrl, {}); +} + +/** + * Store a successful GET response in the cache (fire-and-forget). + * Clones the response so the original body stream is preserved for the caller. + */ +function cacheResponse( + method: string, + fullUrl: string, + response: Response +): void { + if (method !== "GET" || !response.ok) { + return; + } + // Cast needed: Bun extends Response with extra properties (toJSON, count, getAll) + // that .clone() doesn't carry over, but our cache only reads standard Response API + storeCachedResponse(method, fullUrl, {}, response.clone() as Response).catch( + () => { + // Non-fatal: cache write failures don't affect the response + } + ); +} + +/** + * Create a fetch function with authentication, timeout, retry, caching, and 401 refresh. * * This wraps the native fetch with: + * - **Response caching** for GET requests (checked before hitting the network) * - Auth token injection (Bearer token) * - Request timeout via AbortController * - Automatic retry on transient HTTP errors (408, 429, 5xx) @@ -220,6 +261,10 @@ function extractUrlPath(input: Request | string | URL): string { * - User-Agent header for API analytics * - Automatic HTTP span tracing for every request * + * Cache is checked first — on a hit, auth refresh, timeout, and retry logic are + * all skipped. On a miss or for non-GET methods, the full authenticated flow runs + * and successful GET responses are stored in the cache afterward. + * * @returns A fetch-compatible function for use with @sentry/api SDK functions */ function createAuthenticatedFetch(): ( @@ -235,6 +280,14 @@ function createAuthenticatedFetch(): ( const urlPath = extractUrlPath(input); return withHttpSpan(method, urlPath, async () => { + const fullUrl = extractFullUrl(input); + + // Check cache before auth/retry for GET requests + const cached = await tryCacheHit(method, fullUrl); + if (cached) { + return cached; + } + const { token } = await refreshToken(); const headers = prepareHeaders(input, init, token); @@ -248,6 +301,7 @@ function createAuthenticatedFetch(): ( ); if (result.action === "done") { + cacheResponse(method, fullUrl, result.response); return result.response; } if (result.action === "throw") { diff --git a/test/lib/response-cache.property.test.ts b/test/lib/response-cache.property.test.ts new file mode 100644 index 000000000..12ea720af --- /dev/null +++ b/test/lib/response-cache.property.test.ts @@ -0,0 +1,246 @@ +/** + * Property-Based Tests for Response Cache + * + * Verifies properties of cache key generation, URL normalization, + * and URL classification that should hold for any valid input. + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + assert as fcAssert, + property, + string, + tuple, +} from "fast-check"; +import { + buildCacheKey, + classifyUrl, + normalizeUrl, +} from "../../src/lib/response-cache.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Generate valid HTTP methods */ +const methodArb = constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"); + +/** Generate simple path segments */ +const pathSegmentArb = string({ minLength: 1, maxLength: 20 }).filter((s) => + /^[a-zA-Z0-9_-]+$/.test(s) +); + +/** Generate URL-like strings with paths and query params */ +const sentryUrlArb = tuple( + constantFrom( + "https://us.sentry.io", + "https://de.sentry.io", + "https://sentry.io" + ), + array(pathSegmentArb, { minLength: 1, maxLength: 5 }), + array( + tuple( + string({ minLength: 1, maxLength: 10 }).filter((s) => + /^[a-zA-Z]+$/.test(s) + ), + string({ minLength: 1, maxLength: 20 }).filter((s) => + /^[a-zA-Z0-9]+$/.test(s) + ) + ), + { minLength: 0, maxLength: 4 } + ) +).map(([base, paths, params]) => { + const pathStr = `/api/0/${paths.join("/")}`; + const query = + params.length > 0 + ? `?${params.map(([k, v]) => `${k}=${v}`).join("&")}` + : ""; + return `${base}${pathStr}${query}`; +}); + +// --------------------------------------------------------------------------- +// Tests: buildCacheKey +// --------------------------------------------------------------------------- + +describe("property: buildCacheKey", () => { + test("produces a 64-char hex string (SHA-256)", () => { + fcAssert( + property(methodArb, sentryUrlArb, (method, url) => { + const key = buildCacheKey(method, url); + expect(key).toMatch(/^[0-9a-f]{64}$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is deterministic — same inputs produce same key", () => { + fcAssert( + property(methodArb, sentryUrlArb, (method, url) => { + const key1 = buildCacheKey(method, url); + const key2 = buildCacheKey(method, url); + expect(key1).toBe(key2); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("different methods produce different keys for same URL", () => { + fcAssert( + property(sentryUrlArb, (url) => { + const getKey = buildCacheKey("GET", url); + const postKey = buildCacheKey("POST", url); + expect(getKey).not.toBe(postKey); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("query param order does not affect the key", () => { + fcAssert( + property( + constantFrom("https://us.sentry.io", "https://de.sentry.io"), + pathSegmentArb, + (base, path) => { + const url1 = `${base}/api/0/${path}?a=1&b=2&c=3`; + const url2 = `${base}/api/0/${path}?c=3&a=1&b=2`; + const key1 = buildCacheKey("GET", url1); + const key2 = buildCacheKey("GET", url2); + expect(key1).toBe(key2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("method comparison is case-insensitive", () => { + fcAssert( + property(sentryUrlArb, (url) => { + const key1 = buildCacheKey("get", url); + const key2 = buildCacheKey("GET", url); + expect(key1).toBe(key2); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: normalizeUrl +// --------------------------------------------------------------------------- + +describe("property: normalizeUrl", () => { + test("sorts query parameters alphabetically", () => { + const normalized = normalizeUrl("GET", "https://sentry.io/api?z=1&a=2&m=3"); + expect(normalized).toBe("GET|https://sentry.io/api?a=2&m=3&z=1"); + }); + + test("uppercases the method", () => { + fcAssert( + property( + constantFrom("get", "post", "put", "delete"), + sentryUrlArb, + (method, url) => { + const normalized = normalizeUrl(method, url); + expect(normalized.startsWith(method.toUpperCase())).toBe(true); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("produces pipe-separated method|url format", () => { + fcAssert( + property(methodArb, sentryUrlArb, (method, url) => { + const normalized = normalizeUrl(method, url); + expect(normalized).toContain("|"); + const [m] = normalized.split("|", 1); + expect(m).toBe(method.toUpperCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: classifyUrl +// --------------------------------------------------------------------------- + +describe("property: classifyUrl", () => { + test("always returns a valid tier", () => { + fcAssert( + property(sentryUrlArb, (url) => { + const tier = classifyUrl(url); + expect(["immutable", "stable", "volatile", "no-cache"]).toContain(tier); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("event detail URLs are immutable", () => { + const urls = [ + "https://us.sentry.io/api/0/projects/myorg/myproject/events/abc123/", + "https://sentry.io/api/0/projects/org/proj/events/deadbeef/?full=true", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("immutable"); + } + }); + + test("trace URLs with 32-char hex IDs are immutable", () => { + const traceId = "a".repeat(32); + const url = `https://us.sentry.io/api/0/organizations/myorg/trace/${traceId}/`; + expect(classifyUrl(url)).toBe("immutable"); + }); + + test("issue list URLs are volatile", () => { + const urls = [ + "https://us.sentry.io/api/0/projects/org/proj/issues/", + "https://us.sentry.io/api/0/projects/org/proj/issues/?query=is:unresolved", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("volatile"); + } + }); + + test("dataset=logs URLs are volatile", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/events/?dataset=logs&query=foo"; + expect(classifyUrl(url)).toBe("volatile"); + }); + + test("dataset=transactions URLs are volatile", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/events/?dataset=transactions"; + expect(classifyUrl(url)).toBe("volatile"); + }); + + test("autofix URLs are no-cache", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/org/issues/123/autofix/", + "https://sentry.io/api/0/organizations/org/issues/456/autofix/?format=json", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("no-cache"); + } + }); + + test("root-cause URLs are no-cache", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/issues/123/root-cause/"; + expect(classifyUrl(url)).toBe("no-cache"); + }); + + test("org/project/team list URLs default to stable", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/", + "https://us.sentry.io/api/0/organizations/myorg/projects/", + "https://us.sentry.io/api/0/organizations/myorg/teams/", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("stable"); + } + }); +}); diff --git a/test/lib/response-cache.test.ts b/test/lib/response-cache.test.ts new file mode 100644 index 000000000..6ae99ff04 --- /dev/null +++ b/test/lib/response-cache.test.ts @@ -0,0 +1,338 @@ +/** + * Unit Tests for Response Cache + * + * Tests the cache lifecycle: store, retrieve, expire, clear, and bypass. + * Uses isolated temp directories per test to avoid interference. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { + buildCacheKey, + clearResponseCache, + getCachedResponse, + storeCachedResponse, +} from "../../src/lib/response-cache.js"; +import { useTestConfigDir } from "../helpers.js"; + +const getConfigDir = useTestConfigDir("response-cache-"); + +// Reset cache disabled state between tests +let savedNoCache: string | undefined; + +beforeEach(() => { + savedNoCache = process.env.SENTRY_NO_CACHE; + delete process.env.SENTRY_NO_CACHE; +}); + +afterEach(() => { + if (savedNoCache !== undefined) { + process.env.SENTRY_NO_CACHE = savedNoCache; + } else { + delete process.env.SENTRY_NO_CACHE; + } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a mock Response with JSON body and optional headers */ +function mockResponse( + body: unknown, + status = 200, + headers: Record = {} +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + ...headers, + }, + }); +} + +const TEST_URL = "https://us.sentry.io/api/0/organizations/myorg/projects/"; +const TEST_METHOD = "GET"; +const TEST_BODY = { data: [{ id: 1, name: "test" }] }; + +// --------------------------------------------------------------------------- +// Store and Retrieve +// --------------------------------------------------------------------------- + +describe("store and retrieve", () => { + test("round-trip: store then retrieve returns same body", async () => { + const response = mockResponse(TEST_BODY); + await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeDefined(); + expect(cached!.status).toBe(200); + + const cachedBody = await cached!.json(); + expect(cachedBody).toEqual(TEST_BODY); + }); + + test("preserves Link header for pagination", async () => { + const linkHeader = + '; rel="next"'; + const response = mockResponse(TEST_BODY, 200, { link: linkHeader }); + await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeDefined(); + expect(cached!.headers.get("link")).toBe(linkHeader); + }); + + test("cache miss returns undefined", async () => { + const cached = await getCachedResponse( + TEST_METHOD, + "https://us.sentry.io/api/0/organizations/nonexistent/projects/", + {} + ); + expect(cached).toBeUndefined(); + }); + + test("different URLs produce different cache entries", async () => { + const url1 = "https://us.sentry.io/api/0/organizations/org1/projects/"; + const url2 = "https://us.sentry.io/api/0/organizations/org2/projects/"; + const body1 = { data: "org1" }; + const body2 = { data: "org2" }; + + await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse(body1)); + await storeCachedResponse(TEST_METHOD, url2, {}, mockResponse(body2)); + + const cached1 = await getCachedResponse(TEST_METHOD, url1, {}); + const cached2 = await getCachedResponse(TEST_METHOD, url2, {}); + + expect(await cached1!.json()).toEqual(body1); + expect(await cached2!.json()).toEqual(body2); + }); + + test("query param order does not affect cache lookup", async () => { + const url1 = "https://us.sentry.io/api/0/orgs/?a=1&b=2"; + const url2 = "https://us.sentry.io/api/0/orgs/?b=2&a=1"; + + await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse(TEST_BODY)); + + const cached = await getCachedResponse(TEST_METHOD, url2, {}); + expect(cached).toBeDefined(); + expect(await cached!.json()).toEqual(TEST_BODY); + }); +}); + +// --------------------------------------------------------------------------- +// Method isolation +// --------------------------------------------------------------------------- + +describe("method isolation", () => { + test("only GET requests are cached", async () => { + await storeCachedResponse("POST", TEST_URL, {}, mockResponse(TEST_BODY)); + + const cached = await getCachedResponse("POST", TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("GET lookup does not return POST-stored data", async () => { + // This is already guaranteed since POST doesn't store, but test explicitly + await storeCachedResponse("GET", TEST_URL, {}, mockResponse(TEST_BODY)); + + // GET should find it + const getResult = await getCachedResponse("GET", TEST_URL, {}); + expect(getResult).toBeDefined(); + + // POST should not even look + const postResult = await getCachedResponse("POST", TEST_URL, {}); + expect(postResult).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Non-2xx responses +// --------------------------------------------------------------------------- + +describe("non-2xx responses", () => { + test("4xx responses are not cached", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse({ detail: "not found" }, 404) + ); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("5xx responses are not cached", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse({ detail: "server error" }, 500) + ); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Cache-Control: no-store +// --------------------------------------------------------------------------- + +describe("Cache-Control: no-store", () => { + test("responses with no-store are not cached", async () => { + const response = mockResponse(TEST_BODY, 200, { + "cache-control": "no-store", + }); + await storeCachedResponse(TEST_METHOD, TEST_URL, {}, response); + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// clearResponseCache +// --------------------------------------------------------------------------- + +describe("clearResponseCache", () => { + test("removes all cached entries", async () => { + const url1 = "https://us.sentry.io/api/0/orgs/a/projects/"; + const url2 = "https://us.sentry.io/api/0/orgs/b/projects/"; + + await storeCachedResponse(TEST_METHOD, url1, {}, mockResponse({ a: 1 })); + await storeCachedResponse(TEST_METHOD, url2, {}, mockResponse({ b: 2 })); + + // Verify entries exist + expect(await getCachedResponse(TEST_METHOD, url1, {})).toBeDefined(); + + await clearResponseCache(); + + // Verify all cleared + expect(await getCachedResponse(TEST_METHOD, url1, {})).toBeUndefined(); + expect(await getCachedResponse(TEST_METHOD, url2, {})).toBeUndefined(); + }); + + test("is idempotent — clearing empty cache does not throw", async () => { + await clearResponseCache(); + await clearResponseCache(); + // No error + }); +}); + +// --------------------------------------------------------------------------- +// Cache bypass +// --------------------------------------------------------------------------- + +describe("cache bypass", () => { + test("SENTRY_NO_CACHE=1 bypasses cache reads", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + process.env.SENTRY_NO_CACHE = "1"; + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); + + test("SENTRY_NO_CACHE=1 bypasses cache writes", async () => { + process.env.SENTRY_NO_CACHE = "1"; + + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + // Remove the bypass to verify nothing was written + delete process.env.SENTRY_NO_CACHE; + + const cached = await getCachedResponse(TEST_METHOD, TEST_URL, {}); + expect(cached).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// buildCacheKey +// --------------------------------------------------------------------------- + +describe("buildCacheKey", () => { + test("produces a 64-char hex string", () => { + const key = buildCacheKey("GET", TEST_URL); + expect(key).toMatch(/^[0-9a-f]{64}$/); + }); + + test("is deterministic", () => { + const key1 = buildCacheKey("GET", TEST_URL); + const key2 = buildCacheKey("GET", TEST_URL); + expect(key1).toBe(key2); + }); + + test("different methods produce different keys", () => { + const getKey = buildCacheKey("GET", TEST_URL); + const postKey = buildCacheKey("POST", TEST_URL); + expect(getKey).not.toBe(postKey); + }); +}); + +// --------------------------------------------------------------------------- +// No-cache tier (polling endpoints) +// --------------------------------------------------------------------------- + +describe("no-cache tier", () => { + test("autofix URLs are not cached", async () => { + const autofixUrl = + "https://us.sentry.io/api/0/organizations/myorg/issues/123/autofix/"; + await storeCachedResponse( + TEST_METHOD, + autofixUrl, + {}, + mockResponse({ autofix: { status: "PROCESSING" } }) + ); + + const cached = await getCachedResponse(TEST_METHOD, autofixUrl, {}); + expect(cached).toBeUndefined(); + }); + + test("root-cause URLs are not cached", async () => { + const rootCauseUrl = + "https://us.sentry.io/api/0/organizations/myorg/issues/123/root-cause/"; + await storeCachedResponse( + TEST_METHOD, + rootCauseUrl, + {}, + mockResponse({ cause: "something" }) + ); + + const cached = await getCachedResponse(TEST_METHOD, rootCauseUrl, {}); + expect(cached).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// File structure +// --------------------------------------------------------------------------- + +describe("file structure", () => { + test("creates cache directory under config dir", async () => { + await storeCachedResponse( + TEST_METHOD, + TEST_URL, + {}, + mockResponse(TEST_BODY) + ); + + const cacheDir = join(getConfigDir(), "cache", "responses"); + const files = await readdir(cacheDir); + expect(files.length).toBe(1); + expect(files[0]).toMatch(/^[0-9a-f]{64}\.json$/); + }); +}); From e716417b1c16b34400cb19ef0753ca12905184ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 02:23:09 +0000 Subject: [PATCH 02/12] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index d2fb1e0e9..ae9fd877d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -89,6 +89,7 @@ View authentication status **Flags:** - `--show-token - Show the stored token (masked by default)` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -106,6 +107,7 @@ Show the currently authenticated user **Flags:** - `--json - Output as JSON` +- `--refresh - Bypass cache and fetch fresh data` ### Org @@ -118,6 +120,7 @@ List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "30")` - `--json - Output JSON` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -134,6 +137,7 @@ View details of an organization **Flags:** - `--json - Output as JSON` - `-w, --web - Open in browser` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -166,6 +170,7 @@ List projects - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -187,6 +192,7 @@ View details of a project **Flags:** - `--json - Output as JSON` - `-w, --web - Open in browser` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -220,6 +226,7 @@ List issues in a project - `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -264,6 +271,7 @@ Analyze an issue's root cause using Seer AI **Flags:** - `--json - Output as JSON` - `--force - Force new analysis even if one exists` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -291,6 +299,7 @@ Generate a solution plan using Seer AI - `--cause - Root cause ID to plan (required if multiple causes exist)` - `--json - Output as JSON` - `--force - Force new plan even if one exists` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -318,6 +327,7 @@ View details of a specific issue - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -345,6 +355,7 @@ View details of a specific event - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -467,6 +478,7 @@ List repositories - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `--refresh - Bypass cache and fetch fresh data` ### Team @@ -480,6 +492,7 @@ List teams - `-n, --limit - Maximum number of teams to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -510,6 +523,7 @@ List logs from a project - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` - `--trace - Filter logs by trace ID (32-character hex string)` - `--json - Output as JSON` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -555,6 +569,7 @@ View details of one or more log entries **Flags:** - `--json - Output as JSON` - `-w, --web - Open in browser` +- `--refresh - Bypass cache and fetch fresh data` **Examples:** @@ -591,6 +606,7 @@ List recent traces in a project - `-s, --sort - Sort by: date, duration - (default: "date")` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `--json - Output as JSON` +- `--refresh - Bypass cache and fetch fresh data` #### `sentry trace view ` @@ -600,6 +616,7 @@ View details of a specific trace - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` +- `--refresh - Bypass cache and fetch fresh data` #### `sentry trace logs ` @@ -611,6 +628,7 @@ View logs associated with a trace - `-t, --period - Time period to search (e.g., "14d", "7d", "24h"). Default: 14d - (default: "14d")` - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Additional filter query (Sentry search syntax)` +- `--refresh - Bypass cache and fetch fresh data` ### Issues @@ -627,6 +645,7 @@ List issues in a project - `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` +- `--refresh - Bypass cache and fetch fresh data` ### Orgs @@ -639,6 +658,7 @@ List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "30")` - `--json - Output JSON` +- `--refresh - Bypass cache and fetch fresh data` ### Projects @@ -653,6 +673,7 @@ List projects - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` +- `--refresh - Bypass cache and fetch fresh data` ### Repos @@ -666,6 +687,7 @@ List repositories - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `--refresh - Bypass cache and fetch fresh data` ### Teams @@ -679,6 +701,7 @@ List teams - `-n, --limit - Maximum number of teams to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `--refresh - Bypass cache and fetch fresh data` ### Logs @@ -694,6 +717,7 @@ List logs from a project - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` - `--trace - Filter logs by trace ID (32-character hex string)` - `--json - Output as JSON` +- `--refresh - Bypass cache and fetch fresh data` ### Traces @@ -709,6 +733,7 @@ List recent traces in a project - `-s, --sort - Sort by: date, duration - (default: "date")` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `--json - Output as JSON` +- `--refresh - Bypass cache and fetch fresh data` ### Whoami @@ -720,6 +745,7 @@ Show the currently authenticated user **Flags:** - `--json - Output as JSON` +- `--refresh - Bypass cache and fetch fresh data` ## Global Options From 8d55bb0cb968dd212e1ffbc9561a523e58b389a5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 10:31:06 +0000 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20rename=20--refresh=20to=20--fresh,=20fix=20URL=20cl?= =?UTF-8?q?assification=20and=20auth=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix issue detail URLs (/issues/12345/) misclassified as 'stable' (5min) instead of 'volatile' (60sec). Broadened pattern to match all /issues/ URLs. - Fix cache ignoring Authorization headers for Vary-correctness. Thread current auth token into tryCacheHit() and refreshed token into cacheResponse() via new authHeaders() helper. - Extract fetchWithRetry() to keep authenticatedFetch complexity under 15. - Rename --refresh flag to --fresh with -f alias across all 16 commands. - Create FRESH_FLAG constant and applyFreshFlag() helper in list-command.ts as a common primitive, removing direct disableResponseCache imports from all command files. - Add -f alias to buildOrgListCommand and all individual command aliases. Exception: log list uses -f for --follow (no conflict). - Verify logout already clears cache via clearAuth() → clearResponseCache(). --- src/commands/auth/status.ts | 12 ++- src/commands/auth/whoami.ts | 12 ++- src/commands/event/view.ts | 13 ++-- src/commands/issue/explain.ts | 12 ++- src/commands/issue/list.ts | 20 +++-- src/commands/issue/plan.ts | 12 ++- src/commands/issue/view.ts | 13 ++-- src/commands/log/list.ts | 14 ++-- src/commands/log/view.ts | 13 ++-- src/commands/org/list.ts | 14 ++-- src/commands/org/view.ts | 13 ++-- src/commands/project/list.ts | 14 ++-- src/commands/project/view.ts | 17 ++-- src/commands/trace/list.ts | 14 ++-- src/commands/trace/logs.ts | 13 ++-- src/commands/trace/view.ts | 13 ++-- src/lib/list-command.ts | 33 ++++++-- src/lib/response-cache.ts | 4 +- src/lib/sentry-client.ts | 99 ++++++++++++++++-------- test/lib/response-cache.property.test.ts | 5 +- 20 files changed, 191 insertions(+), 169 deletions(-) diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 01fa37117..b0af86e35 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -27,13 +27,12 @@ import { formatUserIdentity, maskToken, } from "../../lib/formatters/human.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import type { Writer } from "../../types/index.js"; type StatusFlags = { readonly "show-token": boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; /** @@ -151,13 +150,12 @@ export const statusCommand = buildCommand({ brief: "Show the stored token (masked by default)", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, + aliases: { f: "fresh" }, }, async func(this: SentryContext, flags: StatusFlags): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, stderr } = this; const auth = await getAuthConfig(); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index d03bb017d..db4b1fcd1 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -13,12 +13,11 @@ import { isAuthenticated } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; import { AuthError } from "../../lib/errors.js"; import { formatUserIdentity, writeJson } from "../../lib/formatters/index.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; type WhoamiFlags = { readonly json: boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; export const whoamiCommand = buildCommand({ @@ -36,13 +35,12 @@ export const whoamiCommand = buildCommand({ brief: "Output as JSON", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, + aliases: { f: "fresh" }, }, async func(this: SentryContext, flags: WhoamiFlags): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout } = this; if (!(await isAuthenticated())) { diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index af95d55fb..442120e87 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,14 +23,13 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { applySentryUrlContext, parseSentryUrl, @@ -43,7 +42,7 @@ type ViewFlags = { readonly json: boolean; readonly web: boolean; readonly spans: number; - readonly refresh: boolean; + readonly fresh: boolean; }; type HumanOutputOptions = { @@ -321,18 +320,16 @@ export const viewCommand = buildCommand({ default: false, }, ...spansFlag, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { f: "fresh", w: "web" }, }, async func( this: SentryContext, flags: ViewFlags, ...args: string[] ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd } = this; const log = logger.withTag("event.view"); diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 303e3478e..e657becde 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -12,8 +12,7 @@ import { formatRootCauseList, handleSeerApiError, } from "../../lib/formatters/seer.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import { extractRootCauses } from "../../types/seer.js"; import { ensureRootCauseAnalysis, @@ -24,7 +23,7 @@ import { type ExplainFlags = { readonly json: boolean; readonly force: boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; export const explainCommand = buildCommand({ @@ -64,17 +63,16 @@ export const explainCommand = buildCommand({ brief: "Force new analysis even if one exists", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, + aliases: { f: "fresh" }, }, async func( this: SentryContext, flags: ExplainFlags, issueArg: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, stderr, cwd } = this; // Declare org outside try block so it's accessible in catch for error messages diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index cf8ca0dd0..a9f32b794 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -43,13 +43,14 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { + applyFreshFlag, buildListCommand, buildListLimitFlag, + FRESH_FLAG, LIST_BASE_ALIASES, LIST_JSON_FLAG, LIST_TARGET_POSITIONAL, parseCursorFlag, - REFRESH_FLAG, targetPatternExplanation, } from "../../lib/list-command.js"; import { @@ -64,7 +65,6 @@ import { resolveAllTargets, toNumericId, } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { ProjectAliasEntry, @@ -82,7 +82,7 @@ type ListFlags = { readonly period: string; readonly json: boolean; readonly cursor?: string; - readonly refresh: boolean; + readonly fresh: boolean; }; /** @internal */ export type SortValue = "date" | "new" | "freq" | "user"; @@ -1196,18 +1196,22 @@ export const listCommand = buildListCommand("issue", { 'Pagination cursor for / or multi-target modes (use "last" to continue)', optional: true, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, + }, + aliases: { + ...LIST_BASE_ALIASES, + f: "fresh", + q: "query", + s: "sort", + t: "period", }, - aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort", t: "period" }, }, async func( this: SentryContext, flags: ListFlags, target?: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, stderr, cwd, setContext } = this; const parsed = parseOrgProjectArg(target); diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index a46d17dea..1132390c0 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -15,8 +15,7 @@ import { formatSolution, handleSeerApiError, } from "../../lib/formatters/seer.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import type { Writer } from "../../types/index.js"; import { type AutofixState, @@ -36,7 +35,7 @@ type PlanFlags = { readonly cause?: number; readonly json: boolean; readonly force: boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; /** @@ -177,17 +176,16 @@ export const planCommand = buildCommand({ brief: "Force new plan even if one exists", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, + aliases: { f: "fresh" }, }, async func( this: SentryContext, flags: PlanFlags, issueArg: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, stderr, cwd } = this; // Declare org outside try block so it's accessible in catch for error messages diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 7a10e3a75..eaf02687c 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -16,8 +16,7 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, SentryIssue, Writer } from "../../types/index.js"; import { issueIdPositional, resolveIssue } from "./utils.js"; @@ -26,7 +25,7 @@ type ViewFlags = { readonly json: boolean; readonly web: boolean; readonly spans: number; - readonly refresh: boolean; + readonly fresh: boolean; }; /** @@ -103,18 +102,16 @@ export const viewCommand = buildCommand({ default: false, }, ...spansFlag, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { f: "fresh", w: "web" }, }, async func( this: SentryContext, flags: ViewFlags, issueArg: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd, setContext } = this; // Resolve issue using shared resolution logic diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 1791cf24b..83d723947 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -26,15 +26,15 @@ import { import { renderInlineMarkdown } from "../../lib/formatters/markdown.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { + applyFreshFlag, buildListCommand, - REFRESH_FLAG, + FRESH_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { resolveOrg, resolveOrgProjectFromArg, } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { validateTraceId } from "../../lib/trace-id.js"; import { getUpdateNotification } from "../../lib/version-check.js"; import type { Writer } from "../../types/index.js"; @@ -45,8 +45,8 @@ type ListFlags = { readonly follow?: number; readonly json: boolean; readonly trace?: string; - readonly refresh: boolean; -} + readonly fresh: boolean; +}; /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -442,7 +442,7 @@ export const listCommand = buildListCommand("log", { brief: "Output as JSON", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, aliases: { n: "limit", @@ -455,9 +455,7 @@ export const listCommand = buildListCommand("log", { flags: ListFlags, target?: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, stderr, cwd, setContext } = this; if (flags.trace) { diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 5227e09f5..c21ff2554 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -18,13 +18,12 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; import { validateHexId } from "../../lib/hex-id.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { buildLogsUrl } from "../../lib/sentry-urls.js"; import type { DetailedSentryLog } from "../../types/index.js"; @@ -33,7 +32,7 @@ const log = logger.withTag("log-view"); type ViewFlags = { readonly json: boolean; readonly web: boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; /** Usage hint for ContextError messages */ @@ -336,18 +335,16 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { f: "fresh", w: "web" }, }, async func( this: SentryContext, flags: ViewFlags, ...args: string[] ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd, setContext } = this; const cmdLog = logger.withTag("log.view"); diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 276dbde6b..08693cc3f 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -13,16 +13,16 @@ import { writeFooter, writeJson } from "../../lib/formatters/index.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { + applyFreshFlag, buildListLimitFlag, + FRESH_FLAG, LIST_JSON_FLAG, - REFRESH_FLAG, } from "../../lib/list-command.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; type ListFlags = { readonly limit: number; readonly json: boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; /** @@ -75,15 +75,13 @@ export const listCommand = buildCommand({ flags: { limit: buildListLimitFlag("organizations"), json: LIST_JSON_FLAG, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, // Only -n for --limit; no -c since org list has no --cursor flag - aliases: { n: "limit" }, + aliases: { f: "fresh", n: "limit" }, }, async func(this: SentryContext, flags: ListFlags): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout } = this; const orgs = await listOrganizations(); diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index e04abcc9d..141f1fa87 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -10,15 +10,14 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails, writeOutput } from "../../lib/formatters/index.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { buildOrgUrl } from "../../lib/sentry-urls.js"; type ViewFlags = { readonly json: boolean; readonly web: boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; export const viewCommand = buildCommand({ @@ -54,18 +53,16 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { f: "fresh", w: "web" }, }, async func( this: SentryContext, flags: ViewFlags, orgSlug?: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd } = this; const resolved = await resolveOrg({ org: orgSlug, cwd }); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 6fe858276..c03582b00 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -35,13 +35,14 @@ import { writeFooter, writeJson } from "../../lib/formatters/index.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { + applyFreshFlag, buildListCommand, buildListLimitFlag, + FRESH_FLAG, LIST_BASE_ALIASES, LIST_CURSOR_FLAG, LIST_JSON_FLAG, LIST_TARGET_POSITIONAL, - REFRESH_FLAG, targetPatternExplanation, } from "../../lib/list-command.js"; import { @@ -52,7 +53,6 @@ import { type ResolvedTarget, resolveAllTargets, } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryProject, Writer } from "../../types/index.js"; @@ -64,7 +64,7 @@ type ListFlags = { readonly json: boolean; readonly cursor?: string; readonly platform?: string; - readonly refresh: boolean; + readonly fresh: boolean; }; /** @@ -586,18 +586,16 @@ export const listCommand = buildListCommand("project", { brief: "Filter by platform (e.g., javascript, python)", optional: true, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { ...LIST_BASE_ALIASES, p: "platform" }, + aliases: { ...LIST_BASE_ALIASES, f: "fresh", p: "platform" }, }, async func( this: SentryContext, flags: ListFlags, target?: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd } = this; const parsed = parseOrgProjectArg(target); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 53423c4c7..8f1701be0 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -20,20 +20,23 @@ import { writeJson, writeOutput, } from "../../lib/formatters/index.js"; -import { REFRESH_FLAG, TARGET_PATTERN_NOTE } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_FLAG, + TARGET_PATTERN_NOTE, +} from "../../lib/list-command.js"; import { type ResolvedTarget, resolveAllTargets, resolveProjectBySlug, } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; import type { SentryProject } from "../../types/index.js"; type ViewFlags = { readonly json: boolean; readonly web: boolean; - readonly refresh: boolean; + readonly fresh: boolean; }; /** Usage hint for ContextError messages */ @@ -199,18 +202,16 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { f: "fresh", w: "web" }, }, async func( this: SentryContext, flags: ViewFlags, targetArg?: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd } = this; const parsed = parseOrgProjectArg(targetArg); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 16c1217f9..80afe7899 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -19,13 +19,13 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { + applyFreshFlag, buildListCommand, + FRESH_FLAG, LIST_CURSOR_FLAG, - REFRESH_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; type ListFlags = { readonly limit: number; @@ -33,7 +33,7 @@ type ListFlags = { readonly sort: "date" | "duration"; readonly json: boolean; readonly cursor?: string; - readonly refresh: boolean; + readonly fresh: boolean; }; type SortValue = "date" | "duration"; @@ -144,18 +144,16 @@ export const listCommand = buildListCommand("trace", { brief: "Output as JSON", default: false, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { n: "limit", q: "query", s: "sort", c: "cursor" }, + aliases: { f: "fresh", n: "limit", q: "query", s: "sort", c: "cursor" }, }, async func( this: SentryContext, flags: ListFlags, target?: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd, setContext } = this; // Resolve org/project from positional arg, config, or DSN auto-detection diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 4491d728b..49d5fa6a4 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -11,9 +11,8 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { displayTraceLogs } from "../../lib/formatters/index.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import { validateTraceId } from "../../lib/trace-id.js"; @@ -23,7 +22,7 @@ type LogsFlags = { readonly period: string; readonly limit: number; readonly query?: string; - readonly refresh: boolean; + readonly fresh: boolean; }; /** Maximum allowed value for --limit flag */ @@ -166,18 +165,16 @@ export const logsCommand = buildCommand({ brief: "Additional filter query (Sentry search syntax)", optional: true, }, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { w: "web", t: "period", n: "limit", q: "query" }, + aliases: { f: "fresh", w: "web", t: "period", n: "limit", q: "query" }, }, async func( this: SentryContext, flags: LogsFlags, ...args: string[] ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd, setContext } = this; const { traceId, orgArg } = parsePositionalArgs(args); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 3f762364f..b4c4e687d 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -23,13 +23,12 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { REFRESH_FLAG } from "../../lib/list-command.js"; +import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; -import { disableResponseCache } from "../../lib/response-cache.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import type { Writer } from "../../types/index.js"; @@ -37,7 +36,7 @@ type ViewFlags = { readonly json: boolean; readonly web: boolean; readonly spans: number; - readonly refresh: boolean; + readonly fresh: boolean; }; /** Usage hint for ContextError messages */ @@ -163,18 +162,16 @@ export const viewCommand = buildCommand({ default: false, }, ...spansFlag, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { f: "fresh", w: "web" }, }, async func( this: SentryContext, flags: ViewFlags, ...args: string[] ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd, setContext } = this; const log = logger.withTag("trace.view"); diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 9d141e6ae..6d98f3b93 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -85,15 +85,34 @@ export const LIST_JSON_FLAG = { } as const; /** - * The `--refresh` flag shared by read-only commands. + * The `--fresh` / `-f` flag shared by read-only commands. * Bypasses the response cache and fetches fresh data from the API. + * + * Add to any command's `flags` object, then call `applyFreshFlag(flags)` at + * the top of `func()` to activate cache bypass when the flag is set. */ -export const REFRESH_FLAG = { +export const FRESH_FLAG = { kind: "boolean" as const, brief: "Bypass cache and fetch fresh data", default: false, } as const; +/** + * Apply the `--fresh` flag: disables the response cache for this invocation. + * + * Call at the top of a command's `func()` after defining the `fresh` flag: + * ```ts + * flags: { fresh: FRESH_FLAG }, + * async func(this: SentryContext, flags) { + * applyFreshFlag(flags); + * ``` + */ +export function applyFreshFlag(flags: { readonly fresh: boolean }): void { + if (flags.fresh) { + disableResponseCache(); + } +} + /** Matches strings that are all digits — used to detect invalid cursor values */ const ALL_DIGITS_RE = /^\d+$/; @@ -357,9 +376,9 @@ export function buildOrgListCommand( limit: buildListLimitFlag(config.entityPlural), json: LIST_JSON_FLAG, cursor: LIST_CURSOR_FLAG, - refresh: REFRESH_FLAG, + fresh: FRESH_FLAG, }, - aliases: LIST_BASE_ALIASES, + aliases: { ...LIST_BASE_ALIASES, f: "fresh" }, }, async func( this: SentryContext, @@ -367,13 +386,11 @@ export function buildOrgListCommand( readonly limit: number; readonly json: boolean; readonly cursor?: string; - readonly refresh: boolean; + readonly fresh: boolean; }, target?: string ): Promise { - if (flags.refresh) { - disableResponseCache(); - } + applyFreshFlag(flags); const { stdout, cwd } = this; const parsed = parseOrgProjectArg(target); await dispatchOrgScopedList({ config, stdout, cwd, flags, parsed }); diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index 169c6219d..e37a2b316 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -63,8 +63,8 @@ const URL_TIER_PATTERNS: ReadonlyArray<{ pattern: RegExp; tier: TtlTier }> = [ { pattern: /\/events\/[^/?]+\/?(?:\?|$)/, tier: "immutable" }, { pattern: /\/trace\/[0-9a-f]{32}\//, tier: "immutable" }, - // Volatile: list endpoints with high churn - { pattern: /\/issues\/?\?/, tier: "volatile" }, + // Volatile: issue endpoints (lists AND detail views — status/assignee change often) + { pattern: /\/issues\//, tier: "volatile" }, { pattern: /\/issues\/?$/, tier: "volatile" }, { pattern: /[?&]dataset=logs/, tier: "volatile" }, { pattern: /[?&]dataset=transactions/, tier: "volatile" }, diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index f4d86e95d..1895b54a1 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -9,7 +9,7 @@ */ import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js"; -import { isEnvTokenActive, refreshToken } from "./db/auth.js"; +import { getAuthToken, isEnvTokenActive, refreshToken } from "./db/auth.js"; import { getCachedResponse, storeCachedResponse } from "./response-cache.js"; import { withHttpSpan } from "./telemetry.js"; @@ -216,24 +216,32 @@ function extractUrlPath(input: Request | string | URL): string { /** * Attempt to serve a GET request from the response cache. * Returns the cached Response if valid, or undefined on miss. + * + * @param requestHeaders - Headers that were (or will be) sent with the request, + * needed for correct `Vary` handling in CachePolicy freshness checks. */ async function tryCacheHit( method: string, - fullUrl: string + fullUrl: string, + requestHeaders: Record ): Promise { if (method !== "GET") { return; } - return await getCachedResponse(method, fullUrl, {}); + return await getCachedResponse(method, fullUrl, requestHeaders); } /** * Store a successful GET response in the cache (fire-and-forget). * Clones the response so the original body stream is preserved for the caller. + * + * @param requestHeaders - Headers sent with the request, stored in CachePolicy + * for future `Vary`-aware freshness checks. */ function cacheResponse( method: string, fullUrl: string, + requestHeaders: Record, response: Response ): void { if (method !== "GET" || !response.ok) { @@ -241,11 +249,53 @@ function cacheResponse( } // Cast needed: Bun extends Response with extra properties (toJSON, count, getAll) // that .clone() doesn't carry over, but our cache only reads standard Response API - storeCachedResponse(method, fullUrl, {}, response.clone() as Response).catch( - () => { - // Non-fatal: cache write failures don't affect the response + storeCachedResponse( + method, + fullUrl, + requestHeaders, + response.clone() as Response + ).catch(() => { + // Non-fatal: cache write failures don't affect the response + }); +} + +/** Build a `{ authorization }` header map from a bearer token, or `{}` if absent. */ +function authHeaders(token: string | undefined): Record { + return token ? { authorization: `Bearer ${token}` } : {}; +} + +/** + * Authenticate and execute a request with retry logic. + * + * Refreshes the auth token, then retries the request up to `MAX_RETRIES` times + * with exponential backoff on transient errors. + */ +async function fetchWithRetry( + input: Request | string | URL, + init: RequestInit | undefined, + method: string, + fullUrl: string +): Promise { + const { token } = await refreshToken(); + const headers = prepareHeaders(input, init, token); + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const isLastAttempt = attempt === MAX_RETRIES; + const result = await executeAttempt(input, init, headers, isLastAttempt); + + if (result.action === "done") { + cacheResponse(method, fullUrl, authHeaders(token), result.response); + return result.response; } - ); + if (result.action === "throw") { + throw result.error; + } + + await Bun.sleep(backoffDelay(attempt)); + } + + // Unreachable: the last attempt always returns 'done' or 'throw' + throw new Error("Exhausted all retry attempts"); } /** @@ -282,37 +332,18 @@ function createAuthenticatedFetch(): ( return withHttpSpan(method, urlPath, async () => { const fullUrl = extractFullUrl(input); - // Check cache before auth/retry for GET requests - const cached = await tryCacheHit(method, fullUrl); + // Check cache before auth/retry for GET requests. + // Uses current token (no refresh) so lookups are fast but Vary-correct. + const cached = await tryCacheHit( + method, + fullUrl, + authHeaders(getAuthToken()) + ); if (cached) { return cached; } - const { token } = await refreshToken(); - const headers = prepareHeaders(input, init, token); - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const isLastAttempt = attempt === MAX_RETRIES; - const result = await executeAttempt( - input, - init, - headers, - isLastAttempt - ); - - if (result.action === "done") { - cacheResponse(method, fullUrl, result.response); - return result.response; - } - if (result.action === "throw") { - throw result.error; - } - - await Bun.sleep(backoffDelay(attempt)); - } - - // Unreachable: the last attempt always returns 'done' or 'throw' - throw new Error("Exhausted all retry attempts"); + return await fetchWithRetry(input, init, method, fullUrl); }); }; } diff --git a/test/lib/response-cache.property.test.ts b/test/lib/response-cache.property.test.ts index 12ea720af..e98a4dfd3 100644 --- a/test/lib/response-cache.property.test.ts +++ b/test/lib/response-cache.property.test.ts @@ -195,10 +195,13 @@ describe("property: classifyUrl", () => { expect(classifyUrl(url)).toBe("immutable"); }); - test("issue list URLs are volatile", () => { + test("issue URLs are volatile (lists and detail views)", () => { const urls = [ "https://us.sentry.io/api/0/projects/org/proj/issues/", "https://us.sentry.io/api/0/projects/org/proj/issues/?query=is:unresolved", + "https://us.sentry.io/api/0/issues/12345/", + "https://sentry.io/api/0/issues/67890/?format=json", + "https://us.sentry.io/api/0/organizations/org/issues/12345/hashes/", ]; for (const url of urls) { expect(classifyUrl(url)).toBe("volatile"); From 1e42a359335561e582fb22aa22d9c87d04d314f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 10:31:55 +0000 Subject: [PATCH 04/12] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index ae9fd877d..f96e95178 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -89,7 +89,7 @@ View authentication status **Flags:** - `--show-token - Show the stored token (masked by default)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -107,7 +107,7 @@ Show the currently authenticated user **Flags:** - `--json - Output as JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Org @@ -120,7 +120,7 @@ List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "30")` - `--json - Output JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -137,7 +137,7 @@ View details of an organization **Flags:** - `--json - Output as JSON` - `-w, --web - Open in browser` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -170,7 +170,7 @@ List projects - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -192,7 +192,7 @@ View details of a project **Flags:** - `--json - Output as JSON` - `-w, --web - Open in browser` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -226,7 +226,7 @@ List issues in a project - `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -271,7 +271,7 @@ Analyze an issue's root cause using Seer AI **Flags:** - `--json - Output as JSON` - `--force - Force new analysis even if one exists` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -299,7 +299,7 @@ Generate a solution plan using Seer AI - `--cause - Root cause ID to plan (required if multiple causes exist)` - `--json - Output as JSON` - `--force - Force new plan even if one exists` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -327,7 +327,7 @@ View details of a specific issue - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -355,7 +355,7 @@ View details of a specific event - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -478,7 +478,7 @@ List repositories - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Team @@ -492,7 +492,7 @@ List teams - `-n, --limit - Maximum number of teams to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -523,7 +523,7 @@ List logs from a project - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` - `--trace - Filter logs by trace ID (32-character hex string)` - `--json - Output as JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `--fresh - Bypass cache and fetch fresh data` **Examples:** @@ -569,7 +569,7 @@ View details of one or more log entries **Flags:** - `--json - Output as JSON` - `-w, --web - Open in browser` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` **Examples:** @@ -606,7 +606,7 @@ List recent traces in a project - `-s, --sort - Sort by: date, duration - (default: "date")` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `--json - Output as JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` #### `sentry trace view ` @@ -616,7 +616,7 @@ View details of a specific trace - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` #### `sentry trace logs ` @@ -628,7 +628,7 @@ View logs associated with a trace - `-t, --period - Time period to search (e.g., "14d", "7d", "24h"). Default: 14d - (default: "14d")` - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Additional filter query (Sentry search syntax)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Issues @@ -645,7 +645,7 @@ List issues in a project - `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Orgs @@ -658,7 +658,7 @@ List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "30")` - `--json - Output JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Projects @@ -673,7 +673,7 @@ List projects - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Repos @@ -687,7 +687,7 @@ List repositories - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Teams @@ -701,7 +701,7 @@ List teams - `-n, --limit - Maximum number of teams to list - (default: "30")` - `--json - Output JSON` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Logs @@ -717,7 +717,7 @@ List logs from a project - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` - `--trace - Filter logs by trace ID (32-character hex string)` - `--json - Output as JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `--fresh - Bypass cache and fetch fresh data` ### Traces @@ -733,7 +733,7 @@ List recent traces in a project - `-s, --sort - Sort by: date, duration - (default: "date")` - `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `--json - Output as JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ### Whoami @@ -745,7 +745,7 @@ Show the currently authenticated user **Flags:** - `--json - Output as JSON` -- `--refresh - Bypass cache and fetch fresh data` +- `-f, --fresh - Bypass cache and fetch fresh data` ## Global Options From 2e137457ca6ea2ce775bc01c96ecfbd380715746 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 10:43:48 +0000 Subject: [PATCH 05/12] fix: respect expired server TTL and use current token for cache storage - Fix fallback TTL overriding explicitly expired server cache headers. Changed isEntryFresh() to use serverTtl !== 0 (catches both positive and negative values) instead of serverTtl > 0 (missed negative/expired). Same fix in collectEntryMetadata() with explicit < 0 branch. - Fix cache storing stale token after 401 refresh. Use getAuthToken() (reads current DB value) instead of the captured token variable when building cache request headers, so post-refresh tokens are stored. --- src/lib/response-cache.ts | 17 ++++++++++++----- src/lib/sentry-client.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index e37a2b316..d759cdc86 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -232,14 +232,16 @@ function isEntryFresh( return true; } - // CachePolicy says stale — check if we should override with fallback TTL + // CachePolicy says stale — check if we should override with fallback TTL. + // timeToLive() returns 0 when the server sent no cache headers, or a + // positive/negative value when it did (negative = already expired). const serverTtl = policy.timeToLive(); - if (serverTtl > 0) { - // Server provided a TTL and it expired — respect the server + if (serverTtl !== 0) { + // Server provided an explicit TTL — respect it (even if expired) return false; } - // No server TTL — use our fallback tier + // No server TTL (0) — use our URL-based fallback tier const tier = classifyUrl(url); const fallbackTtl = FALLBACK_TTL_MS[tier]; const age = Date.now() - entry.createdAt; @@ -516,12 +518,17 @@ async function collectEntryMetadata( const entry = JSON.parse(raw) as CacheEntry; const policy = CachePolicy.fromObject(entry.policy); + // timeToLive() returns 0 when the server sent no cache headers, + // positive when still fresh, negative when explicitly expired. const serverTtl = policy.timeToLive(); let expired: boolean; if (serverTtl > 0) { expired = false; + } else if (serverTtl < 0) { + // Server-provided TTL has expired — don't override with fallback + expired = true; } else { - // Use the entry's stored URL for accurate tier classification + // No server TTL (0) — use URL-based fallback tier const tier = classifyUrl(entry.url ?? ""); expired = now - entry.createdAt > FALLBACK_TTL_MS[tier]; } diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index 1895b54a1..90e7f259a 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -284,7 +284,14 @@ async function fetchWithRetry( const result = await executeAttempt(input, init, headers, isLastAttempt); if (result.action === "done") { - cacheResponse(method, fullUrl, authHeaders(token), result.response); + // Use getAuthToken() instead of captured `token` — after a 401 refresh, + // handleUnauthorized stores a new token in the DB + cacheResponse( + method, + fullUrl, + authHeaders(getAuthToken()), + result.response + ); return result.response; } if (result.action === "throw") { From cd3342e720353f0c1723a421278ad758e9368317 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 11:25:12 +0000 Subject: [PATCH 06/12] fix: make clearAuth async and fix unawaited asyncProperty tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make clearAuth() properly async so clearResponseCache() is awaited rather than fire-and-forget. This ensures the filesystem cache is fully removed before the process exits. Fix pre-existing bug in model-based tests: fcAssert(asyncProperty(...)) returns a Promise that was never awaited. When all commands were synchronous this was invisible, but with the async clearAuth() the yield point caused createIsolatedDbContext() cleanups to interleave with subsequent iterations, corrupting the SENTRY_CONFIG_DIR env var. Changes: - src/lib/db/auth.ts: clearAuth() → async, try/await/catch for clearResponseCache() (was .catch() fire-and-forget) - test/lib/db/model-based.test.ts: add async/await to all test functions using asyncProperty; await clearAuth() calls - test/lib/db/pagination.model-based.test.ts: same async/await fix - test/commands/project/list.test.ts: await clearAuth() calls, add required fresh:false to ListFlags objects --- src/lib/db/auth.ts | 11 ++++-- test/commands/project/list.test.ts | 46 +++++++++++++++++----- test/lib/db/model-based.test.ts | 30 +++++++------- test/lib/db/pagination.model-based.test.ts | 4 +- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/src/lib/db/auth.ts b/src/lib/db/auth.ts index ff2fdf53f..c0ad70860 100644 --- a/src/lib/db/auth.ts +++ b/src/lib/db/auth.ts @@ -154,7 +154,7 @@ export function setAuthToken( }); } -export function clearAuth(): void { +export async function clearAuth(): Promise { withDbSpan("clearAuth", () => { const db = getDatabase(); db.query("DELETE FROM auth WHERE id = 1").run(); @@ -164,10 +164,13 @@ export function clearAuth(): void { db.query("DELETE FROM pagination_cursors").run(); }); - // Clear cached API responses — they are tied to the current user's permissions - clearResponseCache().catch(() => { + // Clear cached API responses — they are tied to the current user's permissions. + // Awaited so cache is fully removed before the process exits. + try { + await clearResponseCache(); + } catch { // Non-fatal: cache directory may not exist yet - }); + } } export async function isAuthenticated(): Promise { diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index dc84db253..a9e46ec35 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -426,6 +426,7 @@ describe("handleExplicit", () => { await handleExplicit(writer, "test-org", "frontend", { limit: 30, json: false, + fresh: false, }); const text = output(); @@ -440,6 +441,7 @@ describe("handleExplicit", () => { await handleExplicit(writer, "test-org", "frontend", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -454,6 +456,7 @@ describe("handleExplicit", () => { await handleExplicit(writer, "test-org", "nonexistent", { limit: 30, json: false, + fresh: false, }); const text = output(); @@ -469,6 +472,7 @@ describe("handleExplicit", () => { await handleExplicit(writer, "test-org", "nonexistent", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -483,6 +487,7 @@ describe("handleExplicit", () => { limit: 30, json: false, platform: "ruby", + fresh: false, }); const text = output(); @@ -498,6 +503,7 @@ describe("handleExplicit", () => { limit: 30, json: false, platform: "javascript", + fresh: false, }); const text = output(); @@ -524,7 +530,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false }, + flags: { limit: 30, json: false, fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -546,7 +552,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: true }, + flags: { limit: 30, json: true, fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -564,7 +570,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: true }, + flags: { limit: 30, json: true, fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -584,7 +590,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false }, + flags: { limit: 30, json: false, fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -607,7 +613,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false }, + flags: { limit: 30, json: false, fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -626,7 +632,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false, platform: "rust" }, + flags: { limit: 30, json: false, platform: "rust", fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -644,7 +650,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false }, + flags: { limit: 30, json: false, fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -660,7 +666,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false, platform: "rust" }, + flags: { limit: 30, json: false, platform: "rust", fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -680,7 +686,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false }, + flags: { limit: 30, json: false, fresh: false }, contextKey: "type:org:test-org", cursor: undefined, }); @@ -701,7 +707,7 @@ describe("handleOrgAll", () => { await handleOrgAll({ stdout: writer, org: "test-org", - flags: { limit: 30, json: false, platform: "python" }, + flags: { limit: 30, json: false, platform: "python", fresh: false }, contextKey: "type:org:test-org:platform:python", cursor: undefined, }); @@ -730,6 +736,7 @@ describe("handleProjectSearch", () => { await handleProjectSearch(writer, "frontend", { limit: 30, json: false, + fresh: false, }); const text = output(); @@ -743,6 +750,7 @@ describe("handleProjectSearch", () => { await handleProjectSearch(writer, "frontend", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -779,6 +787,7 @@ describe("handleProjectSearch", () => { handleProjectSearch(writer, "nonexistent", { limit: 30, json: false, + fresh: false, }) ).rejects.toThrow(ContextError); }); @@ -811,6 +820,7 @@ describe("handleProjectSearch", () => { await handleProjectSearch(writer, "nonexistent", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -824,6 +834,7 @@ describe("handleProjectSearch", () => { await handleProjectSearch(writer, "frontend", { limit: 30, json: false, + fresh: false, }); const text = output(); @@ -838,6 +849,7 @@ describe("handleProjectSearch", () => { limit: 30, json: false, platform: "rust", + fresh: false, }); const text = output(); @@ -894,6 +906,7 @@ describe("handleProjectSearch", () => { await handleProjectSearch(writer, "frontend", { limit: 1, json: false, + fresh: false, }); const text = output(); @@ -950,6 +963,7 @@ describe("handleProjectSearch", () => { await handleProjectSearch(writer, "frontend", { limit: 1, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -1163,6 +1177,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: false, + fresh: false, }); const text = output(); @@ -1179,6 +1194,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -1194,6 +1210,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: false, + fresh: false, }); expect(output()).toContain("No projects found"); @@ -1209,6 +1226,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 2, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -1225,6 +1243,7 @@ describe("handleAutoDetect", () => { limit: 30, json: true, platform: "python", + fresh: false, }); const parsed = JSON.parse(output()); @@ -1243,6 +1262,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 2, json: false, + fresh: false, }); const text = output(); @@ -1259,6 +1279,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -1279,6 +1300,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: false, + fresh: false, }); const text = output(); @@ -1298,6 +1320,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -1325,6 +1348,7 @@ describe("handleAutoDetect", () => { await handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: true, + fresh: false, }); const parsed = JSON.parse(output()); @@ -1342,6 +1366,7 @@ describe("handleAutoDetect", () => { handleAutoDetect(writer, "/tmp/test-project", { limit: 30, json: true, + fresh: false, }) ).rejects.toThrow(AuthError); }); @@ -1356,6 +1381,7 @@ describe("handleAutoDetect", () => { limit: 30, json: true, platform: "python", + fresh: false, }); const parsed = JSON.parse(output()); diff --git a/test/lib/db/model-based.test.ts b/test/lib/db/model-based.test.ts index fc07b966a..3f34b8a2f 100644 --- a/test/lib/db/model-based.test.ts +++ b/test/lib/db/model-based.test.ts @@ -236,7 +236,7 @@ class ClearAuthCommand implements AsyncCommand { check = () => true; async run(model: DbModel, _real: RealDb): Promise { - clearAuth(); + await clearAuth(); // Clear auth state model.auth.token = null; @@ -760,8 +760,8 @@ const allCommands = [ // Tests describe("model-based: database layer", () => { - test("random sequences of database operations maintain consistency", () => { - fcAssert( + test("random sequences of database operations maintain consistency", async () => { + await fcAssert( asyncProperty(commands(allCommands, { size: "+1" }), async (cmds) => { const cleanup = createIsolatedDbContext(); // Save env vars so model commands that set them don't leak across runs @@ -798,8 +798,8 @@ describe("model-based: database layer", () => { ); }); - test("clearAuth also clears org regions (key invariant)", () => { - fcAssert( + test("clearAuth also clears org regions (key invariant)", async () => { + await fcAssert( asyncProperty( array(tuple(slugArb, regionUrlArb), { minLength: 1, maxLength: 5 }), async (entries) => { @@ -817,7 +817,7 @@ describe("model-based: database layer", () => { expect(regionsBefore.size).toBe(uniqueOrgSlugs.size); // Clear auth - clearAuth(); + await clearAuth(); // Verify regions were also cleared (this is the invariant!) const regionsAfter = await getAllOrgRegions(); @@ -831,8 +831,8 @@ describe("model-based: database layer", () => { ); }); - test("clearAuth also clears pagination cursors (key invariant)", () => { - fcAssert( + test("clearAuth also clears pagination cursors (key invariant)", async () => { + await fcAssert( asyncProperty(tuple(slugArb, slugArb), async ([commandKey, context]) => { const cleanup = createIsolatedDbContext(); try { @@ -850,7 +850,7 @@ describe("model-based: database layer", () => { expect(before).toBe("1735689600000:100:0"); // Clear auth - clearAuth(); + await clearAuth(); // Verify pagination cursor was also cleared (this is the invariant!) const after = getPaginationCursor(commandKey, context); @@ -863,8 +863,8 @@ describe("model-based: database layer", () => { ); }); - test("alias lookup is case-insensitive", () => { - fcAssert( + test("alias lookup is case-insensitive", async () => { + await fcAssert( asyncProperty( tuple(aliasArb, slugArb, slugArb), async ([alias, org, project]) => { @@ -919,7 +919,7 @@ describe("model-based: database layer", () => { ); }); - test("fingerprint mismatch rejects alias lookup", () => { + test("fingerprint mismatch rejects alias lookup", async () => { // Combine all parameters into a single tuple to avoid parameter limit const paramsArb = tuple( aliasArb, @@ -931,7 +931,7 @@ describe("model-based: database layer", () => { nat(1000) ); - fcAssert( + await fcAssert( asyncProperty(paramsArb, async ([alias, org, project, a, b, c, d]) => { // Ensure fingerprints are different const fp1 = `${a}:${b}`; @@ -961,13 +961,13 @@ describe("model-based: database layer", () => { ); }); - test("setProjectAliases replaces all existing aliases", () => { + test("setProjectAliases replaces all existing aliases", async () => { const aliasEntryArb = array(tuple(aliasArb, slugArb, slugArb), { minLength: 1, maxLength: 3, }); - fcAssert( + await fcAssert( asyncProperty( tuple(aliasEntryArb, aliasEntryArb), async ([first, second]) => { diff --git a/test/lib/db/pagination.model-based.test.ts b/test/lib/db/pagination.model-based.test.ts index e43519b1f..df37d55cc 100644 --- a/test/lib/db/pagination.model-based.test.ts +++ b/test/lib/db/pagination.model-based.test.ts @@ -192,8 +192,8 @@ const allCommands = [setCmdArb, getCmdArb, clearCmdArb]; // Tests describe("model-based: pagination cursor storage", () => { - test("random sequences of pagination operations maintain consistency", () => { - fcAssert( + test("random sequences of pagination operations maintain consistency", async () => { + await fcAssert( asyncProperty(commands(allCommands, { size: "+1" }), async (cmds) => { const cleanup = createIsolatedDbContext(); try { From 7c8dd631088d8af5c1f15c221b60d91ac6c7c37f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 12:01:29 +0000 Subject: [PATCH 07/12] fix: harden cache policy deserialization and await clearAuth in tests BugBot #9: Wrap CachePolicy.fromObject() and related calls in try-catch inside getCachedResponse(). A corrupted or version-incompatible policy object now triggers a cache miss (and best-effort cleanup of the broken entry) instead of crashing the API request. BugBot #10: Add missing `await` to clearAuth() calls in project/list tests at lines 1080 and 1362 to prevent floating promises. --- src/lib/response-cache.ts | 25 +++++++++++++++++-------- test/commands/project/list.test.ts | 4 ++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index d759cdc86..ab5c8978f 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -336,16 +336,25 @@ export async function getCachedResponse( return; } - const policy = CachePolicy.fromObject(entry.policy); - if (!isEntryFresh(policy, entry, requestHeaders, url)) { + try { + const policy = CachePolicy.fromObject(entry.policy); + if (!isEntryFresh(policy, entry, requestHeaders, url)) { + return; + } + + const responseHeaders = buildResponseHeaders(policy, entry); + return new Response(JSON.stringify(entry.body), { + status: entry.status, + headers: responseHeaders, + }); + } catch { + // Corrupted or version-incompatible policy object — treat as cache miss. + // Delete the broken entry so it doesn't keep failing on every request. + await unlink(cacheFilePath(key)).catch(() => { + // Best-effort cleanup + }); return; } - - const responseHeaders = buildResponseHeaders(policy, entry); - return new Response(JSON.stringify(entry.body), { - status: entry.status, - headers: responseHeaders, - }); } /** diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index a9e46ec35..88f22e1b3 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -1077,7 +1077,7 @@ describe("fetchOrgProjectsSafe", () => { test("propagates AuthError when not authenticated", async () => { // Clear auth token so the API client throws AuthError before making any request - clearAuth(); + await clearAuth(); await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); }); @@ -1359,7 +1359,7 @@ describe("handleAutoDetect", () => { test("fast path: AuthError still propagates", async () => { await setDefaults("test-org"); // Clear auth so getAuthToken() throws AuthError before any fetch - clearAuth(); + await clearAuth(); const { writer } = createCapture(); await expect( From f96788172014e926e0468e25b226529533d93664 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 13:14:49 +0000 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20refactor=20cache,=20export=20FRESH=5FALIASES,=20par?= =?UTF-8?q?allelize=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review Round 3 — 15 human review comments addressed: to reduce boilerplate; update 15 command files to use it never change once created) combine duplicate regex patterns into single alternations ASCII URL query param sorting cache already came from a fetch, always valid) instead of hardcoded magic number manual forEach loop catch block checks during cleanup (no CachePolicy deserialization needed) deleteExpiredEntries, evictExcessEntries) using p-limit-style concurrency limiter (max 8 concurrent) --- src/commands/auth/login.ts | 12 ++- src/commands/auth/status.ts | 8 +- src/commands/auth/whoami.ts | 8 +- src/commands/event/view.ts | 8 +- src/commands/issue/explain.ts | 8 +- src/commands/issue/list.ts | 3 +- src/commands/issue/plan.ts | 8 +- src/commands/issue/view.ts | 8 +- src/commands/log/view.ts | 8 +- src/commands/org/list.ts | 3 +- src/commands/org/view.ts | 8 +- src/commands/project/list.ts | 3 +- src/commands/project/view.ts | 3 +- src/commands/trace/list.ts | 9 +- src/commands/trace/logs.ts | 14 ++- src/commands/trace/view.ts | 8 +- src/lib/list-command.ts | 27 ++++- src/lib/response-cache.ts | 182 ++++++++++++++++++++-------------- 18 files changed, 222 insertions(+), 106 deletions(-) diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 404f10d2d..0ab4d9f22 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -68,9 +68,11 @@ export const loginCommand = buildCommand({ // Token-based authentication if (flags.token) { // Clear stale cached responses from a previous session - await clearResponseCache().catch(() => { + try { + await clearResponseCache(); + } catch { // Non-fatal: cache directory may not exist - }); + } // Save token first, then validate by fetching user regions await setAuthToken(flags.token); @@ -111,9 +113,11 @@ export const loginCommand = buildCommand({ } // Clear stale cached responses from a previous session - await clearResponseCache().catch(() => { + try { + await clearResponseCache(); + } catch { // Non-fatal: cache directory may not exist - }); + } // Device Flow OAuth const loginSuccess = await runInteractiveLogin( diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index b0af86e35..8fd76bf60 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -27,7 +27,11 @@ import { formatUserIdentity, maskToken, } from "../../lib/formatters/human.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import type { Writer } from "../../types/index.js"; type StatusFlags = { @@ -152,7 +156,7 @@ export const statusCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh" }, + aliases: FRESH_ALIASES, }, async func(this: SentryContext, flags: StatusFlags): Promise { applyFreshFlag(flags); diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index db4b1fcd1..b7454e73e 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -13,7 +13,11 @@ import { isAuthenticated } from "../../lib/db/auth.js"; import { setUserInfo } from "../../lib/db/user.js"; import { AuthError } from "../../lib/errors.js"; import { formatUserIdentity, writeJson } from "../../lib/formatters/index.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; type WhoamiFlags = { readonly json: boolean; @@ -37,7 +41,7 @@ export const whoamiCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh" }, + aliases: FRESH_ALIASES, }, async func(this: SentryContext, flags: WhoamiFlags): Promise { applyFreshFlag(flags); diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 442120e87..360d01a15 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,7 +23,11 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; import { @@ -322,7 +326,7 @@ export const viewCommand = buildCommand({ ...spansFlag, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, async func( this: SentryContext, diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index e657becde..5145168e2 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -12,7 +12,11 @@ import { formatRootCauseList, handleSeerApiError, } from "../../lib/formatters/seer.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { extractRootCauses } from "../../types/seer.js"; import { ensureRootCauseAnalysis, @@ -65,7 +69,7 @@ export const explainCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh" }, + aliases: FRESH_ALIASES, }, async func( this: SentryContext, diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index a9f32b794..8a14151e5 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -46,6 +46,7 @@ import { applyFreshFlag, buildListCommand, buildListLimitFlag, + FRESH_ALIASES, FRESH_FLAG, LIST_BASE_ALIASES, LIST_JSON_FLAG, @@ -1200,7 +1201,7 @@ export const listCommand = buildListCommand("issue", { }, aliases: { ...LIST_BASE_ALIASES, - f: "fresh", + ...FRESH_ALIASES, q: "query", s: "sort", t: "period", diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 1132390c0..91a82f7e4 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -15,7 +15,11 @@ import { formatSolution, handleSeerApiError, } from "../../lib/formatters/seer.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import type { Writer } from "../../types/index.js"; import { type AutofixState, @@ -178,7 +182,7 @@ export const planCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh" }, + aliases: FRESH_ALIASES, }, async func( this: SentryContext, diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index eaf02687c..a7ded81fa 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -16,7 +16,11 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, SentryIssue, Writer } from "../../types/index.js"; import { issueIdPositional, resolveIssue } from "./utils.js"; @@ -104,7 +108,7 @@ export const viewCommand = buildCommand({ ...spansFlag, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, async func( this: SentryContext, diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index c21ff2554..31ab4b247 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -18,7 +18,11 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; import { validateHexId } from "../../lib/hex-id.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject, @@ -337,7 +341,7 @@ export const viewCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, async func( this: SentryContext, diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 08693cc3f..f8600e9c2 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -15,6 +15,7 @@ import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, buildListLimitFlag, + FRESH_ALIASES, FRESH_FLAG, LIST_JSON_FLAG, } from "../../lib/list-command.js"; @@ -78,7 +79,7 @@ export const listCommand = buildCommand({ fresh: FRESH_FLAG, }, // Only -n for --limit; no -c since org list has no --cursor flag - aliases: { f: "fresh", n: "limit" }, + aliases: { ...FRESH_ALIASES, n: "limit" }, }, async func(this: SentryContext, flags: ListFlags): Promise { applyFreshFlag(flags); diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index 141f1fa87..d51b5898b 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -10,7 +10,11 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails, writeOutput } from "../../lib/formatters/index.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { buildOrgUrl } from "../../lib/sentry-urls.js"; @@ -55,7 +59,7 @@ export const viewCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, async func( this: SentryContext, diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index c03582b00..10ffbd625 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -38,6 +38,7 @@ import { applyFreshFlag, buildListCommand, buildListLimitFlag, + FRESH_ALIASES, FRESH_FLAG, LIST_BASE_ALIASES, LIST_CURSOR_FLAG, @@ -588,7 +589,7 @@ export const listCommand = buildListCommand("project", { }, fresh: FRESH_FLAG, }, - aliases: { ...LIST_BASE_ALIASES, f: "fresh", p: "platform" }, + aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES, p: "platform" }, }, async func( this: SentryContext, diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 8f1701be0..82e856276 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -22,6 +22,7 @@ import { } from "../../lib/formatters/index.js"; import { applyFreshFlag, + FRESH_ALIASES, FRESH_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; @@ -204,7 +205,7 @@ export const viewCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, async func( this: SentryContext, diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 80afe7899..75c39b6a8 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -21,6 +21,7 @@ import { import { applyFreshFlag, buildListCommand, + FRESH_ALIASES, FRESH_FLAG, LIST_CURSOR_FLAG, TARGET_PATTERN_NOTE, @@ -146,7 +147,13 @@ export const listCommand = buildListCommand("trace", { }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", n: "limit", q: "query", s: "sort", c: "cursor" }, + aliases: { + ...FRESH_ALIASES, + n: "limit", + q: "query", + s: "sort", + c: "cursor", + }, }, async func( this: SentryContext, diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 49d5fa6a4..ce25126f3 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -11,7 +11,11 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { displayTraceLogs } from "../../lib/formatters/index.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import { validateTraceId } from "../../lib/trace-id.js"; @@ -167,7 +171,13 @@ export const logsCommand = buildCommand({ }, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", w: "web", t: "period", n: "limit", q: "query" }, + aliases: { + ...FRESH_ALIASES, + w: "web", + t: "period", + n: "limit", + q: "query", + }, }, async func( this: SentryContext, diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index b4c4e687d..97ab084c0 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -23,7 +23,11 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { applyFreshFlag, FRESH_FLAG } from "../../lib/list-command.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject, @@ -164,7 +168,7 @@ export const viewCommand = buildCommand({ ...spansFlag, fresh: FRESH_FLAG, }, - aliases: { f: "fresh", w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, async func( this: SentryContext, diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 6d98f3b93..a41282807 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -90,6 +90,18 @@ export const LIST_JSON_FLAG = { * * Add to any command's `flags` object, then call `applyFreshFlag(flags)` at * the top of `func()` to activate cache bypass when the flag is set. + * + * @example + * ```ts + * import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG } from "../lib/list-command.js"; + * + * // In parameters: + * flags: { ..., fresh: FRESH_FLAG }, + * aliases: { ...FRESH_ALIASES }, + * + * // In func(): + * applyFreshFlag(flags); + * ``` */ export const FRESH_FLAG = { kind: "boolean" as const, @@ -97,6 +109,19 @@ export const FRESH_FLAG = { default: false, } as const; +/** + * Alias map for the `--fresh` flag: `-f` → `--fresh`. + * + * Spread into a command's `aliases` alongside other aliases: + * ```ts + * aliases: { ...FRESH_ALIASES, w: "web" } + * ``` + * + * **Note**: Commands that use `-f` for a different flag (e.g. `log list` + * uses `-f` for `--follow`) should NOT spread this constant. + */ +export const FRESH_ALIASES = { f: "fresh" } as const; + /** * Apply the `--fresh` flag: disables the response cache for this invocation. * @@ -378,7 +403,7 @@ export function buildOrgListCommand( cursor: LIST_CURSOR_FLAG, fresh: FRESH_FLAG, }, - aliases: { ...LIST_BASE_ALIASES, f: "fresh" }, + aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES }, }, async func( this: SentryContext, diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index ab5c8978f..b5e08b136 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -25,6 +25,7 @@ import { } from "node:fs/promises"; import { join } from "node:path"; import CachePolicy from "http-cache-semantics"; +import pLimit from "p-limit"; import { getConfigDir } from "./db/index.js"; @@ -44,33 +45,38 @@ type TtlTier = "immutable" | "stable" | "volatile" | "no-cache"; /** Fallback TTL durations by tier (milliseconds). `no-cache` uses 0 as a sentinel. */ const FALLBACK_TTL_MS: Record = { - immutable: 60 * 60 * 1000, // 1 hour + immutable: 24 * 60 * 60 * 1000, // 24 hours — events and traces never change stable: 5 * 60 * 1000, // 5 minutes volatile: 60 * 1000, // 60 seconds "no-cache": 0, }; /** - * URL patterns → TTL tier (checked in order, first match wins). - * Patterns match against the full URL string. + * URL patterns grouped by TTL tier. + * + * Checked in tier priority order (no-cache → immutable → volatile). + * "stable" has no patterns — it is the default fallback when nothing else matches. */ -const URL_TIER_PATTERNS: ReadonlyArray<{ pattern: RegExp; tier: TtlTier }> = [ - // No-cache: polling endpoints where state changes rapidly - { pattern: /\/autofix\//, tier: "no-cache" }, - { pattern: /\/root-cause\//, tier: "no-cache" }, - - // Immutable: specific resources by ID (events, traces) - { pattern: /\/events\/[^/?]+\/?(?:\?|$)/, tier: "immutable" }, - { pattern: /\/trace\/[0-9a-f]{32}\//, tier: "immutable" }, - - // Volatile: issue endpoints (lists AND detail views — status/assignee change often) - { pattern: /\/issues\//, tier: "volatile" }, - { pattern: /\/issues\/?$/, tier: "volatile" }, - { pattern: /[?&]dataset=logs/, tier: "volatile" }, - { pattern: /[?&]dataset=transactions/, tier: "volatile" }, - { pattern: /\/trace-logs\//, tier: "volatile" }, - - // Everything else falls through to "stable" (default) +const URL_TIER_REGEXPS: Readonly> = { + // Polling endpoints where state changes rapidly + "no-cache": [/\/(?:autofix|root-cause)\//], + // Specific resources by ID (events, traces) — never change once created + immutable: [/\/events\/[^/?]+\/?(?:\?|$)/, /\/trace\/[0-9a-f]{32}\//], + // Issue endpoints (lists AND detail views), dataset queries, trace-logs + volatile: [ + /\/issues\//, + /[?&]dataset=(?:logs|transactions)/, + /\/trace-logs\//, + ], + // Default fallback — no patterns needed + stable: [], +}; + +/** Tier check order — stable is the default and has no patterns to check. */ +const TIER_CHECK_ORDER: readonly TtlTier[] = [ + "no-cache", + "immutable", + "volatile", ]; /** @@ -81,9 +87,11 @@ const URL_TIER_PATTERNS: ReadonlyArray<{ pattern: RegExp; tier: TtlTier }> = [ * @internal Exported for testing */ export function classifyUrl(url: string): TtlTier { - for (const { pattern, tier } of URL_TIER_PATTERNS) { - if (pattern.test(url)) { - return tier; + for (const tier of TIER_CHECK_ORDER) { + for (const pattern of URL_TIER_REGEXPS[tier]) { + if (pattern.test(url)) { + return tier; + } } } return "stable"; @@ -117,19 +125,20 @@ export function buildCacheKey(method: string, url: string): string { * @internal Exported for testing */ export function normalizeUrl(method: string, url: string): string { - try { - const parsed = new URL(url); - const sortedParams = new URLSearchParams( - [...parsed.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)) - ); - parsed.search = sortedParams.toString() - ? `?${sortedParams.toString()}` - : ""; - return `${method.toUpperCase()}|${parsed.toString()}`; - } catch { - // Malformed URL — use as-is - return `${method.toUpperCase()}|${url}`; - } + const parsed = new URL(url); + const sortedParams = new URLSearchParams( + [...parsed.searchParams.entries()].sort(([a], [b]) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }) + ); + parsed.search = sortedParams.toString() ? `?${sortedParams.toString()}` : ""; + return `${method.toUpperCase()}|${parsed.toString()}`; } // --------------------------------------------------------------------------- @@ -150,13 +159,19 @@ type CacheEntry = { url: string; /** When this entry was created (epoch ms) */ createdAt: number; + /** + * Pre-computed expiry timestamp (epoch ms). + * Allows cleanup to check freshness without deserializing CachePolicy. + * Optional for backwards compatibility with entries written before this field. + */ + expiresAt?: number; }; /** CachePolicy options for a single-user CLI cache */ const POLICY_OPTIONS: CachePolicy.Options = { shared: false, cacheHeuristic: 0.1, - immutableMinTimeToLive: 3_600_000, + immutableMinTimeToLive: FALLBACK_TTL_MS.immutable, }; /** Maximum number of cache files to retain */ @@ -208,11 +223,7 @@ function pickHeaders(headers: Headers): Record { /** Convert Headers to a plain object for http-cache-semantics */ function headersToObject(headers: Headers): Record { - const obj: Record = {}; - headers.forEach((value, key) => { - obj[key] = value; - }); - return obj; + return Object.fromEntries(headers.entries()); } /** @@ -349,9 +360,9 @@ export async function getCachedResponse( }); } catch { // Corrupted or version-incompatible policy object — treat as cache miss. - // Delete the broken entry so it doesn't keep failing on every request. - await unlink(cacheFilePath(key)).catch(() => { - // Best-effort cleanup + // Best-effort cleanup of the broken entry. + unlink(cacheFilePath(key)).catch(() => { + // Ignored — fire-and-forget }); return; } @@ -440,6 +451,12 @@ async function writeResponseToCache( const body: unknown = await response.json(); const key = buildCacheKey(method, url); + const now = Date.now(); + + // Pre-compute expiry for cheap cleanup checks (avoids CachePolicy deserialization) + const serverTtl = policy.timeToLive(); + const fallbackTtl = FALLBACK_TTL_MS[classifyUrl(url)]; + const ttl = serverTtl > 0 ? serverTtl : fallbackTtl; const entry: CacheEntry = { policy: policy.toObject(), @@ -447,7 +464,8 @@ async function writeResponseToCache( status: response.status, headers: pickHeaders(response.headers), url, - createdAt: Date.now(), + createdAt: now, + expiresAt: now + ttl, }; await mkdir(getCacheDir(), { recursive: true, mode: 0o700 }); @@ -473,6 +491,25 @@ export async function clearResponseCache(): Promise { } } +// --------------------------------------------------------------------------- +// Concurrency helper +// --------------------------------------------------------------------------- + +/** Concurrency limit for parallel cache file I/O operations */ +const CACHE_IO_CONCURRENCY = 8; + +/** + * Run an async function over items with bounded concurrency. + * Uses p-limit to prevent overwhelming the filesystem with simultaneous reads. + */ +async function parallel( + items: readonly T[], + fn: (item: T) => Promise +): Promise { + const limit = pLimit(CACHE_IO_CONCURRENCY); + await Promise.all(items.map((item) => limit(() => fn(item)))); +} + // --------------------------------------------------------------------------- // Cache cleanup // --------------------------------------------------------------------------- @@ -512,7 +549,13 @@ async function cleanupCache(): Promise { /** Metadata for a cache entry, used for cleanup decisions */ type EntryMetadata = { file: string; createdAt: number; expired: boolean }; -/** Read all cache files and determine which are expired */ +/** + * Read all cache files and determine which are expired. + * + * Uses the pre-computed `expiresAt` field when available (cheap — no + * CachePolicy deserialization). Falls back to URL-based TTL classification + * for entries written before `expiresAt` was added. + */ async function collectEntryMetadata( cacheDir: string, jsonFiles: string[] @@ -520,36 +563,24 @@ async function collectEntryMetadata( const entries: EntryMetadata[] = []; const now = Date.now(); - for (const file of jsonFiles) { + await parallel(jsonFiles, async (file) => { const filePath = join(cacheDir, file); try { const raw = await readFile(filePath, "utf-8"); const entry = JSON.parse(raw) as CacheEntry; - const policy = CachePolicy.fromObject(entry.policy); - - // timeToLive() returns 0 when the server sent no cache headers, - // positive when still fresh, negative when explicitly expired. - const serverTtl = policy.timeToLive(); - let expired: boolean; - if (serverTtl > 0) { - expired = false; - } else if (serverTtl < 0) { - // Server-provided TTL has expired — don't override with fallback - expired = true; - } else { - // No server TTL (0) — use URL-based fallback tier - const tier = classifyUrl(entry.url ?? ""); - expired = now - entry.createdAt > FALLBACK_TTL_MS[tier]; - } - + const expired = + entry.expiresAt !== undefined + ? now >= entry.expiresAt + : now - entry.createdAt > + FALLBACK_TTL_MS[classifyUrl(entry.url ?? "")]; entries.push({ file, createdAt: entry.createdAt, expired }); } catch { // Unparseable file — delete it - await unlink(filePath).catch(() => { + unlink(filePath).catch(() => { // Best-effort cleanup of corrupted file }); } - } + }); return entries; } @@ -559,13 +590,12 @@ async function deleteExpiredEntries( cacheDir: string, entries: EntryMetadata[] ): Promise { - for (const entry of entries) { - if (entry.expired) { - await unlink(join(cacheDir, entry.file)).catch(() => { - // Best-effort: file may have been deleted by another process - }); - } - } + const expired = entries.filter((e) => e.expired); + await parallel(expired, async (entry) => { + await unlink(join(cacheDir, entry.file)).catch(() => { + // Best-effort: file may have been deleted by another process + }); + }); } /** Evict the oldest entries when over the max count */ @@ -580,9 +610,9 @@ async function evictExcessEntries( remaining.sort((a, b) => a.createdAt - b.createdAt); const toEvict = remaining.slice(0, remaining.length - MAX_CACHE_ENTRIES); - for (const entry of toEvict) { + await parallel(toEvict, async (entry) => { await unlink(join(cacheDir, entry.file)).catch(() => { // Best-effort eviction }); - } + }); } From 7fd1c6ecf94bb2eebaa7e69e6c73b3a6ecadf94b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 13:55:56 +0000 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20address=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20simplify=20parallel(),=20add=20Sentry=20cache=20spa?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review comments addressed: has f: 'follow' and it doesn't use FRESH_ALIASES not shared (RFC heuristic vs probabilistic cleanup trigger) built-in .map() method (cacheIO.map(items, fn)) at all 3 call sites Promise.all() since they operate on disjoint file sets cache.lookup and cache.store spans in response-cache.ts with URL attrs --- src/lib/response-cache.ts | 102 +++++++++++++++++++------------------- src/lib/telemetry.ts | 19 +++++++ 2 files changed, 71 insertions(+), 50 deletions(-) diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index b5e08b136..0f8598360 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -28,6 +28,7 @@ import CachePolicy from "http-cache-semantics"; import pLimit from "p-limit"; import { getConfigDir } from "./db/index.js"; +import { withCacheSpan } from "./telemetry.js"; // --------------------------------------------------------------------------- // TTL tiers — used as fallback when the server sends no cache headers @@ -341,31 +342,37 @@ export async function getCachedResponse( return; } - const key = buildCacheKey(method, url); - const entry = await readCacheEntry(key); - if (!entry) { - return; - } - - try { - const policy = CachePolicy.fromObject(entry.policy); - if (!isEntryFresh(policy, entry, requestHeaders, url)) { - return; - } + return await withCacheSpan( + "cache.lookup", + async () => { + const key = buildCacheKey(method, url); + const entry = await readCacheEntry(key); + if (!entry) { + return; + } - const responseHeaders = buildResponseHeaders(policy, entry); - return new Response(JSON.stringify(entry.body), { - status: entry.status, - headers: responseHeaders, - }); - } catch { - // Corrupted or version-incompatible policy object — treat as cache miss. - // Best-effort cleanup of the broken entry. - unlink(cacheFilePath(key)).catch(() => { - // Ignored — fire-and-forget - }); - return; - } + try { + const policy = CachePolicy.fromObject(entry.policy); + if (!isEntryFresh(policy, entry, requestHeaders, url)) { + return; + } + + const responseHeaders = buildResponseHeaders(policy, entry); + return new Response(JSON.stringify(entry.body), { + status: entry.status, + headers: responseHeaders, + }); + } catch { + // Corrupted or version-incompatible policy object — treat as cache miss. + // Best-effort cleanup of the broken entry. + unlink(cacheFilePath(key)).catch(() => { + // Ignored — fire-and-forget + }); + return; + } + }, + { "cache.url": url } + ); } /** @@ -424,7 +431,11 @@ export async function storeCachedResponse( } try { - await writeResponseToCache(method, url, requestHeaders, response); + await withCacheSpan( + "cache.store", + () => writeResponseToCache(method, url, requestHeaders, response), + { "cache.url": url } + ); } catch { // Cache write failures are non-fatal — silently ignore } @@ -498,17 +509,8 @@ export async function clearResponseCache(): Promise { /** Concurrency limit for parallel cache file I/O operations */ const CACHE_IO_CONCURRENCY = 8; -/** - * Run an async function over items with bounded concurrency. - * Uses p-limit to prevent overwhelming the filesystem with simultaneous reads. - */ -async function parallel( - items: readonly T[], - fn: (item: T) => Promise -): Promise { - const limit = pLimit(CACHE_IO_CONCURRENCY); - await Promise.all(items.map((item) => limit(() => fn(item)))); -} +/** Shared concurrency limiter for all cache I/O — created once, reused across calls */ +const cacheIO = pLimit(CACHE_IO_CONCURRENCY); // --------------------------------------------------------------------------- // Cache cleanup @@ -539,11 +541,11 @@ async function cleanupCache(): Promise { const entries = await collectEntryMetadata(cacheDir, jsonFiles); - // Delete expired entries - await deleteExpiredEntries(cacheDir, entries); - - // Enforce max entry count — evict oldest first - await evictExcessEntries(cacheDir, entries); + // Both operations are best-effort — run them in parallel without blocking + await Promise.all([ + deleteExpiredEntries(cacheDir, entries), + evictExcessEntries(cacheDir, entries), + ]); } /** Metadata for a cache entry, used for cleanup decisions */ @@ -563,7 +565,7 @@ async function collectEntryMetadata( const entries: EntryMetadata[] = []; const now = Date.now(); - await parallel(jsonFiles, async (file) => { + await cacheIO.map(jsonFiles, async (file) => { const filePath = join(cacheDir, file); try { const raw = await readFile(filePath, "utf-8"); @@ -591,11 +593,11 @@ async function deleteExpiredEntries( entries: EntryMetadata[] ): Promise { const expired = entries.filter((e) => e.expired); - await parallel(expired, async (entry) => { - await unlink(join(cacheDir, entry.file)).catch(() => { + await cacheIO.map(expired, (entry) => + unlink(join(cacheDir, entry.file)).catch(() => { // Best-effort: file may have been deleted by another process - }); - }); + }) + ); } /** Evict the oldest entries when over the max count */ @@ -610,9 +612,9 @@ async function evictExcessEntries( remaining.sort((a, b) => a.createdAt - b.createdAt); const toEvict = remaining.slice(0, remaining.length - MAX_CACHE_ENTRIES); - await parallel(toEvict, async (entry) => { - await unlink(join(cacheDir, entry.file)).catch(() => { + await cacheIO.map(toEvict, (entry) => + unlink(join(cacheDir, entry.file)).catch(() => { // Best-effort eviction - }); - }); + }) + ); } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 14c36350f..9390aab10 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -950,3 +950,22 @@ export function withFsSpan( ): Promise { return withTracing(operation, "file", fn); } + +/** + * Wrap a cache operation with a span for tracing. + * + * Creates a child span under the current active span to track + * response cache hit/miss/store operations. + * + * @param operation - Name of the operation (e.g., "cache.lookup", "cache.store") + * @param fn - The function that performs the cache operation + * @param attributes - Optional span attributes (e.g., url, cache.hit) + * @returns The result of the function + */ +export function withCacheSpan( + operation: string, + fn: () => T | Promise, + attributes?: Record +): Promise { + return withTracing(operation, "cache", fn, attributes); +} From ac56e1e5fcfb38c67ba106cbdc2ff7a22ff5bddd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 14:59:13 +0000 Subject: [PATCH 10/12] fix: add resetCacheState() for test isolation of cache disable flag --- src/lib/response-cache.ts | 15 ++++++++++++++- test/lib/response-cache.test.ts | 3 +++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index 0f8598360..234509022 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -297,12 +297,25 @@ let cacheDisabledFlag = false; /** * Disable the response cache for the current process. - * Called when `--refresh` flag is passed to a command. + * Called when `--fresh` flag is passed to a command. */ export function disableResponseCache(): void { cacheDisabledFlag = true; } +/** + * Re-enable the response cache after `disableResponseCache()` was called. + * + * This is only needed in tests to prevent one test's `--fresh` flag from + * permanently disabling caching for subsequent tests in the same process. + * Production CLI invocations are single-process, so the flag resets naturally. + * + * @internal Exported for testing + */ +export function resetCacheState(): void { + cacheDisabledFlag = false; +} + /** * Check if response caching is disabled. * Cache is disabled when: diff --git a/test/lib/response-cache.test.ts b/test/lib/response-cache.test.ts index 6ae99ff04..b25f6ea1f 100644 --- a/test/lib/response-cache.test.ts +++ b/test/lib/response-cache.test.ts @@ -12,6 +12,7 @@ import { buildCacheKey, clearResponseCache, getCachedResponse, + resetCacheState, storeCachedResponse, } from "../../src/lib/response-cache.js"; import { useTestConfigDir } from "../helpers.js"; @@ -24,6 +25,7 @@ let savedNoCache: string | undefined; beforeEach(() => { savedNoCache = process.env.SENTRY_NO_CACHE; delete process.env.SENTRY_NO_CACHE; + resetCacheState(); }); afterEach(() => { @@ -32,6 +34,7 @@ afterEach(() => { } else { delete process.env.SENTRY_NO_CACHE; } + resetCacheState(); }); // --------------------------------------------------------------------------- From 15d70b96fcf75e3d640cf4d4b29e38e730619e47 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Mar 2026 15:12:53 +0000 Subject: [PATCH 11/12] fix: distinguish max-age=0 from missing cache headers via rescc check --- src/lib/response-cache.ts | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index 234509022..4013e77bd 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -227,6 +227,21 @@ function headersToObject(headers: Headers): Record { return Object.fromEntries(headers.entries()); } +/** + * Check whether the server sent explicit cache-control directives. + * + * When `rescc` (response cache-control) is empty, the server sent no + * Cache-Control header. When it has keys, the server explicitly provided + * directives (e.g., `max-age=0`, `no-cache`, `max-age=300`). + * + * This distinction is critical: `timeToLive() === 0` is ambiguous — it can + * mean "no headers" (use fallback TTL) or "max-age=0" (don't cache). + */ +function hasServerCacheDirectives(policy: CachePolicy): boolean { + const { rescc } = policy.toObject(); + return Object.keys(rescc).length > 0; +} + /** * Check whether a cache entry is still fresh. * @@ -244,16 +259,13 @@ function isEntryFresh( return true; } - // CachePolicy says stale — check if we should override with fallback TTL. - // timeToLive() returns 0 when the server sent no cache headers, or a - // positive/negative value when it did (negative = already expired). - const serverTtl = policy.timeToLive(); - if (serverTtl !== 0) { - // Server provided an explicit TTL — respect it (even if expired) + // If the server sent explicit cache directives (e.g., max-age=0), respect + // them — CachePolicy already said stale, so this entry is expired. + if (hasServerCacheDirectives(policy)) { return false; } - // No server TTL (0) — use our URL-based fallback tier + // No server cache headers — use our URL-based fallback tier const tier = classifyUrl(url); const fallbackTtl = FALLBACK_TTL_MS[tier]; const age = Date.now() - entry.createdAt; @@ -477,10 +489,13 @@ async function writeResponseToCache( const key = buildCacheKey(method, url); const now = Date.now(); - // Pre-compute expiry for cheap cleanup checks (avoids CachePolicy deserialization) + // Pre-compute expiry for cheap cleanup checks (avoids CachePolicy deserialization). + // When the server sent explicit cache directives, use its TTL (even if 0). + // Only fall back to URL-based tier when no server cache headers were present. const serverTtl = policy.timeToLive(); - const fallbackTtl = FALLBACK_TTL_MS[classifyUrl(url)]; - const ttl = serverTtl > 0 ? serverTtl : fallbackTtl; + const ttl = hasServerCacheDirectives(policy) + ? serverTtl + : FALLBACK_TTL_MS[classifyUrl(url)]; const entry: CacheEntry = { policy: policy.toObject(), From 4dccaddf35c5eabb0cb839b1492cbd69e1144f6a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Mar 2026 11:30:17 +0000 Subject: [PATCH 12/12] feat: implement Sentry Cache Module instrumentation for response cache Align cache spans with the Sentry Cache Module spec for the Caches Insights dashboard: - Change span ops from generic 'cache' to 'cache.get' and 'cache.put' - Set 'cache.key' attribute (string array) with the cache entry key - Set 'cache.hit' attribute (boolean) dynamically after lookup - Set 'cache.item_size' attribute with serialized body length on hit/store - Set 'network.peer.address' to the cache directory path - Use the URL as span name instead of generic 'cache.lookup'/'cache.store' - Pass span to callback in withCacheSpan for dynamic attribute setting Ref: https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/caches-module/ --- src/lib/response-cache.ts | 67 ++++++++++++++++++++++++++++++--------- src/lib/telemetry.ts | 35 ++++++++++++++------ 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/lib/response-cache.ts b/src/lib/response-cache.ts index 4013e77bd..e818ac885 100644 --- a/src/lib/response-cache.ts +++ b/src/lib/response-cache.ts @@ -367,36 +367,48 @@ export async function getCachedResponse( return; } + const key = buildCacheKey(method, url); + return await withCacheSpan( - "cache.lookup", - async () => { - const key = buildCacheKey(method, url); + url, + "cache.get", + async (span) => { const entry = await readCacheEntry(key); if (!entry) { + span.setAttribute("cache.hit", false); return; } try { const policy = CachePolicy.fromObject(entry.policy); if (!isEntryFresh(policy, entry, requestHeaders, url)) { + span.setAttribute("cache.hit", false); return; } + const body = JSON.stringify(entry.body); + span.setAttribute("cache.hit", true); + span.setAttribute("cache.item_size", body.length); + const responseHeaders = buildResponseHeaders(policy, entry); - return new Response(JSON.stringify(entry.body), { + return new Response(body, { status: entry.status, headers: responseHeaders, }); } catch { // Corrupted or version-incompatible policy object — treat as cache miss. // Best-effort cleanup of the broken entry. + span.setAttribute("cache.hit", false); unlink(cacheFilePath(key)).catch(() => { // Ignored — fire-and-forget }); return; } }, - { "cache.url": url } + { + "cache.key": [key], + "network.peer.address": getCacheDir(), + } ); } @@ -455,38 +467,60 @@ export async function storeCachedResponse( return; } + const key = buildCacheKey(method, url); + try { await withCacheSpan( - "cache.store", - () => writeResponseToCache(method, url, requestHeaders, response), - { "cache.url": url } + url, + "cache.put", + async (span) => { + const size = await writeResponseToCache( + key, + url, + requestHeaders, + response + ); + if (size > 0) { + span.setAttribute("cache.item_size", size); + } + }, + { + "cache.key": [key], + "network.peer.address": getCacheDir(), + } ); } catch { // Cache write failures are non-fatal — silently ignore } } -/** Core cache write logic, separated for complexity management */ +/** + * Core cache write logic, separated for complexity management. + * + * Always called for GET requests (caller checks method), so "GET" is hardcoded + * for the CachePolicy constructor. + * + * @returns The serialized body size in bytes (0 if not storable). + */ async function writeResponseToCache( - method: string, + key: string, url: string, requestHeaders: Record, response: Response -): Promise { +): Promise { const responseHeadersObj = headersToObject(response.headers); const policy = new CachePolicy( - { url, method, headers: requestHeaders }, + { url, method: "GET", headers: requestHeaders }, { status: response.status, headers: responseHeadersObj }, POLICY_OPTIONS ); if (!policy.storable()) { - return; + return 0; } const body: unknown = await response.json(); - const key = buildCacheKey(method, url); const now = Date.now(); // Pre-compute expiry for cheap cleanup checks (avoids CachePolicy deserialization). @@ -507,8 +541,9 @@ async function writeResponseToCache( expiresAt: now + ttl, }; + const serialized = JSON.stringify(entry); await mkdir(getCacheDir(), { recursive: true, mode: 0o700 }); - await writeFile(cacheFilePath(key), JSON.stringify(entry), "utf-8"); + await writeFile(cacheFilePath(key), serialized, "utf-8"); // Probabilistic cleanup to avoid unbounded cache growth if (Math.random() < CLEANUP_PROBABILITY) { @@ -516,6 +551,8 @@ async function writeResponseToCache( // Non-fatal: cleanup failure doesn't affect cache correctness }); } + + return serialized.length; } /** diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 9390aab10..6db58cf48 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -952,20 +952,35 @@ export function withFsSpan( } /** - * Wrap a cache operation with a span for tracing. + * Wrap a cache operation with a Sentry Cache Module span. * - * Creates a child span under the current active span to track - * response cache hit/miss/store operations. + * Implements the [Sentry Cache Module spec](https://develop.sentry.dev/sdk/performance/modules/caches/) + * for the Caches Insights dashboard. The span is passed to the callback so + * callers can set `cache.hit`, `cache.item_size`, etc. after the lookup. * - * @param operation - Name of the operation (e.g., "cache.lookup", "cache.store") - * @param fn - The function that performs the cache operation - * @param attributes - Optional span attributes (e.g., url, cache.hit) + * @param name - Span name (typically the cache key or a descriptive label) + * @param op - Cache operation: `"cache.get"` for reads, `"cache.put"` for writes + * @param fn - Function to execute, receives the span for dynamic attribute setting + * @param attributes - Initial span attributes (e.g., `cache.key`, `network.peer.address`) * @returns The result of the function */ export function withCacheSpan( - operation: string, - fn: () => T | Promise, - attributes?: Record + name: string, + op: "cache.get" | "cache.put", + fn: (span: Span) => T | Promise, + attributes?: Record ): Promise { - return withTracing(operation, "cache", fn, attributes); + return Sentry.startSpan( + { name, op, attributes, onlyIfParent: true }, + async (span) => { + try { + const result = await fn(span); + span.setStatus({ code: 1 }); // OK + return result; + } catch (error) { + span.setStatus({ code: 2 }); // Error + throw error; + } + } + ); }