Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bc5b993
feat(eap): add semver sort key for sentry.release ORDER BY in EAP
claude Jun 25, 2026
f08a5cd
fix(eap): fix mypy error and GROUP BY semver tuple mismatch
claude Jun 25, 2026
4be96cd
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jun 25, 2026
1e2d24b
fix(eap): apply semver sort key to flextime pagination boundary compa…
claude Jun 25, 2026
62fcc84
fix(eap): handle Nullable(String) in semver_sort_key
claude Jun 25, 2026
0dc485b
fix(test): handle tied semver sort keys in test_desc_is_reverse_of_asc
claude Jun 25, 2026
6f24de6
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jun 26, 2026
6628441
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jun 26, 2026
6891fa8
fix(lint): add explicit strict=True to zip() in pagination.get_filters
claude Jun 26, 2026
750bfcf
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jun 28, 2026
b576c6c
feat(eap): also semver-sort sentry.sdk.version
claude Jun 29, 2026
a67628a
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jun 29, 2026
b989fe9
Revert sentry.sdk.version from SEMVER_SORT_ATTRIBUTES
claude Jun 29, 2026
85e4223
fix(eap): add raw-string tiebreaker to semver sort key
claude Jun 29, 2026
a75e224
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jun 29, 2026
94eea20
Remove test_uniq_with_default_value_double_casts_to_float64
claude Jun 29, 2026
18d4f49
Merge branch 'master' into claude/semver-sorting-eap-clickhouse-lpg3gs
claude Jul 3, 2026
d3210eb
feat(eap): respect SORT_NATURAL on attribute values endpoint
claude Jul 3, 2026
f8741ab
test: write natural-sort fixtures per-test, not in shared fixture
claude Jul 3, 2026
a9dbc5a
feat(eap): make SORT_NATURAL release-aware for release attributes
claude Jul 3, 2026
7a45d04
test: make release-sort test robust to default sentry.release
claude Jul 3, 2026
75edf1d
fix(eap): treat PEP 440 dot-dev builds as prereleases in semver key
claude Jul 3, 2026
bc680ba
test: update semver structure assertion to match() stability flag
claude Jul 3, 2026
267d3b5
feat(eap): drive semver ordering via SORT_NATURAL on all endpoints
claude Jul 3, 2026
56fac6c
fix: guard _semver_sort_key_py against non-ASCII Unicode digits
claude Jul 3, 2026
d55cf19
[getsentry/action-github-commit] Auto commit
getsantry[bot] Jul 3, 2026
8ecbb25
fix(mypy): type _name_key with Mapping[str, Any] -> Any
claude Jul 3, 2026
b5b23c5
Merge branch 'master' into claude/merge-master-update-deps-7d43rt
claude Jul 21, 2026
25fe042
test: address review comments on semver tests
claude Jul 21, 2026
4c74cfc
fix(eap): harden semver sort — build metadata, boolean guard, page token
claude Jul 21, 2026
de208f0
fix(eap): honor descending for SORT_SEMVER name ordering
claude Jul 21, 2026
c12be89
Merge remote-tracking branch 'origin/master' into claude/merge-master…
claude Jul 21, 2026
c83aa54
docs(eap): condense semver code comments
claude Jul 21, 2026
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"sentry-conventions>=0.17.0",
"sentry-kafka-schemas>=2.1.39",
"sentry-options>=1.2.1",
"sentry-protos>=0.46.0",
"sentry-protos>=0.47.0",
"sentry-redis-tools>=0.5.1",
"sentry-relay>=0.9.25",
"sentry-sdk>=2.35.0",
Expand Down
35 changes: 35 additions & 0 deletions snuba/web/rpc/common/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,41 @@ def attribute_key_to_expression(attr_key: AttributeKey) -> Expression:
raise BadSnubaRPCRequestException(str(e)) from e


_SEMVER_COMPONENT_COUNT = 4 # major.minor.patch.build


