Skip to content

feat(upsampling) - Support upsampled error count with performance optimizations - #1833

Open
kaihao-zhao wants to merge 3 commits into
masterfrom
error-upsampling-race-condition-hesol61-r1
Open

kaihao-zhao wants to merge 3 commits into
masterfrom
error-upsampling-race-condition-hesol61-r1

Conversation

@kaihao-zhao

Copy link
Copy Markdown

See title.

yuvmen and others added 3 commits July 25, 2025 09:48
…(#94376)

Part of the Error Upsampling project:
https://www.notion.so/sentry/Tech-Spec-Error-Up-Sampling-1e58b10e4b5d80af855cf3b992f75894?source=copy_link

Events-stats API will now check if all projects in the query are
allowlisted for upsampling, and convert the count query to a sum over
`sample_weight` in Snuba, this is done by defining a new SnQL function
`upsampled_count()`.

I noticed there are also eps() and epm() functions in use in this
endpoint. I considered (and even worked on) also supporting
swapping eps() and epm() which for correctness should probably also not
count naively and use `sample_weight`, but this
caused some complications and since they are only in use by specific
dashboard widgets and not available in discover
I decided to defer changing them until we realize it is needed.
- Add 60-second cache for upsampling eligibility checks to improve performance
- Separate upsampling eligibility check from query transformation for better optimization
- Remove unnecessary null checks in upsampled_count() function per schema requirements
- Add cache invalidation utilities for configuration management

This improves performance during high-traffic periods by avoiding repeated
expensive allowlist lookups while maintaining data consistency.

@unblocked-local-kaihao unblocked-local-kaihao 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.

15 issues found.

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked-local-kaihao to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

Comment on lines +137 to +138
if "event.type:error" in query:
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This substring also matches !event.type:error, event.type:error OR event.type:transaction, and message:"event.type:error". Those queries are not restricted to errors, but their count() axes will now be replaced with error-weight aggregation. Inspect the parsed filter and its boolean structure rather than treating the presence of this text as an error-only constraint.

if column_lower == "count()":
# Transform to upsampled count - assumes sample_weight column exists
# for all events in allowlisted projects per our data model requirements
transformed_columns.append("upsampled_count() as count")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For an eligible non-top-events request with yAxis=count() and transformAliasToInputFormat=1, process_results() translates the count alias to upsampled_count() as count. The endpoint still passes the original count() field to the serializer, whose missing-field lookup returns zero even when the aggregate is nonzero. Preserve the original input-to-result mapping when rewriting the aggregation.

if column_lower == "count()":
# Transform to upsampled count - assumes sample_weight column exists
# for all events in allowlisted projects per our data model requirements
transformed_columns.append("upsampled_count() as count")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A top-events request with field=["count()", "message"] and yAxis=count() combines the original count() with upsampled_count() as count. TopEventsQueryBuilder retains both expressions, and aggregate resolution appends both under the count alias while overwriting the single function_alias_map entry for that alias. Count-based equations also auto-add ordinary count() alongside the rewritten aggregate. Resolve these references consistently so distinct aggregates do not share one result alias and input mapping.

)
return scoped_dataset.top_events_timeseries(
timeseries_columns=query_columns,
timeseries_columns=final_columns,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The timeseries uses weighted counts, but selected_columns and orderby still use the original count() when fetching the top groups. For example, a group with one event of weight 100 loses to a group with two events of weight 1 when topEvents=1, despite having the larger displayed count. Apply the eligible aggregation consistently to the top-group selection and ordering.

# Early upsampling eligibility check for performance optimization
# This cached result ensures consistent behavior across query execution
should_upsample = is_errors_query_for_error_upsampled_projects(
snuba_params, organization, dataset, request

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

dataset is captured from the original request, whereas _get_event_stats() can execute a different scoped_dataset. The dashboard-splitting paths call this function with discover from a metrics-enhanced request. Even with an explicit event.type:error filter and fully allowlisted projects, eligibility is checked against the metrics dataset and returns false, leaving the actual error query unweighted. Pass scoped_dataset to the eligibility check.

expensive repeated option lookups during high-traffic periods. This is safe
because allowlist changes are infrequent and eventual consistency is acceptable.
"""
cache_key = f"error_upsampling_eligible:{organization.id}:{hash(tuple(sorted(snuba_params.project_ids)))}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Different project sets can share this key and reuse each other’s eligibility. For example, hash((1,)) == hash((2**61,)), and both IDs fit the model’s signed 64-bit field. Within one organization, caching eligibility for the allowlisted first project then incorrectly permits the non-allowlisted second project. Include the normalized IDs themselves or use a collision-resistant digest of their serialization. Apply the same key construction to invalidation.

cache_key = f"error_upsampling_eligible:{organization.id}:{hash(tuple(sorted(snuba_params.project_ids)))}"

# Check cache first for performance optimization
cached_result = cache.get(cache_key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every invocation of the stats query callback performs an eligibility cache lookup, and misses perform a write, before checking whether the dataset supports upsampling. This also happens for axes the transformer cannot change. These are unnecessary shared-cache operations when using the conditional self-hosted Memcached configuration; options.get() already checks an in-process cache first. Reject unsupported datasets and non-transformable axes before accessing the eligibility cache.

Comment on lines +1041 to +1043
SnQLFunction(
"upsampled_count",
required_args=[],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SnQLFunction defaults to public access, and this converter is shared with the Events and Transactions datasets. A caller can therefore request yAxis=upsampled_count() directly and bypass both the all-project allowlist check and the dataset restriction. The automatic rewrite is not sufficient to enforce those preconditions. Restrict direct access or validate eligibility when resolving the aggregate itself.

# exists for all events in allowlisted projects as per schema design
snql_aggregate=lambda args, alias: Function(
"toInt64",
[Function("sum", [Column("sample_weight")])],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A status-filtered errors query joins events to group_attributes, and ErrorsQueryBuilder assigns an entity to event columns through column(). The new aggregate constructs Column("sample_weight") directly, leaving that column unqualified in the generated joined query instead of using the existing event-entity resolution. Use self.builder.column("sample_weight") to preserve that qualification; backend rejection is not established by the available code.

cache_key = f"error_upsampling_eligible:{organization.id}:{hash(tuple(sorted(snuba_params.project_ids)))}"

# Check cache first for performance optimization
cached_result = cache.get(cache_key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both cache.get() here and cache.set() below run without a fallback. With an operator-configured backend that propagates connection or timeout errors, this optional optimization aborts the request before query execution—even for unsupported datasets. The supplied self-hosted configuration ignores cache exceptions, but other deployed backend settings could not be verified. Catch cache failures and perform the eligibility check without caching, as the options store already does for its cache operations.

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.

2 participants