Skip to content
2 changes: 1 addition & 1 deletion src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
"sentry.deletions.tasks.scheduled",
"sentry.deletions.tasks.seer",
"sentry.demo_mode.tasks",
"sentry.dynamic_sampling.per_org.tasks.scheduler",
"sentry.dynamic_sampling.per_org.scheduler",
"sentry.dynamic_sampling.tasks.boost_low_volume_projects",
"sentry.dynamic_sampling.tasks.boost_low_volume_transactions",
"sentry.dynamic_sampling.tasks.recalibrate_orgs",
Expand Down
97 changes: 97 additions & 0 deletions src/sentry/dynamic_sampling/per_org/calculations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

import logging
from typing import cast

from sentry.dynamic_sampling.models.common import RebalancedItem
from sentry.dynamic_sampling.models.projects_rebalancing import (
ProjectsRebalancingInput,
ProjectsRebalancingModel,
)
from sentry.dynamic_sampling.per_org.configuration import BaseDynamicSamplingConfiguration
from sentry.dynamic_sampling.per_org.queries import ProjectVolume
from sentry.dynamic_sampling.rules.utils import get_redis_client_for_ds
from sentry.dynamic_sampling.tasks.common import sample_rate_to_float
from sentry.dynamic_sampling.tasks.helpers.boost_low_volume_projects import (
generate_boost_low_volume_projects_cache_key,
)

PROJECT_BALANCING_COMPARISON_RELATIVE_TOLERANCE = 0.05
logger = logging.getLogger(__name__)


def run_project_balancing(
config: BaseDynamicSamplingConfiguration, project_volumes: list[ProjectVolume]
) -> list[RebalancedItem]:
sample_rate = cast(float, config.get_sample_rate())
counts_by_project = {project.id: 0 for project in config.projects}
for project_volume in project_volumes:
if project_volume.project_id in counts_by_project:
counts_by_project[project_volume.project_id] = project_volume.total
return ProjectsRebalancingModel().run(
ProjectsRebalancingInput(
classes=[
RebalancedItem(id=project_id, count=count)
for project_id, count in counts_by_project.items()
],
sample_rate=sample_rate,
)
)


def get_cached_rebalanced_project_sample_rates(org_id: int) -> dict[int, float | None]:
redis_client = get_redis_client_for_ds()
cache_key = generate_boost_low_volume_projects_cache_key(org_id=org_id)
return {
int(project_id): sample_rate_to_float(sample_rate)
for project_id, sample_rate in redis_client.hgetall(cache_key).items()
}


def is_within_relative_tolerance(
cached_sample_rate: float | None,
calculated_sample_rate: float,
relative_tolerance: float = PROJECT_BALANCING_COMPARISON_RELATIVE_TOLERANCE,
) -> bool:
relative_deviation = get_relative_deviation(cached_sample_rate, calculated_sample_rate)
if relative_deviation is None:
return False
return relative_deviation <= relative_tolerance + 1e-12


def get_relative_deviation(
cached_sample_rate: float | None, calculated_sample_rate: float
) -> float | None:
if cached_sample_rate is None:
return None
if calculated_sample_rate == 0:
return 0.0 if abs(cached_sample_rate) <= 1e-12 else None
return abs(cached_sample_rate - calculated_sample_rate) / abs(calculated_sample_rate)


def compare_rebalanced_projects_with_cache(
config: BaseDynamicSamplingConfiguration,
rebalanced_projects: list[RebalancedItem],
cached_sample_rates: dict[int, float | None],
) -> None:
calculated_sample_rates = {
int(project.id): project.new_sample_rate for project in rebalanced_projects
}

