Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
  •  
  •  
  •  
1 change: 1 addition & 0 deletions jest.config.snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const swcConfig: SwcOptions = {
const ESM_NODE_MODULES = ['screenfull', 'cbor2', 'nuqs', 'color'];

const config: Config.InitialOptions = {
testTimeout: 30_000,
cacheDirectory: '.cache/jest-snapshots',
// testEnvironment and testMatch are the core differences between this and the main config
testEnvironment: 'node',
Expand Down
13 changes: 0 additions & 13 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1286,19 +1286,6 @@ module = [
"sentry.release_health.tasks",
"sentry.releases",
"sentry.releases.endpoints.*",
"sentry.relocation.api.endpoints",
"sentry.relocation.api.endpoints.abort",
"sentry.relocation.api.endpoints.cancel",
"sentry.relocation.api.endpoints.details",
"sentry.relocation.api.endpoints.index",
"sentry.relocation.api.endpoints.pause",
"sentry.relocation.api.endpoints.public_key",
"sentry.relocation.api.endpoints.recover",
"sentry.relocation.api.endpoints.retry",
"sentry.relocation.api.endpoints.unpause",
"sentry.relocation.models.*",
"sentry.relocation.tasks.*",
"sentry.relocation.utils",
"sentry.remote_subscriptions.*",
"sentry.replays",
"sentry.replays._case_studies.*",
Expand Down
45 changes: 44 additions & 1 deletion src/sentry/dynamic_sampling/per_org/tasks/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

from abc import ABC, abstractmethod
from collections.abc import Mapping
from datetime import timedelta

from django.core.exceptions import ObjectDoesNotExist

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 (
DynamicSamplingException,
DynamicSamplingStatus,
)
from sentry.dynamic_sampling.rules.utils import ProjectId
from sentry.dynamic_sampling.tasks.common import compute_sliding_window_sample_rate
from sentry.dynamic_sampling.tasks.helpers.sliding_window import FALLBACK_SLIDING_WINDOW_SIZE
from sentry.dynamic_sampling.types import DynamicSamplingMode, SamplingMeasure
from sentry.dynamic_sampling.utils import has_custom_dynamic_sampling
from sentry.models.options.project_option import ProjectOption
Expand Down Expand Up @@ -50,12 +54,17 @@ class BaseDynamicSamplingConfiguration(ABC):

def __init__(self, organization: Organization) -> None:
self.organization = organization
self.sliding_window_sample_rate: TargetSampleRate = None

@property
@abstractmethod
def is_enabled(self) -> bool:
raise NotImplementedError

@abstractmethod
def get_sample_rate(self) -> TargetSampleRate:
raise NotImplementedError

@property
def is_span_based(self) -> bool:
return self.measure == SamplingMeasure.SPANS
Expand All @@ -79,12 +88,15 @@ def _get_projects(self) -> list[Project]:

class NoDynamicSamplingConfiguration(BaseDynamicSamplingConfiguration):
def __init__(self) -> None:
pass
self.sliding_window_sample_rate: TargetSampleRate = None

@property
def is_enabled(self) -> bool:
return False

def get_sample_rate(self) -> TargetSampleRate:
return None


class AutomaticDynamicSamplingConfiguration(BaseDynamicSamplingConfiguration):
sample_rate: TargetSampleRate
Expand All @@ -98,12 +110,37 @@ def __init__(self, organization: Organization) -> None:
)
except ObjectDoesNotExist as exc:
raise DynamicSamplingException(DynamicSamplingStatus.NO_SUBSCRIPTION) from exc
if not self.is_enabled:
return
self.projects = self._get_projects()
self.sliding_window_sample_rate = self._get_sliding_window_sample_rate()

@property
def is_enabled(self) -> bool:
return self.sample_rate is not None

def get_sample_rate(self) -> TargetSampleRate:
if self.sliding_window_sample_rate is not None:
return self.sliding_window_sample_rate
return self.sample_rate

def _get_sliding_window_sample_rate(self) -> TargetSampleRate:
if not self.projects:
return None

org_volume_24h = get_eap_organization_volume(
self, time_interval=timedelta(hours=FALLBACK_SLIDING_WINDOW_SIZE)
)
if org_volume_24h is None:
return None

return compute_sliding_window_sample_rate(
org_id=self.organization.id,
project_id=None,
total_root_count=org_volume_24h.total,
window_size=FALLBACK_SLIDING_WINDOW_SIZE,
)


class CustomDynamicSamplingOrganizationConfiguration(BaseDynamicSamplingConfiguration):
sample_rate: TargetSampleRate
Expand All @@ -121,6 +158,9 @@ def __init__(self, organization: Organization) -> None:
def is_enabled(self) -> bool:
return True

def get_sample_rate(self) -> TargetSampleRate:
return self.sample_rate


class CustomDynamicSamplingProjectConfiguration(BaseDynamicSamplingConfiguration):
project_target_sample_rates: ProjectTargetSampleRates
Expand All @@ -138,6 +178,9 @@ def is_enabled(self) -> bool:
sample_rate is not None for sample_rate in self.project_target_sample_rates.values()
)

def get_sample_rate(self) -> TargetSampleRate:
return None

def _get_project_target_sample_rates(self) -> ProjectTargetSampleRates:
project_sample_rates = ProjectOption.objects.get_value_bulk(
self.projects, "sentry:target_sample_rate"
Expand Down
16 changes: 11 additions & 5 deletions src/sentry/dynamic_sampling/per_org/tasks/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,30 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import Any, Literal
from typing import Any, Literal, Protocol

from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ExtrapolationMode

from sentry.dynamic_sampling.per_org.tasks.configuration import BaseDynamicSamplingConfiguration
from sentry.dynamic_sampling.rules.utils import ProjectId
from sentry.dynamic_sampling.tasks.boost_low_volume_transactions import ProjectTransactions
from sentry.dynamic_sampling.tasks.common import (
ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL,
OrganizationDataVolume,
)
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.search.eap.constants import SAMPLING_MODE_HIGHEST_ACCURACY
from sentry.search.eap.types import SearchResolverConfig
from sentry.search.events.types import SnubaParams
from sentry.snuba.referrer import Referrer
from sentry.snuba.spans_rpc import Spans


class OrganizationVolumeConfig(Protocol):
organization: Organization
projects: list[Project]


class DynamicSamplingQueryFilters(StrEnum):
IS_SEGMENT = "sentry.is_segment:true"

Expand Down Expand Up @@ -86,7 +92,7 @@ def run_eap_spans_table_query_in_chunks(


def get_eap_organization_volume(
config: BaseDynamicSamplingConfiguration,
config: OrganizationVolumeConfig,
time_interval: timedelta = ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL,
) -> OrganizationDataVolume | None:
end_time = datetime.now(UTC)
Expand Down Expand Up @@ -128,7 +134,7 @@ def get_eap_organization_volume(


def get_eap_project_volumes(
config: BaseDynamicSamplingConfiguration,
config: OrganizationVolumeConfig,
time_interval: timedelta = timedelta(hours=1),
) -> list[ProjectVolume]:
end_time = datetime.now(UTC)
Expand Down Expand Up @@ -177,7 +183,7 @@ def get_eap_project_volumes(


def get_eap_transaction_volumes(
config: BaseDynamicSamplingConfiguration,
config: OrganizationVolumeConfig,
time_interval: timedelta = ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL,
order_by_volume: Literal["asc", "desc"] = "asc",
max_transactions: int = 100,
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/dynamic_sampling/per_org/tasks/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ def run_calculations_per_org_task(org_id: OrganizationId) -> DynamicSamplingStat
if not config.projects:
return DynamicSamplingStatus.ORG_HAS_NO_PROJECTS

org_volume = get_eap_organization_volume(config)
if org_volume is None:
org_volume_5m = get_eap_organization_volume(config)
if org_volume_5m is None:
return DynamicSamplingStatus.NO_ORG_VOLUME

if config.should_balance_projects:
Expand Down
2 changes: 0 additions & 2 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,6 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:seer-wizard", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable the Seer issues view
manager.add("organizations:seer-issue-view", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable Autofix to use Seer Agent instead of legacy Celery pipeline
manager.add("organizations:autofix-on-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable autofix introspection for early stopping of autofix runs
manager.add("organizations:seer-autofix-introspection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable Seer Workflows in Slack (released, kept until overrides are removed)
Expand Down
37 changes: 31 additions & 6 deletions src/sentry/integrations/discord/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@ class DiscordOAuthApiSerializer(CamelSnakeSerializer):
guild_id = CharField(required=True)


class DiscordInitialDataSerializer(CamelSnakeSerializer):
"""Initial pipeline data for App Directory-originated Discord installs.

When a user installs from Discord's App Directory, Discord initiates OAuth
and redirects back to Sentry with `code` and `guild_id`. The frontend
forwards them here so the pipeline can skip its own OAuth step.
"""

code = CharField(required=False)
guild_id = CharField(required=False)
use_configure = CharField(required=False)


class DiscordOAuthApiStep:
"""API-mode OAuth step for Discord integration setup.

Expand All @@ -170,7 +183,18 @@ def __init__(
self.scopes = scopes
self.redirect_url = redirect_url

def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, str]:
def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]:
# App Directory installs arrive with OAuth already complete: code and
# guild_id are bound to state via initialData. Signal the frontend to
# advance immediately using those values instead of opening a popup.
if pipeline.fetch_state("use_configure"):
return {
"appDirectoryInstall": True,
"code": pipeline.fetch_state("code"),
"guildId": pipeline.fetch_state("guild_id"),
"state": pipeline.signature,
}

params = urlencode(
{
"client_id": self.client_id,
Expand Down Expand Up @@ -245,6 +269,9 @@ def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
),
]

def get_initial_data_serializer_cls(self) -> type[DiscordInitialDataSerializer]:
return DiscordInitialDataSerializer

def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
guild_id = str(state.get("guild_id"))

Expand All @@ -258,11 +285,9 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
except (ApiError, AttributeError):
guild_name = guild_id

discord_config = state.get(IntegrationProviderSlug.DISCORD.value, {})
if isinstance(discord_config, dict):
use_configure = discord_config.get("use_configure") == "1"
else:
use_configure = False
# App Directory installs initiated OAuth with configure_url as the
# redirect_uri, so token exchange must echo it back.
use_configure = state.get("use_configure") == "1"
url = self.configure_url if use_configure else self.setup_url

auth_code = str(state.get("code"))
Expand Down
10 changes: 5 additions & 5 deletions src/sentry/integrations/discord/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from django.urls import re_path

from sentry.integrations.discord.spec import DiscordMessagingSpec
from sentry.integrations.web.discord_extension_configuration import (
DiscordExtensionConfigurationView,
)
from sentry.integrations.discord.views.configure_redirect import DiscordConfigureRedirectView

from .webhooks.base import DiscordInteractionsEndpoint

Expand All @@ -13,10 +11,12 @@
DiscordInteractionsEndpoint.as_view(),
name="sentry-integration-discord-interactions",
),
# Discord App Directory extension install flow
# Discord App Directory's redirect_uri lands here after the user authorizes
# in Discord. We forward the OAuth params to the link view, which opens the
# install pipeline modal to finish the install.
re_path(
r"^configure/$",
DiscordExtensionConfigurationView.as_view(),
DiscordConfigureRedirectView.as_view(),
name="discord-extension-configuration",
),
]
Expand Down
16 changes: 16 additions & 0 deletions src/sentry/integrations/discord/views/configure_redirect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.views.generic.base import RedirectView

from sentry.web.frontend.base import control_silo_view


@control_silo_view
class DiscordConfigureRedirectView(RedirectView):
"""OAuth redirect target for Discord App Directory installs.

Forwards `code` and `guild_id` from Discord's OAuth callback to the
integration link view, which picks an org and opens the install pipeline.
"""

url = "/extensions/discord/link/"
query_string = True
permanent = False
13 changes: 0 additions & 13 deletions src/sentry/integrations/web/discord_extension_configuration.py

This file was deleted.

6 changes: 2 additions & 4 deletions src/sentry/middleware/integrations/parsers/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sentry.hybridcloud.outbox.category import WebhookProviderIdentifier
from sentry.integrations.discord.message_builder.base.flags import EPHEMERAL_FLAG
from sentry.integrations.discord.requests.base import DiscordRequest, DiscordRequestError
from sentry.integrations.discord.views.configure_redirect import DiscordConfigureRedirectView
from sentry.integrations.discord.views.link_identity import DiscordLinkIdentityView
from sentry.integrations.discord.views.unlink_identity import DiscordUnlinkIdentityView
from sentry.integrations.discord.webhooks.base import DiscordInteractionsEndpoint
Expand All @@ -22,9 +23,6 @@
)
from sentry.integrations.models.integration import Integration
from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders
from sentry.integrations.web.discord_extension_configuration import (
DiscordExtensionConfigurationView,
)
from sentry.middleware.integrations.tasks import convert_to_async_discord_response
from sentry.types.cell import Cell

Expand All @@ -38,7 +36,7 @@ class DiscordRequestParser(BaseRequestParser):
control_classes = [
DiscordLinkIdentityView,
DiscordUnlinkIdentityView,
DiscordExtensionConfigurationView,
DiscordConfigureRedirectView,
]

# Dynamically set to avoid RawPostDataException from double reads
Expand Down
5 changes: 3 additions & 2 deletions src/sentry/relocation/api/endpoints/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import timedelta
from functools import reduce
from string import Template
from typing import Any

from django.db import router
from django.db.models import Q
Expand Down Expand Up @@ -53,7 +54,7 @@
RELOCATION_FILE_SIZE_MEDIUM = 100 * 1024**2


def get_relocation_size_category(size) -> str:
def get_relocation_size_category(size: int) -> str:
if size < RELOCATION_FILE_SIZE_SMALL:
return "small"
elif size < RELOCATION_FILE_SIZE_MEDIUM:
Expand Down Expand Up @@ -81,7 +82,7 @@ def should_throttle_relocation(relocation_bucket_size: str) -> bool:
return True


class RelocationsPostSerializer(serializers.Serializer):
class RelocationsPostSerializer(serializers.Serializer[dict[str, Any]]):
file = serializers.FileField(required=True)
orgs = serializers.CharField(required=True, allow_blank=False, allow_null=False)
owner = serializers.CharField(
Expand Down
Loading
Loading