Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions packages/cli/src/lib/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TtlTier, number> = {
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,
Expand All @@ -63,30 +68,35 @@ const FALLBACK_TTL_MS: Record<TtlTier, number> = {
/**
* 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<Record<TtlTier, readonly RegExp[]>> = {
// 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.
Expand Down Expand Up @@ -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 */
Expand Down
34 changes: 23 additions & 11 deletions packages/cli/test/lib/response-cache.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
63 changes: 62 additions & 1 deletion packages/cli/test/lib/response-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading