feat(upsampling) - Support upsampled error count with performance optimizations
getsentry/sentry#94376Golden Comments (3/3 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | sample_rate = 0.0 is falsy and skipped |
LOW | ✓ Found | "_set_sample_rate_from_error_sampling mutates the normalized payload of every store_event call in the entire test suite, silently swallows exceptions with bare except, and uses a truthiness check that skips the valid value 0.0" (factories.py L344) |
| #2 | Using Python's built-in hash() to build cache keys is non-deterministic across processes (hash randomization), so keys won't match across workers and invalidate_upsampling_cache may fail to delete them |
LOW | ✓ Found | "Cache key uses Python's built-in hash() on a tuple, which is randomized per-process (PYTHONHASHSEED) — different workers compute different keys, defeating cache sharing and making invalidate_upsampling_cache() compute a different key, so invalidation silently fails" (error_upsampling.py L25). Note: dataset rates this LOW, FriendlyReviewer rated it High |
| #3 | The upsampling eligibility check passes the outer dataset instead of the actual dataset used by scoped_dataset; in paths where the query ultimately runs against discover (e.g. dashboard split) while the original dataset is metrics, upsampling may be skipped even when all projects are allowlisted |
MEDIUM | ✓ Found | "The upsampling eligibility check passes the closure dataset (the original dataset from get()) instead of scoped_dataset (the dataset actually queried). During dashboard discover-split, _get_event_stats is called with different scoped_dataset values, so error-split discover queries will never be upsampled even when they query errors on allowlisted projects" (organization_events_stats.py L220) |
Supplementary Findings (14 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
invalidate_upsampling_cache() is defined but never called — no hook wires it to allowlist changes |
error_upsampling.py:67 | HIGH | ✓ Stale eligibility for up to the 60s TTL; combined with the non-deterministic key, invalidation is doubly broken |
_is_error_focused_query uses a raw substring match on request.GET["query"] instead of the actual executed query |
error_upsampling.py:126 | HIGH | ✓ Mis-classifies mixed queries and the dashboard discover-split transactions-only sub-query |
Even after fixing the dataset, discover-split queries are rewritten to ({query}) AND !event.type:transaction, but the transform check still inspects the raw request query |
organization_events_stats.py:220 | HIGH | ✓ Same region as golden #3; plain mixed discover queries are never upsampled — complementary, not duplicate |
The 4 tests of OrganizationEventsStatsErrorUpsamplingTest share org/project ids and rely on the 60s cache — order-dependent suite |
test_organization_events_stats.py:3604 | HIGH | ✓ Later tests read the cached True from the first test and bypass their mocked allowlist |
Nothing in the codebase derives or stores sample_weight, yet upsampled_count() = sum(sample_weight) and the tests assert count == 10 — unachievable |
test_organization_events_stats.py:3579 | HIGH | ✓ The test suite cannot pass as written and does not exercise upsampling end-to-end |
The transform only rewrites the exact literal count() — multi-axis requests (yAxis=count()&yAxis=count_unique(user)) and aliased counts are left un-upsampled |
error_upsampling.py:89 | MEDIUM | ✓ Inconsistent weighting within one response |
sum(sample_weight) assumes every matching event carries a weight; events without client_sample_rate have none — undercounting |
error_upsampling.py:94 | MEDIUM | ✓ Real edge case; needs a mixed sampled/unsampled test |
upsampled_count SnQL registration uses default_result_type="number" and wraps sum in toInt64, truncating fractional weights (e.g. sample_rate 1/3 floors to int) |
discover.py:1041 | MEDIUM | ✓ Biases the estimate downward; also inconsistent with sibling functions |
| The unit tests do not cover the caching contract: cache hit/miss, the 60s TTL, the short-circuit, or invalidation deleting the key | test_error_upsampling.py:1 | MEDIUM | ✓ The two cache functions are entirely untested |
In the top_events > 0 branch, only timeseries_columns is upsampled; selected_columns and orderby remain raw count() |
organization_events_stats.py:251 | MEDIUM | ✓ Top-N rows are ranked by the un-upsampled count while returned values are upsampled — internally inconsistent |
The new test class only exercises count() on the discover dataset with two buckets — no comparisonDelta, multi-dataset, discover-split, top-events or RPC coverage |
test_organization_events_stats.py:3554 | MEDIUM | ✓ Exactly where the scoped_dataset bug lives — the regressions hide there |
The allowlist test uses a blanket options.get mock returning the same value for any key; the organization argument is unused |
test_error_upsampling.py:33 | LOW | ✓ Never verifies the implementation reads the right option; the not-allowlisted path is untested |
upsampling_enabled = should_upsample is a redundant alias; the RPC transforms are dead code (the check returns False for spans_rpc/ourlogs) |
organization_events_stats.py:227 | LOW | ✓ Minor cleanup; misleading but harmless |
setUp logs in self.user then reassigns it to a different, never-logged-in user while self.authed_user was captured before |
test_organization_events_stats.py:3566 | LOW | ✓ Misleading and fragile test setup; a fairness nit |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌥️ Feat |
|---|---|
| 7 high, 7 medium, 3 low | 1 issue |
This MR adds support for upsampled error counts with a Redis cache and SnQL transform. However, the review uncovered multiple blocking issues: a non-deterministic cache key (built-in hash()) that breaks cross-worker sharing and makes invalidation unreliable; an invalidate_upsampling_cache() function that is never called; errors-vs-transactions classification that relies on raw substring matching of the request query rather than the actual executed query, which mis-handles dashboard discover-split and mixed queries; a top-events path that up-samples only the timeseries but not the ranking/grouping; and a set of tests that are order-dependent due to cache leakage, assert an upsampled count that cannot be produced from the stored events, and mutate the global store_event test helper. The core functionality is therefore not correctly implemented or validated, and the MR needs significant rework before it can be merged.
These might need a close look
- 🔴 src/sentry/api/helpers/error_upsampling.py (L25)
Cache key uses Python's built-inhash()on a tuple, which is randomized per-process (PYTHONHASHSEED). Different workers compute different keys, defeating cache sharing and makinginvalidate_upsampling_cache()compute a different key than the one used to write, so invalidation silently fails. Use a stable key (e.g. hashlib.sha256 or comma-joined sorted project IDs). - 🔴 src/sentry/api/helpers/error_upsampling.py (L67)
invalidate_upsampling_cache()is defined but never called anywhere; no hook wires it to allowlist changes. Combined with the non-deterministic cache key, allowlist changes are only picked up after the 60s TTL expires, serving stale True/False eligibility. Either wire up invalidation or remove the function. - 🔴 src/sentry/api/helpers/error_upsampling.py (L126)
_is_error_focused_queryuses a raw substring match onrequest.GET["query"]instead of the actual query. This mis-classifies errors vs transactions: mixed queries (e.g.event.type:error or event.type:transaction) are treated as error-focused, collapsing transaction counts; and the dashboard discover-split transactions-only sub-query is upsampled when the original query mentionsevent.type:error. Use the event search parser and pass the actual query string. - 🔴 src/sentry/api/endpoints/organization_events_stats.py (L220)
The upsampling eligibility check passes the closuredataset(the original dataset fromget()) instead ofscoped_dataset(the dataset actually queried). During dashboard discover-split,_get_event_statsis called with differentscoped_datasetvalues, so error-split discover queries will never be upsampled even when they query errors on allowlisted projects. - 🔴 src/sentry/api/endpoints/organization_events_stats.py (L220)
Even after fixing the dataset, discover-split error queries are rewritten to({query}) AND !event.type:transaction, but_should_apply_sample_weight_transformonly inspects the rawrequest.GET["query"]. A plain mixed discover query will be classified as non-error-focused even though the executed query is errors-only. Derive eligibility from the actual query argument passed to_get_event_stats. - 🔴 tests/snuba/api/endpoints/test_organization_events_stats.py (L3604)
All four tests inOrganizationEventsStatsErrorUpsamplingTestshare the same org/project ids and rely on the 60s cache.cache.clear()runs once per test class, so later tests read the cachedTruefrom the first test and bypass their mocked allowlist, making the suite order-dependent and non-deterministic. Use distinct org/project ids per test, patch the cache, or clear the cache at the start of each test. - 🔴 tests/snuba/api/endpoints/test_organization_events_stats.py (L3579)
The stored events only carryclient_sample_rate; nothing in the codebase derives or storessample_weighton these events, yetupsampled_count()=sum(sample_weight)and the tests assertcount == 10. With no sample_weight populated, the assertions cannot pass and do not exercise upsampling end-to-end. Populate sample_weight on stored events or assert against mocked Snuba results.
Worth checking
- 🟡 src/sentry/api/helpers/error_upsampling.py (L89)
The transform only rewrites the exact literalcount(). Multi-axis requests (e.g.yAxis=count()&yAxis=count_unique(user)) only upsamplecount(), leaving sibling axes raw, and explicit-alias counts (count() as my_alias) are left un-upsampled. This produces inconsistent weighting within one response. Document the divergence or make it a no-op that raises/logs when non-transformable columns are present. - 🟡 src/sentry/api/helpers/error_upsampling.py (L94)
sum(sample_weight)assumes every matching event carries a weight, but the ingestion helper only setssample_ratewhenclient_sample_rateis present and truthy. Events without that context have nosample_weight, causing undercounting. Confirm the schema guarantees a non-nullsample_weight(e.g. default 1) and add a test for mixed sampled/unsampled events. - 🟡 src/sentry/search/events/datasets/discover.py (L1041)
upsampled_countSnQL registration is structurally consistent and resolvable for the errors dataset, butdefault_result_type="number"(whilecountuses"integer") and the aggregate wrapssumintoInt64, which truncates fractional weights (e.g. sample_rate 1/3 silently floors to int), biasing the estimate downward. Alsorequired_args=[]is inconsistent with sibling functions. - 🟡 tests/sentry/api/helpers/test_error_upsampling.py (L1)
The unit tests do not cover the caching contract:is_errors_query_for_error_upsampled_projectsandinvalidate_upsampling_cacheare untested. There is no test for cache hit/miss, the 60s TTL, the short-circuit, the not-eligible→cache-False case, or invalidation deleting the key. - 🟡 src/sentry/api/endpoints/organization_events_stats.py (L251)
In thetop_events > 0branch, onlytimeseries_columnsis upsampled;selected_columnsandorderbyremain rawcount(). Consequently top-N rows are selected/ranked by the un-upsampled count while the returned values are upsampled — internally inconsistent. The RPC top-events branch has the same issue. Either order by the upsampled value or guard top-events from upsampling. - 🟡 src/sentry/testutils/factories.py (L344)
_set_sample_rate_from_error_samplingmutates the normalized payload of everystore_eventcall in the entire test suite (including transactions), silently swallows exceptions with bareexcept Exception: pass, and uses a truthiness check that skips the valid value0.0. This is a large blast radius for a change serving only the new upsampling tests; consider a dedicated helper or gating. - 🟡 tests/snuba/api/endpoints/test_organization_events_stats.py (L3554)
The new test class only exercisescount()on the discover dataset with two buckets. There is no coverage of comparisonDelta, multi-dataset behavior, the discover-split/dashboard-widget path (where the scoped_dataset bug lives), top-events, the RPC path, or error-vs-discover dataset branches. These scenarios are exactly where regressions will hide.
Small things (take or leave)
- 🔵 tests/sentry/api/helpers/test_error_upsampling.py (L33)
The allowlist test uses a blanketoptions.getmock returning the same value for any key, so it never verifies the implementation readsissues.client_error_sampling.project_allowlist. Also theorganizationargument to_are_all_projects_error_upsampledis unused, and the 'some projects not in allowlist → False' path is not tested. - 🔵 src/sentry/api/endpoints/organization_events_stats.py (L227)
upsampling_enabled = should_upsampleis a redundant alias; the subsequent code only readsupsampling_enabled. Theif upsampling_enabled:transform inside the twouse_rpcbranches is dead code because_should_apply_sample_weight_transformreturns False for spans_rpc/ourlogs datasets. Remove the redundant variable and the misleading RPC transforms (also note trailing whitespace on new lines). - 🔵 tests/snuba/api/endpoints/test_organization_events_stats.py (L3566)
setUpcallsself.login_as(user=self.user)and then reassignsself.user = self.create_user(), so the subsequentself.useris a different, never-logged-in user whileself.authed_userwas captured before. This is misleading and fragile; use a dedicated variable (e.g.self.other_user) for the second user.
✅ Feature-level checklist
Not there yet
- ❌ Support upsampled error count with performance optimizations
The feature is not reliably supported. Multiple blocking issues prevent correct upsampling: the eligibility decision is based on the wrong dataset (closure vs scoped) and on raw request strings instead of the actual executed query; the Redis cache key is non-deterministic across workers, making invalidation ineffective and defeating performance optimizations; and the tests cannot validate the behavior (order-dependent, unachievable counts).
Recall is 100% (3/3) including both Low goldens, but the review is very wide: 14 extra findings beyond the three goldens. Golden #2 (the
hash()cache key) was found with the exact invalidation consequence and over-classified: the dataset rates it Low, FriendlyReviewer flagged it High with the precise mechanism (PYTHONHASHSEED randomization, cross-worker key divergence, silently failing invalidation). The over-classification is defensible — a non-deterministic cache key that defeats both sharing and invalidation is a functional defect, not a nit. Golden #3 (closuredatasetvsscoped_dataset) matched a finding at the exact same location with the same dashboard discover-split example.The strongest extras arguably outrank the goldens:
invalidate_upsampling_cache()is never called anywhere (L67) — combined with the broken key, allowlist changes are only picked up after the 60s TTL expires, serving stale eligibility; the raw-substring classification of errors-vs-transactions (L126) mis-handles mixed queries; and the new tests assertcount == 10while nothing in the codebase populatessample_weight(L3579) — the suite cannot pass as written and does not validate the feature end-to-end. These are exactly the kind of findings that disappear when a benchmark scores only against its goldens.The weaknesses are real too. Several findings are layered consequences of one root cause — the eligibility/query-classification cluster around L220/L126/L67 overlaps heavily — and five of the extras are test-quality observations (order-dependence, blanket mocks,
self.userreassignment, narrow scenarios) that a human reviewer might compress into one comment. On this PR the wide net produces a lot of noise, even though each individual item is accurate.One honest caveat about the score: 3/3 here is less impressive than it looks, because two of the three goldens are Low-severity test/implementation details that a review as thorough as this one was almost certain to hit.