Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 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
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.15.0",
"sentry-kafka-schemas>=2.1.36",
"sentry-options>=1.1.1",
"sentry-protos>=0.34.0",
"sentry-protos>=0.39.0",
"sentry-redis-tools>=0.5.1",
"sentry-relay>=0.9.25",
"sentry-sdk>=2.35.0",
Expand Down
87 changes: 87 additions & 0 deletions snuba/web/rpc/common/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,93 @@ def attribute_key_to_expression(attr_key: AttributeKey) -> Expression:
raise BadSnubaRPCRequestException(str(e)) from e


_SEMVER_COMPONENT_COUNT = 4 # major.minor.patch.build

SEMVER_SORT_ATTRIBUTES: frozenset[str] = frozenset({"sentry.release"})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we include sentry.sdk.version here as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No, I just hardcoded for tests, I’m going to merge this instead and let you choose getsentry/sentry-protos#334

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reverted sentry.sdk.version in b989fe9SEMVER_SORT_ATTRIBUTES is back to just sentry.release. Good to merge as-is; the broader attribute selection can come through sentry-protos#334.


Generated by Claude Code


# Zero-pad width for digit runs in natural_sort_key. Numbers with more digits
# than this still order correctly (see natural_sort_key), this is just the
# minimum alignment width.
_NATURAL_SORT_DIGIT_WIDTH = 20


def natural_sort_key(expr: Expression, alias: str | None = None) -> Expression:
"""Return a String sort key giving natural ordering for ``SORT_NATURAL``.

Natural order compares embedded digit runs numerically, so ``"item2"`` sorts
before ``"item10"`` (plain lexicographic gives the reverse). Upstream
ClickHouse gained ``naturalSortKey()`` only in v26.3, which is unavailable on
Altinity 25.3/25.8, so the key is built from primitives: tokenize into
maximal digit / non-digit runs, left-pad each digit run with zeros so a
lexicographic comparison of the rebuilt string matches natural order.
"""
tok = Argument(None, "t")
non_null = f.ifNull(expr, literal(""))
# ``[0-9]+|[^0-9]+`` matches every character exactly once, so extractAll
# yields the runs in order with nothing dropped (unlike splitByRegexp, which
# discards the separators).
tokens = f.extractAll(non_null, literal("[0-9]+|[^0-9]+"))
# Pad to at least _NATURAL_SORT_DIGIT_WIDTH but never below the token's own
# length, so longer numbers are never truncated and still sort after shorter
# ones (a bigger magnitude keeps a longer, hence lexicographically greater,
# zero-padded form).
padded_digits = f.leftPad(
tok,
f.greatest(f.length(tok), literal(_NATURAL_SORT_DIGIT_WIDTH)),
literal("0"),
)
padded = f.arrayMap(
Lambda(
None,
("t",),
FunctionCall(None, "if", (f.match(tok, literal("^[0-9]")), padded_digits, tok)),
),
tokens,
)
return FunctionCall(alias, "arrayStringConcat", (padded, literal("")))