for project_id, eap_sample_rate in sorted(calculated_sample_rates.items()):
generic_metrics_sample_rate = cached_sample_rates.get(project_id)
logger.info(
"dynamic_sampling.per_org.project_balancing_comparison",
extra={
"org_id": config.organization.id,
"project_id": project_id,
"generic_metrics_sample_rate": generic_metrics_sample_rate,
"eap_sample_rate": eap_sample_rate,
"relative_deviation": get_relative_deviation(
generic_metrics_sample_rate, eap_sample_rate
),
"is_equal": is_within_relative_tolerance(
generic_metrics_sample_rate, eap_sample_rate
),
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

from sentry import options, quotas
from sentry.constants import SAMPLING_MODE_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus
from sentry.dynamic_sampling.per_org.tasks.queries import get_eap_organization_volume
from sentry.dynamic_sampling.per_org.tasks.telemetry import (
from sentry.dynamic_sampling.per_org.queries import get_eap_organization_volume
from sentry.dynamic_sampling.per_org.telemetry import (
DynamicSamplingException,
DynamicSamplingStatus,
)
Expand Down Expand Up @@ -99,6 +99,14 @@ def get_sample_rate(self) -> TargetSampleRate:


class AutomaticDynamicSamplingConfiguration(BaseDynamicSamplingConfiguration):
"""
This configuration is used for organizations for which sample rates are computed based on the volume sent.
It currently applies to AM2 organizations.
It consists of the following components:
- The sample rate is either based on the reserved volume or the ingested volume, which are then used as input to the quotas API to get the sample rate.
- Projects are rebalanced
"""

sample_rate: TargetSampleRate

def __init__(self, organization: Organization) -> None:
Expand All @@ -125,6 +133,10 @@ def get_sample_rate(self) -> TargetSampleRate:
return self.sample_rate

def _get_sliding_window_sample_rate(self) -> TargetSampleRate:
"""
The sliding window sample rate uses the ingested segment volume to compute the sample rate by extrapolating
the volume of the current month and then using the quotas API to get the sample rate for that volume.
"""
if not self.projects:
return None

Expand All @@ -143,6 +155,14 @@ def _get_sliding_window_sample_rate(self) -> TargetSampleRate:


class CustomDynamicSamplingOrganizationConfiguration(BaseDynamicSamplingConfiguration):
"""
This configuration is used for organizations for which sample rates are computed based on the target sample rate option.
It currently applies to AM3 organizations with custom dynamic sampling enabled and sampling mode set to organization.
It consists of the following components:
- The sample rate is based on the target sample rate option that can be set by the user at the organzation level. There is no volume-based sample rate computation.
- Projects are rebalanced
"""

sample_rate: TargetSampleRate

def __init__(self, organization: Organization) -> None:
Expand All @@ -163,6 +183,14 @@ def get_sample_rate(self) -> TargetSampleRate:


class CustomDynamicSamplingProjectConfiguration(BaseDynamicSamplingConfiguration):
"""
This configuration is used for organizations for which sample rates are computed based on the target sample rate option.
It currently applies to AM3 organizations with custom dynamic sampling enabled and sampling mode set to project.
It consists of the following components:
- The sample rate is based on the target sample rate option that can be set by the user at the project level. There is no volume-based sample rate computation.
- Projects are not rebalanced
"""

project_target_sample_rates: ProjectTargetSampleRates
should_balance_projects: bool = False

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@
from django.db.models.functions import Mod
from taskbroker_client.retry import Retry

from sentry.dynamic_sampling.per_org.tasks.configuration import get_configuration
from sentry.dynamic_sampling.per_org.tasks.gate import is_org_in_rollout
from sentry.dynamic_sampling.per_org.tasks.queries import (
from sentry.dynamic_sampling.per_org.calculations import (
compare_rebalanced_projects_with_cache,
get_cached_rebalanced_project_sample_rates,
run_project_balancing,
)
from sentry.dynamic_sampling.per_org.configuration import get_configuration
from sentry.dynamic_sampling.per_org.gate import is_org_in_rollout
from sentry.dynamic_sampling.per_org.queries import (
get_eap_organization_volume,
get_eap_project_volumes,
get_eap_transaction_volumes,
)
from sentry.dynamic_sampling.per_org.tasks.telemetry import (
from sentry.dynamic_sampling.per_org.telemetry import (
SCHEDULER_BUCKET_ORG_STATUS_METRIC,
DynamicSamplingStatus,
emit_status,
Expand Down Expand Up @@ -116,6 +121,9 @@ def run_calculations_per_org_task(org_id: OrganizationId) -> DynamicSamplingStat
project_volumes = get_eap_project_volumes(config)
if not project_volumes:
return DynamicSamplingStatus.NO_PROJECT_VOLUMES
rebalanced_projects = run_project_balancing(config, project_volumes)
cached_sample_rates = get_cached_rebalanced_project_sample_rates(config.organization.id)
compare_rebalanced_projects_with_cache(config, rebalanced_projects, cached_sample_rates)

if not get_eap_transaction_volumes(config):
return DynamicSamplingStatus.NO_TRANSACTION_VOLUMES
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import sentry_sdk

from sentry.dynamic_sampling.per_org.tasks.gate import (
from sentry.dynamic_sampling.per_org.gate import (
is_killswitch_engaged,
is_rollout_enabled,
metrics_sample_rate,
Expand Down
1 change: 1 addition & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:gen-ai-consent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable LLM-generated title and description for external issue details
manager.add("organizations:external-issues-ai-generate", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:inbound-filters-v2", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable increased issue_owners rate limit for auto-assignment
manager.add("organizations:increased-issue-owners-rate-limit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Starfish: extract metrics from the spans
Expand Down
2 changes: 1 addition & 1 deletion static/app/components/core/info/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export {InfoText} from './infoText';
export {InfoText, type InfoTextProps} from './infoText';
export {InfoTip, DisabledTip} from './infoTip';
16 changes: 8 additions & 8 deletions static/app/components/core/info/infoText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@ import type {DistributedOmit} from 'type-fest';
import {Text, type TextProps} from '@sentry/scraps/text';
import {Tooltip, type TooltipProps} from '@sentry/scraps/tooltip';

type InfoTextProps<T extends 'span' | 'p' | 'label' | 'div'> = DistributedOmit<
TextProps<T>,
'title' | 'variant'
> & {
title: React.ReactNode;
variant?: TooltipProps['underlineColor'] | 'inherit';
} & Pick<TooltipProps, 'position'>;
export type InfoTextProps<T extends 'span' | 'p' | 'label' | 'div' | 'time'> =
DistributedOmit<TextProps<T>, 'title' | 'variant'> & {
title: React.ReactNode;
variant?: TooltipProps['underlineColor'] | 'inherit';
} & Pick<TooltipProps, 'position' | 'maxWidth'>;

export function InfoText<T extends 'span' | 'p' | 'label' | 'div' = 'span'>({
export function InfoText<T extends 'span' | 'p' | 'label' | 'div' | 'time' = 'span'>({
title,
children,
position,
maxWidth,
...textProps
}: InfoTextProps<T>) {
if (!title) {
Expand All @@ -25,6 +24,7 @@ export function InfoText<T extends 'span' | 'p' | 'label' | 'div' = 'span'>({
<Tooltip
title={title}
position={position}
maxWidth={maxWidth}
skipWrapper
isHoverable
showUnderline
Expand Down
11 changes: 6 additions & 5 deletions static/app/components/feedback/feedbackItem/messageTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ export function MessageTitle({feedbackItem, eventData}: Props) {
) : null}
<StyledTimeSince
date={feedbackItem.firstSeen}
tooltipProps={{
title: eventData ? (
disabledAbsoluteTooltip={!eventData}
tooltipBody={
eventData ? (
<FeedbackTimestampsTooltip feedbackItem={feedbackItem} />
) : undefined,
overlayStyle: {maxWidth: 300},
}}
) : undefined
}
maxWidth={300}
/>
</Flex>
</Flex>
Expand Down
Loading
Loading