diff --git a/packages/cli/src/lib/response-cache.ts b/packages/cli/src/lib/response-cache.ts index 6019c1e62..2ada16ce9 100644 --- a/packages/cli/src/lib/response-cache.ts +++ b/packages/cli/src/lib/response-cache.ts @@ -45,16 +45,21 @@ const log = logger.withTag("response-cache"); /** * 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) + * - `stable`: data that changes infrequently (orgs, projects, teams, + * organization event details, trace-item details) + * - `volatile`: data that changes often (issues and their events, project + * event details, full traces, log and span queries) * - `no-cache`: never cache (polling endpoints like autofix state) + * + * No tier treats a response as immutable. Event payloads embed mutable + * fields (`nextEventID`, user reports, release data), `latest`/`oldest`/ + * `recommended` resolve to different events over time, and traces gain + * spans as they arrive (getsentry/sentry#128818). */ -type TtlTier = "immutable" | "stable" | "volatile" | "no-cache"; +type TtlTier = "stable" | "volatile" | "no-cache"; /** Fallback TTL durations by tier (milliseconds). `no-cache` uses 0 as a sentinel. */ const FALLBACK_TTL_MS: Record = { - 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, @@ -63,30 +68,35 @@ const FALLBACK_TTL_MS: Record = { /** * URL patterns grouped by TTL tier. * - * Checked in tier priority order (no-cache → immutable → volatile). + * Checked in tier priority order (no-cache → volatile). * "stable" has no patterns — it is the default fallback when nothing else matches. + * + * TODO: Classify by generated operation metadata from `@sentry/api` instead + * of URL regexes, once it ships per-operation metadata + * (getsentry/sentry-api-schema#81) and a route-keyed operation map + * (getsentry/sentry-api-schema#102). */ const URL_TIER_REGEXPS: Readonly> = { // Polling endpoints where state changes rapidly "no-cache": [/\/(?:autofix|root-cause)\//], - // Specific resources by ID (events, traces, span details) — never change once created - immutable: [ - /\/events\/[^/?]+\/?(?:\?|$)/, + volatile: [ + // Issue endpoints: lists, detail views, and issue events such as + // `/issues/{id}/events/latest/` + /\/issues\//, + /[?&]dataset=(?:logs|spans)/, + /\/trace-logs\//, + // Full traces gain spans as they arrive /\/trace\/[0-9a-f]{32}\//, - /\/trace-items\/[0-9a-f]+\//, + // Project event details embed `nextEventID`/`previousEventID` and user + // reports, and reprocessing can change them for the same ID + /\/projects\/[^/]+\/[^/]+\/events\/[^/?]+\/?(?:\?|$)/, ], - // Issue endpoints (lists AND detail views), dataset queries, trace-logs - volatile: [/\/issues\//, /[?&]dataset=(?:logs|spans)/, /\/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", -]; +const TIER_CHECK_ORDER: readonly TtlTier[] = ["no-cache", "volatile"]; /** * Classify a URL into a TTL tier for fallback caching. @@ -189,7 +199,6 @@ type CacheEntry = { const POLICY_OPTIONS: CachePolicy.Options = { shared: false, cacheHeuristic: 0.1, - immutableMinTimeToLive: FALLBACK_TTL_MS.immutable, }; /** Maximum number of cache files to retain */ diff --git a/packages/cli/test/lib/response-cache.property.test.ts b/packages/cli/test/lib/response-cache.property.test.ts index 6f605c63c..c649b35a4 100644 --- a/packages/cli/test/lib/response-cache.property.test.ts +++ b/packages/cli/test/lib/response-cache.property.test.ts @@ -164,32 +164,44 @@ describe("property: classifyUrl", () => { fcAssert( property(sentryUrlArb, (url) => { const tier = classifyUrl(url); - expect(["immutable", "stable", "volatile", "no-cache"]).toContain(tier); + expect(["stable", "volatile", "no-cache"]).toContain(tier); }), { numRuns: DEFAULT_NUM_RUNS }, ); }); - test("event detail URLs are immutable", () => { + test("organization event and trace-item details are stable", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/myorg/events/myproject:abc123/", + "https://sentry.io/api/0/organizations/org/events/42:deadbeef/?field=title", + "https://us.sentry.io/api/0/projects/org/proj/trace-items/a1b2c3d4e5f67890/?trace_id=abc&item_type=spans", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("stable"); + } + }); + + test("project event details are volatile", () => { 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"); + expect(classifyUrl(url)).toBe("volatile"); } }); - 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 events are volatile, including latest/oldest/recommended", () => { + for (const eventId of ["latest", "oldest", "recommended", "deadbeef"]) { + const url = `https://us.sentry.io/api/0/organizations/org/issues/123/events/${eventId}/`; + expect(classifyUrl(url)).toBe("volatile"); + } }); - test("trace-items (span detail) URLs are immutable", () => { - const url = - "https://us.sentry.io/api/0/projects/org/proj/trace-items/a1b2c3d4e5f67890/?trace_id=abc&item_type=spans"; - expect(classifyUrl(url)).toBe("immutable"); + test("full trace URLs with 32-char hex IDs are volatile", () => { + const traceId = "a".repeat(32); + const url = `https://us.sentry.io/api/0/organizations/myorg/trace/${traceId}/?limit=100`; + expect(classifyUrl(url)).toBe("volatile"); }); test("issue URLs are volatile (lists and detail views)", () => { diff --git a/packages/cli/test/lib/response-cache.test.ts b/packages/cli/test/lib/response-cache.test.ts index 24c0a8369..23022c861 100644 --- a/packages/cli/test/lib/response-cache.test.ts +++ b/packages/cli/test/lib/response-cache.test.ts @@ -7,7 +7,7 @@ import { readdir } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { setAuthToken } from "../../src/lib/db/auth.js"; import { buildCacheKey, @@ -452,6 +452,67 @@ describe("no-cache tier", () => { }); }); +// --------------------------------------------------------------------------- +// Fallback TTL tiers +// --------------------------------------------------------------------------- + +describe("fallback TTL tiers", () => { + const MINUTE = 60 * 1000; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + async function isCachedAfter(url: string, elapsedMs: number) { + await storeCachedResponse(TEST_METHOD, url, {}, mockResponse(TEST_BODY)); + vi.setSystemTime(Date.now() + elapsedMs); + return (await getCachedResponse(TEST_METHOD, url, {})) !== undefined; + } + + test("organization event details are reused for up to 5 minutes", async () => { + const url = + "https://us.sentry.io/api/0/organizations/myorg/events/proj:abc123/"; + expect(await isCachedAfter(url, 4 * MINUTE)).toBe(true); + vi.setSystemTime(Date.now() + 2 * MINUTE); + expect(await getCachedResponse(TEST_METHOD, url, {})).toBeUndefined(); + }); + + test("an issue's latest event expires after 60 seconds", async () => { + const url = + "https://us.sentry.io/api/0/organizations/myorg/issues/123/events/latest/"; + expect(await isCachedAfter(url, 2 * MINUTE)).toBe(false); + }); + + test("project event details expire after 60 seconds", async () => { + const url = + "https://us.sentry.io/api/0/projects/myorg/myproject/events/abc123/"; + expect(await isCachedAfter(url, 2 * MINUTE)).toBe(false); + }); + + test("full traces expire after 60 seconds", async () => { + const url = `https://us.sentry.io/api/0/organizations/myorg/trace/${"a".repeat(32)}/`; + expect(await isCachedAfter(url, 2 * MINUTE)).toBe(false); + }); + + test("server max-age still wins over the fallback tier", async () => { + const url = + "https://us.sentry.io/api/0/organizations/myorg/issues/123/events/latest/"; + await storeCachedResponse( + TEST_METHOD, + url, + {}, + mockResponse(TEST_BODY, 200, { "cache-control": "private, max-age=300" }), + ); + vi.setSystemTime(Date.now() + 4 * MINUTE); + expect(await getCachedResponse(TEST_METHOD, url, {})).toBeDefined(); + }); +}); + // --------------------------------------------------------------------------- // File structure // ---------------------------------------------------------------------------