Skip to content

fix(loader): label loader_cache with the block key, not the resolve chain - #1220

Open
nicacioliveira wants to merge 1 commit into
mainfrom
fix/loader-cache-metric-cardinality
Open

fix(loader): label loader_cache with the block key, not the resolve chain#1220
nicacioliveira wants to merge 1 commit into
mainfrom
fix/loader-cache-metric-cardinality

Conversation

@nicacioliveira

@nicacioliveira nicacioliveira commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes #1219.

Problem

loader_cache and resolver_latency are labelled with ctx.resolverId, which carries the full resolve chain:

Categories@sections.variants.1.value.5.sections.0.section.page
SearchResults Global@sections.0.section.page
Categories@sections.variants.1.value.2.jsonLD

Every section position, of every variant, of every page becomes its own time series. That is an identifier, not a dimension.

Measured

Distinct loader values on the production ClickHouse, 3h window:

site distinct values
fila-store 3,684
todolivrooficial 3,478
montecarlo 2,209
farmrio 1,469
happybooksoficial 1,028
fleet total 21,849

Our own o11y guide forbids >1,000 distinct values per site as an alert dimension and >100 fleet-wide as a cross-fleet aggregation. Five sites are over; the fleet figure is 218× over.

The cost is not theoretical: loader_cache is 11,485,954 of 14,998,196 rows in otel_metrics_sum over 3h — 76.6% of the whole table — and that table holds 48.4 GB.

For contrast, cache_hit covers 194 tenants in 9 series, and the @decocms/start runtime does the equivalent job with 13 distinct deco.cache.profile values fleet-wide (vtex/productDetailsPage, vtex/relatedProducts, site/categoryTree).

Fix

Use the block key that adapt already receives — the loader's module path, e.g. vtex/loaders/legacy/productListingPage.ts — bounded by the number of loader modules in the app. Threaded into wrapLoader as a new parameter; wrapLoader is module-private with a single call site, so nothing external changes.

ctx.resolverId is kept as a fallback, so behaviour is unchanged if a caller ever passes an empty key.

The trade-off, stated plainly

This loses the ability to distinguish two instances of the same loader in different sections. If you are debugging "which section is slow", the metric will no longer answer that — it will tell you which loader is slow.

That is deliberate, and it is the line the o11y guide already draws: high-cardinality attributes are fine for filtering and point lookups, forbidden as aggregation keys. The chain still travels on error logs, which are read by point lookup rather than aggregated.

If the per-section breakdown turns out to be load-bearing for someone, the alternative is to keep the chain as a span attribute and use the key for the metric — happy to switch to that shape instead.

Note for reviewers

This changes an existing label's values. Any dashboard or alert currently grouping by loader will see its series collapse from thousands to dozens after deploy — better, but not a silent change. Historical data keeps the old labels, so charts spanning the deploy will show both.

Verification

deno check blocks/loader.ts — clean. There is no existing test file for blocks/loader.ts; the change is a label substitution with a fallback, exercised by every loader call.

🤖 Generated with Claude Code


Summary by cubic

Label loader_cache and resolver_latency with the loader’s block key (module path) instead of the resolve chain to cut metric cardinality and keep dashboards within our o11y budget. Metrics now group by loader, not per-section instance.

  • Bug Fixes

    • Use blockKey as the loader label; fall back to ctx.resolverId if empty.
    • Add blockKey param to wrapLoader; no external API changes.
    • Aligns with @decocms/start behavior and reduces series count in otel_metrics_sum.
  • Migration

    • Charts grouping by loader will collapse from thousands of series to dozens.
    • Historical data keeps old labels; mixed series may appear across the deploy window.

Written for commit 67ea0b3. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved loader performance tracking by using the specific loader identifier when available.
    • Added a fallback label to ensure cache and latency metrics remain available when an identifier is missing.

…hain

`loader_cache` and `resolver_latency` were labelled with `ctx.resolverId`, which
carries the full resolve chain:

    Categories@sections.variants.1.value.5.sections.0.section.page
    SearchResults Global@sections.0.section.page
    Categories@sections.variants.1.value.2.jsonLD

Every section position, of every variant, of every page becomes its own time
series. That is an identifier, not a dimension.

Measured on the production ClickHouse:

    distinct `loader` values, 3h window
      fila-store          3,684
      todolivrooficial    3,478
      montecarlo          2,209
      farmrio             1,469
      happybooksoficial   1,028
      fleet total        21,849

Against the budget documented in our own o11y guide — >1,000 per site is
forbidden as an alert dimension and >100 fleet-wide as a cross-fleet
aggregation — five sites are over and the fleet figure is 218x over. The cost
is real: `loader_cache` is 11,485,954 of 14,998,196 rows in otel_metrics_sum
over 3h, i.e. 76.6% of the whole table, and that table holds 48.4 GB.

For contrast, `cache_hit` covers 194 tenants in 9 series, and the
@decocms/start runtime does the equivalent job with 13 distinct
`deco.cache.profile` values fleet-wide.

The fix uses the block key that `adapt` already receives — the loader's module
path, e.g. `vtex/loaders/legacy/productListingPage.ts` — which is bounded by
the number of loader modules in the app. It is threaded into `wrapLoader` as a
new parameter; `wrapLoader` is module-private with a single call site.