def semver_sort_key(expr: Expression, alias: str | None = None) -> Expression:
"""Return a Tuple(Array(UInt32), UInt8, String) semver sort key for ``SORT_SEMVER``.

Callers opt in per request (no hardcoded attribute list) and the key is
applied to whatever column they order by. Strips a 'package@' prefix and
'+build' metadata, maps the release part to 4 UInt32 components (so "1.2" ==
"1.2.0"), then adds a stability flag (0=prerelease, 1=stable) so prereleases
sort before their stable release, and the raw string as a tiebreaker for a
deterministic total order. Works on Altinity 25.3/25.8 (no naturalSortKey).
"""
x = Argument(None, "x")
# sentry.release is coalesced, so Nullable(String); strip the nullable
# wrapper (ClickHouse forbids Nullable(Array(…))) before string→array funcs.
non_null = f.ifNull(expr, literal(""))
version_no_prefix = f.arrayElement(f.splitByChar(literal("@"), non_null), literal(-1))
# Drop build metadata (does not affect precedence); left attached it would
# zero the last component and fail the stability match.
version_no_build = f.arrayElement(f.splitByChar(literal("+"), version_no_prefix), literal(1))
release_part = f.arrayElement(f.splitByChar(literal("-"), version_no_build), literal(1))
numeric_key = f.arrayResize(
f.arrayMap(
Lambda(None, ("x",), f.toUInt32OrZero(x)),
f.splitByChar(literal("."), release_part),
),
literal(_SEMVER_COMPONENT_COUNT),
)
# Stable only for plain dotted-numeric versions; anything else (SemVer
# "-beta.1" or PEP 440 dot-dev "24.7.0.dev0+<sha>") is a prerelease.
is_stable = f.match(version_no_build, literal(r"^[0-9]+(\.[0-9]+)*$"))
return FunctionCall(alias, "tuple", (numeric_key, is_stable, non_null))
Comment thread
cursor[bot] marked this conversation as resolved.


