Skip to content

feat: add response caching for read-only API calls#330

Merged
BYK merged 12 commits intomainfrom
feat/response-cache
Mar 6, 2026
Merged

feat: add response caching for read-only API calls#330
BYK merged 12 commits intomainfrom
feat/response-cache

Conversation

@BYK
Copy link
Copy Markdown
Member

@BYK BYK commented Mar 4, 2026

Summary

Implements RFC 7234/9111-aware HTTP response caching for GET requests to the Sentry API, integrated transparently at the fetch level inside createAuthenticatedFetch().

Closes #318

Cache Design

  • Filesystem-based at ~/.sentry/cache/responses/ with JSON files keyed by SHA-256 hash of normalized URL + Authorization header
  • Standards-compliant via http-cache-semantics for freshness evaluation
  • Tiered TTL system based on URL pattern matching:
    Tier TTL Examples
    Immutable 1 hour /events/{id}/, /issues/{id}/hashes/
    Stable 5 min /organizations/, /projects/, /teams/
    Volatile 60 sec /issues/ (lists), issue details
    No-cache 0 sec /autofix/, /root-cause/ (polling)
  • Probabilistic cleanup (10% chance per write) with 500-file LRU cap
  • Only successful (2xx) responses are cached

New Flags & Env Vars

  • --refresh / -r flag on all read commands — bypasses cache for that request
  • SENTRY_NO_CACHE=1 env var — disables caching globally
  • Cache is automatically cleared on login and logout for security

Commands Updated

All read-only commands now support --refresh: 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.

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 integration-style tests for end-to-end cache behavior
  • All existing tests pass (2604 unit + 100 isolated + 101 e2e)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 4, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • Add response caching for read-only API calls by BYK in #330

Bug Fixes 🐛

  • (log-view) Support multiple log IDs and validate hex format by BYK in #362
  • (logs) Harden log schemas against API response format variations by BYK in #361
  • Improve argument parsing for common user mistakes by BYK in #363

Internal Changes 🔧

  • (delta-upgrade) Lazy chain walk, GHCR retry, parallel I/O, offline cache by BYK in #360

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 4, 2026

Codecov Results 📊

104 passed | Total: 104 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests
Passed Tests
Failed Tests
Skipped Tests

✨ No test changes detected

All tests are passing successfully.

✅ Patch coverage is 81.82%. Project has 4086 uncovered lines.
❌ Project coverage is 80.57%. Comparing base (base) to head (head).

Files with missing lines (21)
File Patch % Lines
plan.ts 20.62% ⚠️ 154 Missing
telemetry.ts 75.33% ⚠️ 129 Missing
list.ts 87.32% ⚠️ 96 Missing
view.ts 27.64% ⚠️ 89 Missing
response-cache.ts 79.37% ⚠️ 79 Missing
list.ts 25.00% ⚠️ 72 Missing
list.ts 85.71% ⚠️ 57 Missing
explain.ts 35.63% ⚠️ 56 Missing
list-command.ts 77.20% ⚠️ 44 Missing
sentry-client.ts 82.54% ⚠️ 44 Missing
auth.ts 80.88% ⚠️ 39 Missing
view.ts 61.76% ⚠️ 26 Missing
view.ts 89.18% ⚠️ 25 Missing
list.ts 92.56% ⚠️ 25 Missing
view.ts 88.78% ⚠️ 23 Missing
view.ts 92.73% ⚠️ 16 Missing
view.ts 95.60% ⚠️ 7 Missing
logs.ts 97.84% ⚠️ 3 Missing
status.ts 98.28% ⚠️ 2 Missing
login.ts 98.91% ⚠️ 1 Missing
list.ts 99.30% ⚠️ 1 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
- Coverage    81.02%    80.57%    -0.45%
==========================================
  Files          129       130        +1
  Lines        20363     21033      +670
  Branches         0         0         —
==========================================
+ Hits         16499     16947      +448
- Misses        3864      4086      +222
- Partials         0         0         —

Generated by Codecov Action

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

BugBot #9 (corrupted cache entry) — fixed in 85a119d.

Wrapped CachePolicy.fromObject(), isEntryFresh(), buildResponseHeaders(), and the Response construction in a try-catch block inside getCachedResponse(). Corrupted or version-incompatible policy objects now:

  1. Return undefined (cache miss) — the request proceeds normally via fetch
  2. Best-effort delete the broken cache entry via unlink() so it doesn't keep failing

BugBot #10 (async clearAuth not awaited) — fixed in 85a119d.

Added await to both clearAuth() call sites in test/commands/project/list.test.ts (lines 1080 and 1362).

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: login.ts — don't mix await with .catch() (thread PRRT_kwDOQm6jAs5yDJFz)

Fixed in 2cee148. Both occurrences replaced with try { await clearResponseCache(); } catch { ... } pattern.

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: whoami.ts — factory/wrapper for fresh flag boilerplate (thread PRRT_kwDOQm6jAs5yDJvW)

Fixed in 2cee148. Exported FRESH_ALIASES = { f: "fresh" } as const from list-command.ts with JSDoc documenting the log list exception. Updated 15 command files to use ...FRESH_ALIASES instead of inline { f: "fresh" }. The per-command pattern is now:

import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG } from "../../lib/list-command.js";
// parameters:
flags: { ..., fresh: FRESH_FLAG },
aliases: { ..., ...FRESH_ALIASES },
// func():
applyFreshFlag(flags);