What is lost: the metric can no longer distinguish two instances of the same
loader sitting in different sections. That trade is deliberate. The chain is
still carried on error logs, where high cardinality is acceptable because they
are read by point lookup rather than aggregated — and the o11y guide draws
exactly that line: high-cardinality attributes are fine for filtering and point
lookups, forbidden as aggregation keys.

`ctx.resolverId` is kept as a fallback so behaviour is unchanged if a caller
ever passes an empty key.

Verified: deno check blocks/loader.ts clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.203.1 update
  • 🎉 for Minor 1.204.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

wrapLoader now accepts a loader block key. The loader block passes each module key to it. Cache and latency metrics use this key, with resolver ID or "unknown" fallbacks.

Changes

Loader metric labels

Layer / File(s) Summary
Loader key wiring and metric labeling
blocks/loader.ts
wrapLoader accepts a blockKey. The loader block passes the module key. Cache and latency metrics use the block key, with resolver ID and "unknown" fallbacks.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: using the loader block key for loader cache metric labels.
Linked Issues check ✅ Passed The changes reduce metric cardinality by labeling metrics with loader module keys while retaining the resolver chain as a fallback and for debugging.
Out of Scope Changes check ✅ Passed The changes are limited to loader metric labeling and directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/loader-cache-metric-cardinality

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@blocks/loader.ts`:
- Around line 218-232: Separate the metric label from the cache identity in the
loader flow: keep the bounded block key for the metric’s loader value, but use
ctx.resolverId for ctx.vary?.push and the resolver query parameter when
constructing the cache URL. Update the relevant cache-key handling around
ctx.vary and cache URL generation without changing the existing fallback
behavior for metrics.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d0e276ea-5c9c-435c-86cd-e6408553e328

📥 Commits

Reviewing files that changed from the base of the PR and between 40762b5 and 67ea0b3.

📒 Files selected for processing (1)
  • blocks/loader.ts

Comment thread blocks/loader.ts
Comment on lines +218 to +232
// Metric label. Deliberately the block key and NOT `ctx.resolverId`:
// resolverId carries the full resolve chain, e.g.
// `Categories@sections.variants.1.value.5.sections.0.section.page`, so
// every section position of every variant of every page becomes its own
// time series. Measured in production that reached 3,684 distinct values
// on a single site and 21,849 across the fleet, against a documented
// budget of 1,000 per site and 100 fleet-wide — and `loader_cache` alone
// became 76.6% of all rows in otel_metrics_sum.
//
// The block key is bounded by the number of loader modules in the app,
// which is what the @decocms/start runtime already uses for the
// equivalent metric (13 distinct values fleet-wide). The chain is not
// lost: it still travels on error logs, where high cardinality is fine
// because they are read by point lookup rather than aggregated.
const loader = blockKey || ctx.resolverId || "unknown";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the resolver chain for cache identity.

Line 232 changes loader, but this value is also used by ctx.vary?.push and as the resolver query parameter in the cache URL at Lines 282-305. Two instances of the same loader can therefore collide when their cacheKeyValue values match.

Keep the block key for metric labels, but retain ctx.resolverId for cache identity.

Suggested fix
       const loader = blockKey || ctx.resolverId || "unknown";
+      const cacheResolver = ctx.resolverId || "unknown";
...
-        ctx.vary?.push(loader, cacheKeyValue);
+        ctx.vary?.push(cacheResolver, cacheKeyValue);
...
-          resolver: loader,
+          resolver: cacheResolver,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@blocks/loader.ts` around lines 218 - 232, Separate the metric label from the
cache identity in the loader flow: keep the bounded block key for the metric’s
loader value, but use ctx.resolverId for ctx.vary?.push and the resolver query
parameter when constructing the cache URL. Update the relevant cache-key
handling around ctx.vary and cache URL generation without changing the existing
fallback behavior for metrics.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="blocks/loader.ts">

<violation number="1" location="blocks/loader.ts:232">
P1: Cached loader calls now collide across distinct resolve chains because `loader` is used as the cache identity as well as the metric label, so a loader with the optional/default cache key can serve one section or page's result to another. Keep a separate block-key label for metrics and retain `ctx.resolverId` for the cache URL and vary key.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread blocks/loader.ts
// equivalent metric (13 distinct values fleet-wide). The chain is not
// lost: it still travels on error logs, where high cardinality is fine
// because they are read by point lookup rather than aggregated.
const loader = blockKey || ctx.resolverId || "unknown";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Cached loader calls now collide across distinct resolve chains because loader is used as the cache identity as well as the metric label, so a loader with the optional/default cache key can serve one section or page's result to another. Keep a separate block-key label for metrics and retain ctx.resolverId for the cache URL and vary key.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At blocks/loader.ts, line 232:

<comment>Cached loader calls now collide across distinct resolve chains because `loader` is used as the cache identity as well as the metric label, so a loader with the optional/default cache key can serve one section or page's result to another. Keep a separate block-key label for metrics and retain `ctx.resolverId` for the cache URL and vary key.</comment>

<file context>
@@ -208,7 +215,21 @@ const wrapLoader = (
+      // equivalent metric (13 distinct values fleet-wide). The chain is not
+      // lost: it still travels on error logs, where high cardinality is fine
+      // because they are read by point lookup rather than aggregated.
+      const loader = blockKey || ctx.resolverId || "unknown";
       const start = performance.now();
       let status: "bypass" | "miss" | "stale" | "hit" | undefined;
</file context>

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.

loader_cache uses the resolver chain as a metric label (3,684 series on one site, 21,849 fleet-wide)

1 participant