Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 2 additions & 1 deletion src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3193,10 +3193,11 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:

WINTER_2023_GROUPING_CONFIG = "newstyle:2023-01-11"
FALL_2025_GROUPING_CONFIG = "newstyle:2026-01-20"
FALL_2026_GROUPING_CONFIG = "newstyle:2026-10-24"
# Note: When the default config here is updated, `DEFAULT_ENHANCEMENTS_BASE` in
# `sentry.grouping.enhancer.__init__` *may* also need to be updated.
DEFAULT_GROUPING_CONFIG = FALL_2025_GROUPING_CONFIG
BETA_GROUPING_CONFIG = ""
BETA_GROUPING_CONFIG = FALL_2026_GROUPING_CONFIG

# How long the migration phase for grouping lasts
SENTRY_GROUPING_CONFIG_TRANSITION_DURATION = 90 * 24 * 3600 # 90 days, until groups age out
Expand Down
4 changes: 3 additions & 1 deletion src/sentry/grouping/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,9 @@ def get_grouping_variants_for_event(
)
hint = f"ignored because {fingerprint_source} fingerprint takes precedence"

fingerprint_variant = CustomFingerprintVariant(resolved_fingerprint, fingerprint_info)
fingerprint_variant = CustomFingerprintVariant(
resolved_fingerprint, fingerprint_info, context.config
)
additional_variants[fingerprint_variant.key] = fingerprint_variant

for variant in strategy_component_variants.values():
Expand Down
1 change: 1 addition & 0 deletions src/sentry/grouping/ingest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
# Used to migrate projects that have no activity via getsentry scripts
CONFIGS_TO_DEPRECATE = set(GROUPING_CONFIG_CLASSES.keys()) - {
DEFAULT_GROUPING_CONFIG,
BETA_GROUPING_CONFIG,
}


Expand Down
13 changes: 12 additions & 1 deletion src/sentry/grouping/strategies/configurations.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from sentry.conf.server import FALL_2025_GROUPING_CONFIG, WINTER_2023_GROUPING_CONFIG
from sentry.conf.server import (
FALL_2025_GROUPING_CONFIG,
FALL_2026_GROUPING_CONFIG,
WINTER_2023_GROUPING_CONFIG,
)
from sentry.grouping.strategies.base import (
StrategyConfiguration,
create_strategy_configuration_class,
Expand Down Expand Up @@ -82,3 +86,10 @@ def register_grouping_config(id: str, **kwargs) -> type[StrategyConfiguration]:
},
enhancements_base="all-platforms:2026-01-20",
)

# Opt-in only: changing fingerprint encoding changes existing custom and hybrid hashes.
register_grouping_config(
id=FALL_2026_GROUPING_CONFIG,
base=FALL_2025_GROUPING_CONFIG,
initial_context={"fingerprint_value_boundaries": True},
)
13 changes: 11 additions & 2 deletions src/sentry/grouping/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,24 @@
from sentry.services.eventstore.models import BaseEvent


def hash_from_values(values: Iterable[str | int | UUID | ExceptionGroupingComponent]) -> str:
def hash_from_values(
values: Iterable[str | int | UUID | ExceptionGroupingComponent],
*,
with_value_boundaries: bool = False,
) -> str:
"""
Primarily used at the end of the grouping process, to get a final hash value once the all of the
variants have been constructed, but also used as a hack to compare exception components (by
stringifying their reprs) when calculating variants for chained exceptions.
"""
result = md5()
for value in values:
result.update(force_bytes(value, errors="replace"))
value_bytes = force_bytes(value, errors="replace")
if with_value_boundaries:
# Length-prefix the encoded bytes so empty values and values containing delimiters
# cannot obscure fingerprint boundaries. Keep legacy callers unchanged by default.
result.update(f"{len(value_bytes)}:".encode("ascii"))
result.update(value_bytes)
return result.hexdigest()


Expand Down
28 changes: 24 additions & 4 deletions src/sentry/grouping/variants.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,13 @@ class CustomFingerprintVariant(BaseVariant):

type = "custom_fingerprint"

def __init__(self, fingerprint: list[str], fingerprint_info: FingerprintInfo):
def __init__(
self,
fingerprint: list[str],
fingerprint_info: FingerprintInfo,
strategy_config: StrategyConfiguration,
):
self.config = strategy_config
self.values = fingerprint
self.fingerprint_info = fingerprint_info
self.is_built_in = fingerprint_info.get("matched_rule", {}).get("is_builtin", False)
Expand All @@ -207,7 +213,12 @@ def key(self) -> str:
return get_custom_fingerprint_description(self.fingerprint_info)

def get_hash(self) -> str | None:
return hash_from_values(self.values)
return hash_from_values(
self.values,
with_value_boundaries=self.config.initial_context.get(
"fingerprint_value_boundaries", False
),
)

def _get_metadata_as_dict(self) -> FingerprintVariantMetadata:
return expose_fingerprint_dict(self.values, self.fingerprint_info)
Expand Down Expand Up @@ -260,15 +271,24 @@ def description(self) -> str:
def get_hash(self) -> str | None:
if not self.root_component.contributes:
return None
with_value_boundaries = self.config.initial_context.get(
"fingerprint_value_boundaries", False
)
final_values: list[str | int] = []
for value in self.values:
# If we've hit the `{{ default }}` part of the fingerprint, pull in values from the
# original grouping method (message, stacktrace, etc.)
if is_default_fingerprint_var(value):
final_values.extend(self.root_component.iter_values())
if with_value_boundaries:
# Treat the entire default grouping contribution as one fingerprint value.
# Preserve its existing equivalence rules rather than introducing boundaries
# between internal stacktrace or exception components.
final_values.append("".join(str(v) for v in self.root_component.iter_values()))
else:
final_values.extend(self.root_component.iter_values())
else:
final_values.append(value)
return hash_from_values(final_values)
return hash_from_values(final_values, with_value_boundaries=with_value_boundaries)

def _get_metadata_as_dict(self) -> Mapping[str, Any]:
return {
Expand Down
Loading
Loading