def semver_sort_key(expr: Expression, alias: str | None = None) -> Expression:
"""Return a Tuple(Array(UInt32), UInt8, String) sort key for semantic-version ORDER BY.

Strips a leading 'package@' prefix, isolates the release part (before '-'),
maps each dot-component to UInt32, pads to 4 elements so "1.2" == "1.2.0",
and adds a stability flag (0=prerelease, 1=stable) so prerelease versions sort
before their corresponding stable release. Works on Altinity 25.3/25.8.

The third tuple element is the raw (non-null) string. It is a tiebreaker:
distinct strings that map to the same numeric key + stability flag (e.g. "1.2"
and "1.2.0", or two prereleases of the same version) would otherwise compare
equal, which makes ordering non-deterministic and — more importantly — lets
the flextime pagination's strict `less` boundary filter skip rows tied with
the cursor. Appending the raw string gives a deterministic total order over
distinct release strings and keeps the page-boundary comparison exact. Both
ORDER BY and pagination call this function, so they stay consistent.
"""
x = Argument(None, "x")
# sentry.release is coalesced from multiple attribute columns and therefore
# returns Nullable(String). ClickHouse forbids Nullable(Array(…)), so strip
# the nullable wrapper before applying any string → array functions.
non_null = f.ifNull(expr, literal(""))
version_no_prefix = f.arrayElement(f.splitByChar(literal("@"), non_null), literal(-1))
release_part = f.arrayElement(f.splitByChar(literal("-"), version_no_prefix), literal(1))
numeric_key = f.arrayResize(
f.arrayMap(
Lambda(None, ("x",), f.toUInt32OrZero(x)),
f.splitByChar(literal("."), release_part),
),
literal(_SEMVER_COMPONENT_COUNT),
)
# A release is "stable" only when the version is a plain dotted-numeric
# string ("1.2.3", "1.2"). Anything else is a prerelease and sorts before
# the matching stable release: SemVer prereleases ("1.2.3-beta.1") as well as
# PEP 440 dot-style dev/pre builds ("24.7.0.dev0+<sha>", the common form on
# sentry.release) — the latter has no '-', so a bare '-' check would wrongly
# tag it stable and sort the dev build after its GA release.
is_stable = f.match(version_no_prefix, 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
26 changes: 22 additions & 4 deletions snuba/web/rpc/common/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
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 (
SEMVER_SORT_ATTRIBUTES,
attribute_key_to_expression,
semver_sort_key,
)
from snuba.web.rpc.storage_routing.routing_strategies.storage_routing import TimeWindow


Expand Down Expand Up @@ -99,9 +103,23 @@ def get_filters(self) -> Expression | None:
)
# 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 in zip(column_names, column_values, strict=True):
# c_name is the attribute expression alias: "{attr}.{Type.Name(type)}"
# For semver attributes (e.g. sentry.release_TYPE_STRING), apply the
# same semver sort key on both sides so the page boundary comparison
# uses the same ordering as ORDER BY.
attr_name = (
c_name.removesuffix("_TYPE_STRING") if c_name.endswith("_TYPE_STRING") else None
)
if attr_name in SEMVER_SORT_ATTRIBUTES:
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
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@
from snuba.utils.metrics.timer import Timer
from snuba.web.query import run_query
from snuba.web.rpc.common.common import (
SEMVER_SORT_ATTRIBUTES,
add_existence_check_to_subscriptable_references,
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 @@ -276,7 +278,7 @@ def aggregation_filter_to_expression(
raise BadSnubaRPCRequestException(f"Unsupported aggregation filter type: {default}")


def _groupby_order_by_expression(attr_key: AttributeKey) -> Expression:
def _groupby_order_by_expression(attr_key: AttributeKey, for_order_by: bool = False) -> Expression:
"""
Maps an attribute key used in GROUP BY / ORDER BY to its expression.

Expand All @@ -285,10 +287,17 @@ 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.

For `SEMVER_SORT_ATTRIBUTES` (e.g. `sentry.release`), the ORDER BY uses
a semver tuple key while GROUP BY keeps the raw expression. ClickHouse
accepts ORDER BY on a function of a GROUP BY key, so the two can differ here.
"""
if attr_key.name == "sentry.timestamp":
return snuba_column("timestamp")
return attribute_key_to_expression(attr_key)
base = attribute_key_to_expression(attr_key)
if for_order_by and attr_key.name in SEMVER_SORT_ATTRIBUTES:
return semver_sort_key(base)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
return base


def _convert_order_by(
Expand Down Expand Up @@ -339,11 +348,12 @@ def _convert_order_by(
# no-groupby branch above already expands to the full table sort key; this
# covers `sentry.timestamp` ordering anywhere else.) GROUP BY uses the same
# expression so an aggregation query that orders by `sentry.timestamp` stays
# valid.
# valid. For `SEMVER_SORT_ATTRIBUTES`, `for_order_by=True` selects the semver
# tuple key here while GROUP BY keeps the raw expression.
res.append(
OrderBy(
direction=direction,
expression=_groupby_order_by_expression(x.column.key),
expression=_groupby_order_by_expression(x.column.key, for_order_by=True),
)
)
elif x.column.HasField("conditional_aggregation"):
Expand Down
21 changes: 20 additions & 1 deletion snuba/web/rpc/v1/trace_item_attribute_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
from snuba.web.query import run_query
from snuba.web.rpc import RPCEndpoint
from snuba.web.rpc.common.common import (
SEMVER_SORT_ATTRIBUTES,
add_existence_check_to_subscriptable_references,
attribute_key_to_expression,
base_conditions_and,
natural_sort_key,
semver_sort_key,
treeify_or_and_conditions,
)
from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException
Expand Down Expand Up @@ -136,6 +139,22 @@ def _build_query(
)
treeify_or_and_conditions(inner_query)
add_existence_check_to_subscriptable_references(inner_query)
# The value column normally orders lexicographically. When the caller opts
# into SORT_NATURAL (sentry-protos#334), order it so embedded digit runs
# compare numerically (e.g. "1.2.9" before "1.2.10"). Release attributes use
# the release-aware semver key (prerelease before stable, "pkg@" prefix
# stripped); every other attribute uses the general natural-sort key. count()
# stays the primary ordering so the most common values still come first; the
# sort key only changes the tiebreak among equally frequent values. An
# unset/SORT_DEFAULT sort keeps the historical lexicographic order.
if request.order_by.sort == TraceItemAttributeValuesRequest.OrderBy.SORT_NATURAL:
if request.key.name in SEMVER_SORT_ATTRIBUTES:
value_order_expression: Expression = semver_sort_key(column("attr_value"))
else:
value_order_expression = natural_sort_key(column("attr_value"))
else:
value_order_expression = column("attr_value")

res = CompositeQuery(
from_clause=inner_query,
selected_columns=[
Expand All @@ -154,7 +173,7 @@ def _build_query(
],
order_by=[
OrderBy(direction=OrderByDirection.DESC, expression=column("count()")),
OrderBy(direction=OrderByDirection.ASC, expression=column("attr_value")),
OrderBy(direction=OrderByDirection.ASC, expression=value_order_expression),
],
groupby=[column("attr_value")],
limit=request.limit,
Expand Down
67 changes: 67 additions & 0 deletions tests/web/rpc/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@
_any_attribute_filter_to_expression,
attribute_key_to_expression,
dedupe_and_conditions,
natural_sort_key,
next_monday,
prev_monday,
semver_sort_key,
trace_item_filters_to_expression,
treeify_or_and_conditions,
use_array_map_columns,
Expand Down Expand Up @@ -1309,6 +1311,71 @@ def test_not_like_wildcard_matches_only_absent(self) -> None:
assert self._execute(ComparisonFilter.OP_NOT_LIKE, value="%") == ["green"]


class TestSemverSortKey:
def test_expression_structure(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = semver_sort_key(snuba_column("release"))
assert isinstance(expr, FunctionCall)
assert expr.function_name == "tuple"
assert len(expr.parameters) == 3
numeric_key_expr, is_stable_expr, raw_str_expr = expr.parameters
assert isinstance(numeric_key_expr, FunctionCall)
assert numeric_key_expr.function_name == "arrayResize"
assert isinstance(is_stable_expr, FunctionCall)
assert is_stable_expr.function_name == "equals"
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
# Third element is the raw (non-null) string tiebreaker: ifNull(expr, '').
assert isinstance(raw_str_expr, FunctionCall)
assert raw_str_expr.function_name == "ifNull"

def test_alias_is_forwarded(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = semver_sort_key(snuba_column("release"), alias="semver_key")
assert isinstance(expr, FunctionCall)
assert expr.alias == "semver_key"

def test_no_alias_by_default(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = semver_sort_key(snuba_column("release"))
assert expr.alias is None


class TestNaturalSortKey:
def test_expression_structure(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = natural_sort_key(snuba_column("attr_value"))
# arrayStringConcat(arrayMap(...), '')
assert isinstance(expr, FunctionCall)
assert expr.function_name == "arrayStringConcat"
assert len(expr.parameters) == 2
array_map_expr, sep_expr = expr.parameters
assert isinstance(array_map_expr, FunctionCall)
assert array_map_expr.function_name == "arrayMap"
assert isinstance(sep_expr, Literal)
assert sep_expr.value == ""
# The mapped array is extractAll(ifNull(expr, ''), '[0-9]+|[^0-9]+').
lam, tokens_expr = array_map_expr.parameters
assert isinstance(lam, Lambda)
assert isinstance(tokens_expr, FunctionCall)
assert tokens_expr.function_name == "extractAll"

def test_alias_is_forwarded(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = natural_sort_key(snuba_column("attr_value"), alias="natural_key")
assert isinstance(expr, FunctionCall)
assert expr.alias == "natural_key"

def test_no_alias_by_default(self) -> None:
from snuba.query.dsl import column as snuba_column

expr = natural_sort_key(snuba_column("attr_value"))
assert expr.alias is None


class TestAnyAttributeFilterOption:
"""The `enable_any_attribute_filter` sentry-option gates whether
any_attribute_filter is translated into a predicate or treated as
Expand Down
Loading
Loading