def _trace_item_filter_key_expression(
attr_to_key_expression_callable: Callable[[AttributeKey], Expression],
key: AttributeKey,
Expand Down
98 changes: 72 additions & 26 deletions snuba/web/rpc/common/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
from snuba.query.dsl import Functions as f
from snuba.query.dsl import column, literal
from snuba.query.expressions import Expression
from snuba.web.rpc.common.common import attribute_key_to_expression
from snuba.web.rpc.common.common import (
attribute_key_to_expression,
semver_sort_key,
)
from snuba.web.rpc.storage_routing.routing_strategies.storage_routing import TimeWindow


Expand All @@ -27,6 +30,9 @@ class FlexibleTimeWindowPageWithFilters:
_TIME_WINDOW_START_KEY = f"{_TIME_WINDOW_PREFIX}.start_timestamp"
_TIME_WINDOW_END_KEY = f"{_TIME_WINDOW_PREFIX}.end_timestamp"
_FILTER_PREFIX = "sentry__filter"
# Marks a page-boundary column whose ORDER BY used SORT_SEMVER, so
# get_filters applies the semver key on both sides of the comparison.
_SEMVER_FILTER_PREFIX = "sentry__semver_filter"

def __init__(self, page_token: PageToken):
self._page_token = page_token
Expand Down Expand Up @@ -70,38 +76,66 @@ def get_filters(self) -> Expression | None:

column_names: list[str] = []
column_values: list[Expression] = []
# Parallel to column_names: True when that column's ORDER BY used
# SORT_SEMVER, so the boundary comparison must use the semver key too.
column_is_semver: list[bool] = []

for filter in self.page_token.filter_offset.and_filter.filters:
if filter.HasField(
"comparison_filter"
) and filter.comparison_filter.key.name.startswith(self._FILTER_PREFIX):
if filter.comparison_filter.key.name == f"{self._FILTER_PREFIX}.timestamp":
column_names.append("timestamp")
if filter.comparison_filter.value.HasField("val_str"):
column_values.append(f.toDateTime(filter.comparison_filter.value.val_str))
elif filter.comparison_filter.value.HasField("val_double"):
column_values.append(literal(filter.comparison_filter.value.val_double))
elif filter.comparison_filter.value.HasField("val_int"):
column_values.append(literal(filter.comparison_filter.value.val_int))

if not filter.HasField("comparison_filter"):
continue
key_name = filter.comparison_filter.key.name
is_semver = key_name.startswith(f"{self._SEMVER_FILTER_PREFIX}.")
is_regular = key_name.startswith(f"{self._FILTER_PREFIX}.")
if not (is_semver or is_regular):
continue

if key_name == f"{self._FILTER_PREFIX}.timestamp":
# Resolve the value first and raise on an unsupported type
# (tokens are client-supplied) so the parallel lists stay in
# sync and the strict zip() below can't crash. Mirrors create().
value = filter.comparison_filter.value
if value.HasField("val_str"):
column_values.append(f.toDateTime(value.val_str))
elif value.HasField("val_double"):
column_values.append(literal(value.val_double))
elif value.HasField("val_int"):
column_values.append(literal(value.val_int))
else:
# strip the _FILTER_PREFIX from the attribute key and the dot
column_names.append(
filter.comparison_filter.key.name[len(self._FILTER_PREFIX) + 1 :]
raise ValueError(
f"Timestamp value type {value.WhichOneof('value')} not supported "
"in page token"
)
column_values.append(
literal(
getattr(
filter.comparison_filter.value,
str(filter.comparison_filter.value.WhichOneof("value")),
)
column_names.append("timestamp")
column_is_semver.append(False)
else:
Comment thread
sentry[bot] marked this conversation as resolved.
# strip the matching prefix (and the dot) to recover the alias
prefix = self._SEMVER_FILTER_PREFIX if is_semver else self._FILTER_PREFIX
column_names.append(key_name[len(prefix) + 1 :])
column_is_semver.append(is_semver)
column_values.append(
literal(
getattr(
filter.comparison_filter.value,
str(filter.comparison_filter.value.WhichOneof("value")),
)
)
)
# Assumes everything in the ORDER BY is ordered by DESC
if column_names:
res = f.less(
f.tuple(*(column(c_name) for c_name in column_names)), f.tuple(*column_values)
)
col_exprs = []
val_exprs = []
for c_name, c_value, is_semver in zip(
column_names, column_values, column_is_semver, strict=True
):
# For SORT_SEMVER columns, apply the same semver key on both sides
# so the page-boundary comparison uses the same ordering as ORDER BY.
if is_semver:
col_exprs.append(semver_sort_key(column(c_name)))
val_exprs.append(semver_sort_key(c_value))
else:
col_exprs.append(column(c_name))
val_exprs.append(c_value)
res = f.less(f.tuple(*col_exprs), f.tuple(*val_exprs))
Comment thread
cursor[bot] marked this conversation as resolved.
return res
return None

Expand Down Expand Up @@ -177,22 +211,34 @@ def create(
# find the attribute in the in_msg.columns attribute that has the same label as the `column` attribute in the order_by_clause
# call `attribute_key_to_expression` on it and us its alias as the name of the AttributeKey in the ComparisonFilter
attribute_expression = None
selected_key = None
for selected_column in in_msg.columns:
if selected_column.label == order_by_clause.column.label:
attribute_expression = attribute_key_to_expression(
selected_column.key
)
selected_key = selected_column.key
break
if attribute_expression is None:
raise ValueError(
f"No attribute expression found for column: {order_by_clause.column.label}"
)

# Mark SORT_SEMVER string columns so get_filters wraps
# both sides in the semver key (matching ORDER BY); mirrors
# the resolver's string-only guard.
is_semver = (
order_by_clause.sort == TraceItemTableRequest.OrderBy.SORT_SEMVER
and selected_key is not None
and selected_key.type == AttributeKey.TYPE_STRING
)
prefix = cls._SEMVER_FILTER_PREFIX if is_semver else cls._FILTER_PREFIX

filters.append(
TraceItemFilter(
comparison_filter=ComparisonFilter(
key=AttributeKey(
name=f"{cls._FILTER_PREFIX}.{attribute_expression.alias}",
name=f"{prefix}.{attribute_expression.alias}",
),
op=ComparisonFilter.OP_LESS_THAN,
value=last_result_value,
Expand Down
94 changes: 76 additions & 18 deletions snuba/web/rpc/v1/endpoint_trace_item_attribute_names.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import re
import uuid
from collections.abc import Mapping
from typing import Any

from google.protobuf.json_format import MessageToDict
from sentry_protos.snuba.v1.endpoint_trace_item_attributes_pb2 import (
Expand Down Expand Up @@ -33,6 +36,7 @@
next_monday,
prev_monday,
project_id_and_org_conditions,
semver_sort_key,
treeify_or_and_conditions,
)
from snuba.web.rpc.common.debug_info import extract_response_meta
Expand Down Expand Up @@ -67,18 +71,61 @@ def _order_by_count(request: TraceItemAttributeNamesRequest) -> bool:
return request.order_by.column == TraceItemAttributeNamesRequest.OrderBy.Column.COLUMN_COUNT


def _order_by_semver(request: TraceItemAttributeNamesRequest) -> bool:
"""Whether the caller requested SORT_SEMVER (semver) ordering of names."""
return request.order_by.sort == TraceItemAttributeNamesRequest.OrderBy.SORT_SEMVER


def _order_by_name_descending(request: TraceItemAttributeNamesRequest) -> bool:
"""Whether the caller explicitly requested name ordering in descending order.
"""Whether the caller requested name ordering in descending order.

Only ``COLUMN_NAME`` + ``descending`` flips the default; unset ordering stays
name-ascending for backwards compatibility.
Both an explicit ``COLUMN_NAME`` and ``SORT_SEMVER`` (which orders by the
semver key of the name, typically with ``column`` left unset) select name
ordering, so ``descending`` flips either. Unset ordering stays name-ascending
for backwards compatibility.
"""
return (
return request.order_by.descending and (
request.order_by.column == TraceItemAttributeNamesRequest.OrderBy.Column.COLUMN_NAME
and request.order_by.descending
or _order_by_semver(request)
)


_SEMVER_NUMERIC_RE = re.compile(r"^[0-9]+(\.[0-9]+)*$")


def _semver_sort_key_py(name: str) -> tuple[tuple[int, int, int, int], int, str]:
"""Python mirror of common.semver_sort_key, used to re-sort names in Python so
the merged (ClickHouse + synthetic) result matches the ClickHouse ORDER BY."""
non_null = name or ""
version_no_prefix = non_null.split("@")[-1]
# Drop SemVer build metadata ("1.2.3+build") before parsing; it must not
# affect precedence (mirrors common.semver_sort_key).
version_no_build = version_no_prefix.split("+")[0]
release_part = version_no_build.split("-")[0]
# Mirror ClickHouse toUInt32OrZero: only ASCII decimal parses, anything else
# (including Unicode digits like "²", where str.isdigit() is True but int()
# raises) maps to 0.
components = [int(c) if (c.isascii() and c.isdigit()) else 0 for c in release_part.split(".")]
components = (components + [0, 0, 0, 0])[:4]
is_stable = 1 if _SEMVER_NUMERIC_RE.match(version_no_build) else 0
return (
(components[0], components[1], components[2], components[3]),
is_stable,
non_null,
)


def _name_order_by_expression(semver: bool) -> Expression:
"""ClickHouse ORDER BY expression for the attribute name.

Comment thread
sentry[bot] marked this conversation as resolved.
Default orders by the raw (type, name) tuple; SORT_SEMVER orders by the
semver key of the name part so versions sort numerically.
"""
if semver:
return semver_sort_key(f.tupleElement(column("attr_key"), 2))
return column("attr_key")


class AttributeKeyCollector(ProtoVisitor):
def __init__(self) -> None:
self.keys: set[str] = set()
Expand Down Expand Up @@ -344,6 +391,7 @@ def get_co_occurring_attributes(
alias="attr_key",
)

semver = _order_by_semver(request)
if _order_by_count(request):
# Opt-in frequency ordering: group by key and count how many rows
# (co-occurring attribute sets) contain each key.
Expand All @@ -359,8 +407,9 @@ def get_co_occurring_attributes(
),
expression=column("count"),
),
# stable tiebreak for keys with the same frequency
OrderBy(direction=OrderByDirection.ASC, expression=column("attr_key")),
# stable tiebreak for keys with the same frequency (semver key when
# SORT_SEMVER was requested)
OrderBy(direction=OrderByDirection.ASC, expression=_name_order_by_expression(semver)),
]
else:
# Default (order_by unset or COLUMN_NAME): distinct keys ordered by name.
Expand All @@ -374,7 +423,7 @@ def get_co_occurring_attributes(
order_by = [
OrderBy(
direction=OrderByDirection.DESC if name_descending else OrderByDirection.ASC,
expression=column("attr_key"),
expression=_name_order_by_expression(semver),
),
]

Expand Down Expand Up @@ -428,29 +477,38 @@ def t(row: Row) -> TraceItemAttributeNamesResponse.Attribute:
attribute.count = int(count)
return attribute

# Name-ordering key that mirrors the ClickHouse ORDER BY: the raw (type, name)
# tuple by default, or the semver key of the name under SORT_SEMVER.
semver = _order_by_semver(request)

def _name_key(row: Mapping[str, Any]) -> Any:
attr_key = row.get("attr_key", ("TYPE_STRING", ""))
attr_type, attr_name = attr_key[0], attr_key[1]
if semver:
return _semver_sort_key_py(attr_name)
return (attr_type, attr_name)

data = query_res.result.get("data", [])
if request.type in (AttributeKey.TYPE_UNSPECIFIED, AttributeKey.TYPE_STRING):
non_stored = [
{"attr_key": ("TYPE_STRING", key_name)}
for key_name in NON_STORED_ATTRIBUTE_KEYS
if request.value_substring_match in key_name
]
non_stored.sort(key=lambda row: tuple(row["attr_key"]))
non_stored.sort(key=_name_key)
if _order_by_count(request):
# Order the real (counted) rows to match ClickHouse: count in the
# requested direction, then name ASC (two stable passes). The synthetic
# non-stored attributes have no real count, so pin them first regardless
# of sort direction rather than relying on a sentinel value.
data.sort(key=lambda row: tuple(row.get("attr_key", ("TYPE_STRING", ""))))
# Match ClickHouse: count in the requested direction, then name ASC
# (two stable passes). Synthetic non-stored keys have no count, so
# pin them first rather than relying on a sentinel.
data.sort(key=_name_key)
data.sort(key=lambda row: row.get("count", 0), reverse=request.order_by.descending)
data = non_stored + data
else:
# Default name ordering: merge the synthetic non-stored keys in and re-sort
# by name, honoring the requested direction so it matches the ClickHouse
# ORDER BY (a COLUMN_NAME descending request must stay descending here too).
# Merge synthetic non-stored keys in and re-sort by name in the
# requested direction, matching the ClickHouse ORDER BY.
data.extend(non_stored)
data.sort(
key=lambda row: tuple(row.get("attr_key", ("TYPE_STRING", ""))),
key=_name_key,
reverse=_order_by_name_descending(request),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
attribute_key_to_expression,
base_conditions_and,
get_field_existence_expression,
semver_sort_key,
timestamp_in_range_condition,
trace_item_filters_to_expression,
treeify_or_and_conditions,
Expand Down Expand Up @@ -283,6 +284,10 @@ def _groupby_order_by_expression(attr_key: AttributeKey) -> Expression:
while keeping the cast valid as a function of the grouped column. TYPE_ARRAY keys are
rejected upstream (see _validate_select_and_groupby / _validate_order_by), so they
never reach here.

The SORT_SEMVER (semver) ordering is applied by `_convert_order_by`, not here, so
GROUP BY keeps the raw expression while ORDER BY can wrap it in the semver key.
ClickHouse accepts ORDER BY on a function of a GROUP BY key, so the two can differ.
"""
if attr_key.name == "sentry.timestamp":
return snuba_column("timestamp")
Expand Down Expand Up @@ -338,10 +343,18 @@ def _convert_order_by(
# covers `sentry.timestamp` ordering anywhere else.) GROUP BY uses the same
# expression so an aggregation query that orders by `sentry.timestamp` stays
# valid.
expression = _groupby_order_by_expression(x.column.key)
# SORT_SEMVER: client-driven semver ordering, string columns only
# (numeric/timestamp columns already sort numerically).
if (
x.sort == TraceItemTableRequest.OrderBy.SORT_SEMVER
and x.column.key.type == AttributeKey.TYPE_STRING
):
expression = semver_sort_key(expression)
res.append(
OrderBy(
direction=direction,
expression=_groupby_order_by_expression(x.column.key),
expression=expression,
)
)
elif x.column.HasField("conditional_aggregation"):
Expand Down
Loading
Loading