buildOrgListCommand also uses ...FRESH_ALIASES internally now.

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — immutable TTL too short (thread PRRT_kwDOQm6jAs5yDLi1)

Fixed in 2cee148. Bumped immutable tier from 1 hour → 24 hours. Events and traces are write-once, so caching them for a day is safe. The --fresh flag and SENTRY_NO_CACHE=1 still bypass when needed.

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — URL tier patterns restructure (threads PRRT_kwDOQm6jAs5yDMZM, PRRT_kwDOQm6jAs5yDMoU, PRRT_kwDOQm6jAs5yDMyc)

All three addressed in 2cee148:

  • Restructured from Array<{ pattern, tier }>Record<TtlTier, readonly RegExp[]> (URL_TIER_REGEXPS) with a TIER_CHECK_ORDER array for iteration order
  • Combined no-cache patterns: /\/autofix\// + /\/root-cause\///\/(?:autofix|root-cause)\//
  • Combined dataset patterns: /[?&]dataset=logs/ + /[?&]dataset=transactions//[?&]dataset=(?:logs|transactions)/
  • Removed redundant /\/issues\/?$/ (already covered by /\/issues\//)

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — assorted cleanup (threads #7#11)

All addressed in 2cee148:

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — calculate expiresAt for cheaper cleanup (thread PRRT_kwDOQm6jAs5yDSK3)

Fixed in 2cee148. Added expiresAt?: number to CacheEntry, computed at write time from policy.timeToLive() with URL-based fallback. Cleanup now does a simple now >= expiresAt check without deserializing CachePolicy at all.

Kept the policy field since getCachedResponse() still needs it for RFC 7234 satisfiesWithoutRevalidation() and responseHeaders(). Backwards compatible — old entries without expiresAt fall back to URL-based TTL classification.

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — parallelize I/O with concurrency limiter (threads #13#15)

All three addressed in 2cee148. Added a parallel<T>() helper that limits concurrent async operations (default 8):

  • collectEntryMetadata: file reads run in parallel batches
  • deleteExpiredEntries: unlinks run in parallel
  • evictExcessEntries: unlinks run in parallel

Used a lightweight inline p-limit-style implementation to avoid adding a new dependency — creates a pool of concurrency slots using Promise.race() to wait for a free slot before starting the next operation.

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: issue/list.ts — Does FRESH_ALIASES override f: "follow"? (thread PRRT_kwDOQm6jAs5yD7C7)

No conflict. Verified that only log/list.ts has f: "follow" (line 322), and it does NOT use FRESH_ALIASES. The issue list command has no --follow flag, so ...FRESH_ALIASES ({ f: "fresh" }) is safe here.

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — cacheHeuristic vs CLEANUP_PROBABILITY (thread PRRT_kwDOQm6jAs5yD94G)

These are completely unrelated despite having the same numeric value (0.1):

  • cacheHeuristic: 0.1 is an http-cache-semantics option — it means "use 10% of the resource's age as fallback TTL" per the RFC 7234 heuristic freshness calculation
  • CLEANUP_PROBABILITY = 0.1 means "10% chance of triggering cache cleanup after each write"

Same number, different domain — no shared constant makes sense.

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — parallel() simplification & eviction await (threads PRRT_kwDOQm6jAs5yD_wj, PRRT_kwDOQm6jAs5yEAVl)

Both addressed in c3c622a:

Simplified parallel(): Dropped the custom parallel<T>() wrapper entirely. Now using p-limit's built-in .map() method directly:

const cacheIO = pLimit(CACHE_IO_CONCURRENCY);
// At each call site:
await cacheIO.map(items, fn);

Best-effort eviction: Both deleteExpiredEntries and evictExcessEntries now run in parallel via Promise.all() since they operate on disjoint file sets (expired vs non-expired). The internal unlinks are already wrapped in .catch().

@BYK
Copy link
Copy Markdown
Member Author

BYK commented Mar 4, 2026

Re: response-cache.ts — Sentry instrumentation (thread PRRT_kwDOQm6jAs5yEBHK)

Added in c3c622a:

  • New withCacheSpan() helper in telemetry.ts — creates child spans with op: "cache" and onlyIfParent: true
  • getCachedResponse() wrapped with cache.lookup span (includes cache.url attribute)
  • storeCachedResponse() wrapped with cache.store span (includes cache.url attribute)

This lets us see cache hit/miss rates and latency impact in the Sentry performance dashboard.

@BYK BYK force-pushed the feat/response-cache branch 2 times, most recently from 8d13fd1 to 21db140 Compare March 4, 2026 14:44
@BYK BYK force-pushed the feat/response-cache branch 2 times, most recently from 2a22df6 to 6af87f0 Compare March 6, 2026 11:30
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
github-actions bot and others added 11 commits March 6, 2026 18:22
…lassification and auth headers

- 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().
- 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.
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
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.
…parallelize cleanup

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)
…pans

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
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/
@BYK BYK force-pushed the feat/response-cache branch from 6af87f0 to 4dccadd Compare March 6, 2026 18:33
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

@BYK BYK merged commit c8d714f into main Mar 6, 2026
20 checks passed
@BYK BYK deleted the feat/response-cache branch March 6, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add response caching for read-only API calls

1 participant