diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 7054f1d47db984..14186c95cfc971 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -19,7 +19,7 @@ sentry: 0835_add_schema_version_to_grouphash_metadata social_auth: 0002_default_auto_field -tempest: 0001_create_tempest_credentials_model +tempest: 0002_make_message_type_nullable uptime: 0026_region_mode_col diff --git a/pyproject.toml b/pyproject.toml index 6f4d107fec2ff5..f7cceddd0ce558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,11 +139,9 @@ module = [ "sentry.db.mixin", "sentry.db.postgres.base", "sentry.eventstore.models", - "sentry.identity.bitbucket.provider", "sentry.identity.gitlab.provider", "sentry.identity.oauth2", "sentry.identity.pipeline", - "sentry.identity.providers.dummy", "sentry.incidents.endpoints.bases", "sentry.incidents.endpoints.organization_alert_rule_details", "sentry.incidents.endpoints.organization_alert_rule_index", @@ -158,7 +156,6 @@ module = [ "sentry.integrations.github.issues", "sentry.integrations.github_enterprise.integration", "sentry.integrations.gitlab.client", - "sentry.integrations.gitlab.integration", "sentry.integrations.gitlab.issues", "sentry.integrations.jira.client", "sentry.integrations.jira.integration", @@ -192,9 +189,6 @@ module = [ "sentry.net.http", "sentry.net.socket", "sentry.notifications.notifications.activity.base", - "sentry.pipeline.base", - "sentry.pipeline.views.base", - "sentry.pipeline.views.nested", "sentry.plugins.config", "sentry.release_health.metrics_sessions_v2", "sentry.rules.actions.integrations.create_ticket.utils", @@ -402,6 +396,7 @@ module = [ "sentry.options.rollout", "sentry.organizations.*", "sentry.ownership.*", + "sentry.pipeline.*", "sentry.plugins.base.response", "sentry.plugins.base.view", "sentry.plugins.validators.*", diff --git a/src/sentry/api/endpoints/organization_spans_trace.py b/src/sentry/api/endpoints/organization_spans_trace.py index 5bbd578aaa8496..e85ed073f5765f 100644 --- a/src/sentry/api/endpoints/organization_spans_trace.py +++ b/src/sentry/api/endpoints/organization_spans_trace.py @@ -1,4 +1,5 @@ -from typing import TypedDict +from datetime import datetime +from typing import Any, TypedDict import sentry_sdk from django.http import HttpResponse @@ -12,20 +13,24 @@ from sentry.api.paginator import GenericOffsetPaginator from sentry.api.utils import handle_query_errors, update_snuba_params_with_timestamp from sentry.models.organization import Organization -from sentry.search.events.builder.spans_indexed import SpansIndexedQueryBuilder +from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams -from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer -from sentry.utils.validators import INVALID_ID_DETAILS, is_event_id +from sentry.snuba.spans_rpc import run_trace_query -class SnubaTrace(TypedDict): - span_op: str - span_description: str - id: str +class SerializedEvent(TypedDict): + children: list["SerializedEvent"] + event_id: str + parent_span_id: str | None + project_id: int + project_slug: str + start_timestamp: datetime | None + end_timestamp: datetime | None + transaction: str + description: str + duration: float is_transaction: bool - start_ts: float - finish_ts: float @region_silo_endpoint @@ -34,46 +39,35 @@ class OrganizationSpansTraceEndpoint(OrganizationEventsV2EndpointBase): "GET": ApiPublishStatus.PRIVATE, } - @sentry_sdk.tracing.trace - def query_trace_data(self, snuba_params: SnubaParams, trace_id: str) -> list[SnubaTrace]: - builder = SpansIndexedQueryBuilder( - Dataset.SpansIndexed, - params={}, - snuba_params=snuba_params, - query=f"trace:{trace_id}", - selected_columns=[ - "span.op", - "span.description", - "id", - "is_transaction", - "precise.start_ts", - "precise.finish_ts", - ], - orderby="precise.start_ts", - limit=10_000, + def serialize_rpc_span(self, span: dict[str, Any]) -> SerializedEvent: + return SerializedEvent( + children=[self.serialize_rpc_span(child) for child in span["children"]], + event_id=span["id"], + project_id=span["project.id"], + project_slug=span["project.slug"], + parent_span_id=None if span["parent_span"] == "0" * 16 else span["parent_span"], + start_timestamp=span["precise.start_ts"], + end_timestamp=span["precise.finish_ts"], + duration=span["span.duration"], + transaction=span["transaction"], + is_transaction=span["is_transaction"], + description=span["description"], ) - query_result = builder.run_query(referrer=Referrer.API_SPANS_TRACE_VIEW.value) - result: list[SnubaTrace] = [] - measurement_transaction_count = 0 - for row in query_result["data"]: - result.append( - { - "span_op": row["span.op"], - "span_description": row["span.description"], - "id": row["id"], - "is_transaction": row["is_transaction"] == 1, - "start_ts": row["precise.start_ts"], - "finish_ts": row["precise.finish_ts"], - } - ) - if row["is_transaction"] == 1: - measurement_transaction_count += 1 - sentry_sdk.set_measurement("spans_trace.count.transactions", measurement_transaction_count) - sentry_sdk.set_measurement("spans_trace.count.spans", len(result)) - sentry_sdk.set_measurement( - "spans_trace.count.non_transactions", len(result) - measurement_transaction_count + + @sentry_sdk.tracing.trace + def query_trace_data(self, snuba_params: SnubaParams, trace_id: str) -> list[SerializedEvent]: + trace_data = run_trace_query( + trace_id, snuba_params, Referrer.API_TRACE_VIEW_GET_EVENTS.value, SearchResolverConfig() ) - return result + result = [] + id_to_event = {event["id"]: event for event in trace_data} + for span in trace_data: + if span["parent_span"] in id_to_event: + parent = id_to_event[span["parent_span"]] + parent["children"].append(span) + else: + result.append(span) + return [self.serialize_rpc_span(root) for root in result] def has_feature(self, organization: Organization, request: Request) -> bool: return bool( @@ -92,16 +86,8 @@ def get(self, request: Request, organization: Organization, trace_id: str) -> Ht update_snuba_params_with_timestamp(request, snuba_params) - # Bias the results to include any given event_id - note because this loads spans without taking trace topology - # into account, the descendents of this event might not be in the response - event_id = request.GET.get("event_id") or request.GET.get("eventId") - - # Only need to validate event_id as trace_id is validated in the URL - if event_id and not is_event_id(event_id): - return Response({"detail": INVALID_ID_DETAILS.format("Event ID")}, status=400) - - def data_fn(offset: int, limit: int) -> list[SnubaTrace]: - """API requires pagination even though it doesn't really work yet... ignoring limit and offset for now""" + def data_fn(offset: int, limit: int) -> list[SerializedEvent]: + """offset and limit don't mean anything on this endpoint currently""" with handle_query_errors(): spans = self.query_trace_data(snuba_params, trace_id) return spans diff --git a/src/sentry/api/event_search.py b/src/sentry/api/event_search.py index 997788b0e4ced0..f73af1f0d6edce 100644 --- a/src/sentry/api/event_search.py +++ b/src/sentry/api/event_search.py @@ -916,7 +916,7 @@ def visit_aggregate_duration_filter(self, node, children): try: # Even if the search value matches duration format, only act as # duration for certain columns - result_type = self.builder.get_function_result_type(search_key.name) + result_type = self.get_function_result_type(search_key.name) if result_type == "duration" or result_type in DURATION_UNITS: aggregate_value = parse_duration(*search_value) @@ -957,7 +957,7 @@ def visit_aggregate_percentage_filter(self, node, children): try: # Even if the search value matches percentage format, only act as # percentage for certain columns - result_type = self.builder.get_function_result_type(search_key.name) + result_type = self.get_function_result_type(search_key.name) if result_type == "percentage": aggregate_value = parse_percentage(search_value) except ValueError: diff --git a/src/sentry/auth/helper.py b/src/sentry/auth/helper.py index e6d8f04a5bfa82..1dc6fa344a5427 100644 --- a/src/sentry/auth/helper.py +++ b/src/sentry/auth/helper.py @@ -729,11 +729,13 @@ def __init__( self.organization: RpcOrganization = self.organization self.provider: Provider = self.provider - def get_provider(self, provider_key: str | None, **kwargs) -> PipelineProvider: + def get_provider( + self, provider_key: str | None, *, organization: RpcOrganization | None + ) -> PipelineProvider: if self.provider_model: return cast(PipelineProvider, self.provider_model.get_provider()) elif provider_key: - return super().get_provider(provider_key) + return super().get_provider(provider_key, organization=organization) else: raise NotImplementedError diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 9cf9e3c100ac57..f3461db631cf8b 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -411,6 +411,10 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:trace-view-quota-exceeded-banner", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable feature to use span only trace endpoint. manager.add("organizations:trace-spans-format", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable feature to load new traces onboarding guide. + manager.add("organizations:traces-onboarding-guide", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable feature to load traces schema hints. + manager.add("organizations:traces-schema-hints", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Extraction metrics for transactions during ingestion. manager.add("organizations:transaction-metrics-extraction", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Mark URL transactions scrubbed by regex patterns as "sanitized". diff --git a/src/sentry/identity/bitbucket/provider.py b/src/sentry/identity/bitbucket/provider.py index c90b3c4905a3e8..ea75a035ac9888 100644 --- a/src/sentry/identity/bitbucket/provider.py +++ b/src/sentry/identity/bitbucket/provider.py @@ -1,7 +1,8 @@ -from django.http import HttpResponse, HttpResponseRedirect +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase, HttpResponseRedirect from sentry.identity.base import Provider -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.utils.http import absolute_uri @@ -13,11 +14,8 @@ def get_pipeline_views(self) -> list[PipelineView]: return [BitbucketLoginView()] -from rest_framework.request import Request - - class BitbucketLoginView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: from sentry.integrations.base import IntegrationDomain from sentry.integrations.utils.metrics import ( IntegrationPipelineViewEvent, diff --git a/src/sentry/identity/oauth2.py b/src/sentry/identity/oauth2.py index 13f46002cae698..c17aee301fe559 100644 --- a/src/sentry/identity/oauth2.py +++ b/src/sentry/identity/oauth2.py @@ -7,7 +7,9 @@ from urllib.parse import parse_qsl, urlencode import orjson -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseRedirect +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -225,9 +227,6 @@ def refresh_identity(self, identity: Identity, **kwargs: Any) -> None: identity.update(data=identity.data) -from rest_framework.request import Request - - def record_event(event: IntegrationPipelineViewType, provider: str): from sentry.identity import default_manager as identity_manager @@ -271,7 +270,7 @@ def get_authorize_params(self, state, redirect_uri): } @method_decorator(csrf_exempt) - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: with record_event(IntegrationPipelineViewType.OAUTH_LOGIN, pipeline.provider.key).capture(): for param in ("code", "error", "state"): if param in request.GET: @@ -314,7 +313,7 @@ def get_token_params(self, code, redirect_uri): "client_secret": self.client_secret, } - def exchange_token(self, request: Request, pipeline, code): + def exchange_token(self, request: HttpRequest, pipeline: Pipeline, code: str) -> dict[str, str]: with record_event( IntegrationPipelineViewType.TOKEN_EXCHANGE, pipeline.provider.key ).capture() as lifecycle: @@ -358,7 +357,7 @@ def exchange_token(self, request: Request, pipeline, code): "error_description": "We were not able to parse a JSON response, please try again.", } - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: with record_event( IntegrationPipelineViewType.OAUTH_CALLBACK, pipeline.provider.key ).capture() as lifecycle: diff --git a/src/sentry/identity/pipeline.py b/src/sentry/identity/pipeline.py index 9861ebcef4ed18..8480624fe46e74 100644 --- a/src/sentry/identity/pipeline.py +++ b/src/sentry/identity/pipeline.py @@ -9,7 +9,6 @@ IntegrationPipelineViewEvent, IntegrationPipelineViewType, ) -from sentry.models.organization import Organization from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline import Pipeline, PipelineProvider from sentry.users.models.identity import Identity, IdentityProvider @@ -26,9 +25,9 @@ class IdentityProviderPipeline(Pipeline): provider_model_cls = IdentityProvider # TODO(iamrajjoshi): Delete this after Azure DevOps migration is complete - def get_provider(self, provider_key: str, **kwargs) -> PipelineProvider: - if kwargs.get("organization"): - organization: Organization | RpcOrganization = kwargs["organization"] + def get_provider( + self, provider_key: str, *, organization: RpcOrganization | None + ) -> PipelineProvider: if provider_key == "vsts" and features.has( "organizations:migrate-azure-devops-integration", organization ): @@ -37,7 +36,7 @@ def get_provider(self, provider_key: str, **kwargs) -> PipelineProvider: if provider_key == "vsts_login" and options.get("vsts.social-auth-migration"): provider_key = "vsts_login_new" - return super().get_provider(provider_key) + return super().get_provider(provider_key, organization=organization) def finish_pipeline(self): with IntegrationPipelineViewEvent( diff --git a/src/sentry/identity/providers/dummy.py b/src/sentry/identity/providers/dummy.py index 1e0d8658c990b9..3af423ab5fba01 100644 --- a/src/sentry/identity/providers/dummy.py +++ b/src/sentry/identity/providers/dummy.py @@ -1,17 +1,18 @@ from typing import Any from django.http import HttpResponse -from rest_framework.request import Request +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from sentry.identity.base import Provider -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.users.models.identity import Identity __all__ = ("DummyProvider",) class AskEmail(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "email" in request.POST: pipeline.bind_state("email", request.POST.get("email")) return pipeline.next_step() diff --git a/src/sentry/identity/vsts/provider.py b/src/sentry/identity/vsts/provider.py index 5e23b5bc24514a..e9a74ad2edd39f 100644 --- a/src/sentry/identity/vsts/provider.py +++ b/src/sentry/identity/vsts/provider.py @@ -5,11 +5,12 @@ import orjson from django.core.exceptions import PermissionDenied -from rest_framework.request import Request +from django.http.request import HttpRequest from sentry import http, options from sentry.identity.oauth2 import OAuth2CallbackView, OAuth2LoginView, OAuth2Provider, record_event from sentry.integrations.utils.metrics import IntegrationPipelineViewType +from sentry.pipeline.base import Pipeline from sentry.pipeline.views.base import PipelineView from sentry.users.models.identity import Identity from sentry.utils.http import absolute_uri @@ -123,7 +124,7 @@ def build_identity(self, data): class VSTSOAuth2CallbackView(OAuth2CallbackView): - def exchange_token(self, request: Request, pipeline, code): + def exchange_token(self, request: HttpRequest, pipeline: Pipeline, code: str) -> dict[str, str]: from sentry.http import safe_urlopen, safe_urlread with record_event( @@ -240,7 +241,7 @@ def get_authorize_params(self, state, redirect_uri): class VSTSNewOAuth2CallbackView(OAuth2CallbackView): - def exchange_token(self, request: Request, pipeline, code): + def exchange_token(self, request: HttpRequest, pipeline: Pipeline, code: str) -> dict[str, str]: from urllib.parse import parse_qsl from sentry.http import safe_urlopen, safe_urlread diff --git a/src/sentry/integrations/aws_lambda/integration.py b/src/sentry/integrations/aws_lambda/integration.py index 8a589fffceee8b..292e6f736c9b23 100644 --- a/src/sentry/integrations/aws_lambda/integration.py +++ b/src/sentry/integrations/aws_lambda/integration.py @@ -5,10 +5,9 @@ from typing import Any from botocore.exceptions import ClientError +from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request -from rest_framework.response import Response from sentry import analytics, options from sentry.integrations.base import ( @@ -22,7 +21,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.organizations.services.organization import RpcOrganizationSummary, organization_service -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.projects.services.project import project_service from sentry.silo.base import control_silo_function from sentry.users.models.user import User @@ -257,7 +256,7 @@ def post_install( class AwsLambdaProjectSelectPipelineView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: # if we have the projectId, go to the next step if "projectId" in request.GET: pipeline.bind_state("project_id", request.GET["projectId"]) @@ -283,7 +282,7 @@ def dispatch(self, request: Request, pipeline) -> HttpResponseBase: class AwsLambdaCloudFormationPipelineView(PipelineView): - def dispatch(self, request: Request, pipeline) -> Response: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: curr_step = 0 if pipeline.fetch_state("skipped_project_select") else 1 def render_response(error=None): @@ -345,7 +344,7 @@ def render_response(error=None): class AwsLambdaListFunctionsPipelineView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if request.method == "POST": raw_data = request.POST data = {} @@ -373,7 +372,7 @@ def dispatch(self, request: Request, pipeline) -> HttpResponseBase: class AwsLambdaSetupLayerPipelineView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "finish_pipeline" in request.GET: return pipeline.finish_pipeline() diff --git a/src/sentry/integrations/bitbucket/integration.py b/src/sentry/integrations/bitbucket/integration.py index 70eb622bea6a40..635ad22dbadccd 100644 --- a/src/sentry/integrations/bitbucket/integration.py +++ b/src/sentry/integrations/bitbucket/integration.py @@ -2,10 +2,10 @@ from typing import Any +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.datastructures import OrderedSet from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request -from rest_framework.response import Response from sentry.identity.pipeline import IdentityProviderPipeline from sentry.integrations.base import ( @@ -30,7 +30,7 @@ from sentry.models.apitoken import generate_token from sentry.models.repository import Repository from sentry.organizations.services.organization import RpcOrganizationSummary -from sentry.pipeline import NestedPipelineView, PipelineView +from sentry.pipeline import NestedPipelineView, Pipeline, PipelineView from sentry.shared_integrations.exceptions import ApiError from sentry.utils.http import absolute_uri @@ -261,7 +261,7 @@ def setup(self): class VerifyInstallation(PipelineView): - def dispatch(self, request: Request, pipeline) -> Response: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: with IntegrationPipelineViewEvent( IntegrationPipelineViewType.VERIFY_INSTALLATION, IntegrationDomain.SOURCE_CODE_MANAGEMENT, diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index fb32de6da6787c..797f2fcc71d912 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -7,11 +7,12 @@ from cryptography.hazmat.primitives.serialization import load_pem_private_key from django import forms from django.core.validators import URLValidator -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseRedirect +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt -from rest_framework.request import Request from sentry.integrations.base import ( FeatureDescription, @@ -32,7 +33,7 @@ ) from sentry.models.repository import Repository from sentry.organizations.services.organization import RpcOrganizationSummary -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.users.models.identity import Identity from sentry.web.helpers import render_to_response @@ -140,7 +141,7 @@ class InstallationConfigView(PipelineView): Collect the OAuth client credentials from the user. """ - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if request.method == "POST": form = InstallationForm(request.POST) if form.is_valid(): @@ -165,7 +166,7 @@ class OAuthLoginView(PipelineView): """ @method_decorator(csrf_exempt) - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: with IntegrationPipelineViewEvent( IntegrationPipelineViewType.OAUTH_LOGIN, IntegrationDomain.SOURCE_CODE_MANAGEMENT, @@ -205,7 +206,7 @@ class OAuthCallbackView(PipelineView): """ @method_decorator(csrf_exempt) - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: with IntegrationPipelineViewEvent( IntegrationPipelineViewType.OAUTH_CALLBACK, IntegrationDomain.SOURCE_CODE_MANAGEMENT, diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py index ac06671454bf29..e9d95f726ffb3d 100644 --- a/src/sentry/integrations/discord/integration.py +++ b/src/sentry/integrations/discord/integration.py @@ -5,6 +5,8 @@ from urllib.parse import urlencode from django.http import HttpResponseRedirect +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ from sentry import options @@ -20,6 +22,7 @@ from sentry.integrations.discord.types import DiscordPermissions from sentry.integrations.models.integration import Integration from sentry.organizations.services.organization.model import RpcOrganizationSummary +from sentry.pipeline.base import Pipeline from sentry.pipeline.views.base import PipelineView from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.utils.http import absolute_uri @@ -282,7 +285,7 @@ def __init__(self, params): self.params = params super().__init__() - def dispatch(self, request, pipeline): + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "guild_id" not in request.GET or "code" not in request.GET: state = pipeline.fetch_state(key="discord") or {} redirect_uri = ( diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index c135eb96910e1b..f4cf9b739fb84e 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -4,7 +4,8 @@ from typing import Any from django.http import HttpResponse -from rest_framework.request import Request +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from sentry.integrations.base import ( FeatureDescription, @@ -22,7 +23,7 @@ from sentry.integrations.source_code_management.repository import RepositoryIntegration from sentry.models.repository import Repository from sentry.organizations.services.organization import RpcOrganizationSummary -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.plugins.migrator import Migrator from sentry.shared_integrations.exceptions import IntegrationError from sentry.users.services.user import RpcUser @@ -39,7 +40,7 @@ class ExampleSetupView(PipelineView): """ - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "name" in request.POST: pipeline.bind_state("name", request.POST["name"]) return pipeline.next_step() diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index 08fa2b74ef83c0..fa29a9a4dc4739 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -7,11 +7,11 @@ from typing import Any from urllib.parse import parse_qsl +from django.http.request import HttpRequest from django.http.response import HttpResponseBase, HttpResponseRedirect from django.urls import reverse from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request from sentry import features, options from sentry.constants import ObjectStatus @@ -403,7 +403,7 @@ def record_event(event: IntegrationPipelineViewType): class OAuthLoginView(PipelineView): - def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: with record_event(IntegrationPipelineViewType.OAUTH_LOGIN).capture() as lifecycle: self.active_organization = determine_active_organization(request) lifecycle.add_extra( @@ -480,7 +480,7 @@ def get_app_url(self) -> str: name = options.get("github-app.name") return f"https://github.com/apps/{slugify(name)}" - def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: with record_event(IntegrationPipelineViewType.GITHUB_INSTALLATION).capture() as lifecycle: installation_id = request.GET.get( "installation_id", pipeline.fetch_state("installation_id") diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index 82a86163d41fd4..f3c87d47818d00 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -5,9 +5,10 @@ from urllib.parse import urlparse from django import forms -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseRedirect +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request from sentry import http from sentry.identity.pipeline import IdentityProviderPipeline @@ -26,7 +27,7 @@ from sentry.integrations.source_code_management.repository import RepositoryIntegration from sentry.models.repository import Repository from sentry.organizations.services.organization import RpcOrganizationSummary -from sentry.pipeline import NestedPipelineView, PipelineView +from sentry.pipeline import NestedPipelineView, Pipeline, PipelineView from sentry.shared_integrations.constants import ERR_INTERNAL, ERR_UNAUTHORIZED from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.utils import jwt @@ -304,7 +305,7 @@ def __init__(self, *args, **kwargs): class InstallationConfigView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if request.method == "POST": form = InstallationForm(request.POST) if form.is_valid(): @@ -494,7 +495,7 @@ def get_app_url(self, installation_data): name = installation_data.get("name") return f"https://{url}/github-apps/{name}" - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: installation_data = pipeline.fetch_state(key="installation_data") if "installation_id" in request.GET: diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index b3944a30cfd1d5..069a746b636e10 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -5,9 +5,9 @@ from urllib.parse import urlparse from django import forms -from django.http import HttpResponse +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request from sentry.identity.gitlab import get_oauth_data, get_user_info from sentry.identity.gitlab.provider import GitlabIdentityProvider @@ -22,7 +22,7 @@ from sentry.integrations.source_code_management.commit_context import CommitContextIntegration from sentry.integrations.source_code_management.repository import RepositoryIntegration from sentry.models.repository import Repository -from sentry.pipeline import NestedPipelineView, PipelineView +from sentry.pipeline import NestedPipelineView, Pipeline, PipelineView from sentry.shared_integrations.exceptions import ( ApiError, IntegrationError, @@ -252,7 +252,7 @@ def clean_url(self): class InstallationConfigView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "goback" in request.GET: pipeline.state.step_index = 0 return pipeline.current_step() @@ -294,7 +294,7 @@ def dispatch(self, request: Request, pipeline) -> HttpResponse: class InstallationGuideView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "completed_installation_guide" in request.GET: return pipeline.next_step() return render_to_response( diff --git a/src/sentry/integrations/jira_server/integration.py b/src/sentry/integrations/jira_server/integration.py index 8871c56ba10ef5..0ef57c2d145ade 100644 --- a/src/sentry/integrations/jira_server/integration.py +++ b/src/sentry/integrations/jira_server/integration.py @@ -10,12 +10,13 @@ from cryptography.hazmat.primitives.serialization import load_pem_private_key from django import forms from django.core.validators import URLValidator -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseRedirect +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views.decorators.csrf import csrf_exempt -from rest_framework.request import Request from sentry import features from sentry.integrations.base import ( @@ -33,7 +34,7 @@ from sentry.integrations.services.integration import integration_service from sentry.models.group import Group from sentry.organizations.services.organization.service import organization_service -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.shared_integrations.exceptions import ( ApiError, ApiHostError, @@ -169,7 +170,7 @@ class InstallationConfigView(PipelineView): Collect the OAuth client credentials from the user. """ - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if request.method == "POST": form = InstallationForm(request.POST) if form.is_valid(): @@ -194,7 +195,7 @@ class OAuthLoginView(PipelineView): """ @method_decorator(csrf_exempt) - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "oauth_token" in request.GET: return pipeline.next_step() @@ -234,7 +235,7 @@ class OAuthCallbackView(PipelineView): """ @method_decorator(csrf_exempt) - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: config = pipeline.fetch_state("installation_data") client = JiraServerSetupClient( config.get("url"), diff --git a/src/sentry/integrations/msteams/integration.py b/src/sentry/integrations/msteams/integration.py index bc4c37b2087b85..19ca9d34a60ee2 100644 --- a/src/sentry/integrations/msteams/integration.py +++ b/src/sentry/integrations/msteams/integration.py @@ -3,9 +3,9 @@ import logging from typing import Any +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request -from rest_framework.response import Response from sentry import options from sentry.integrations.base import ( @@ -17,7 +17,7 @@ ) from sentry.integrations.models.integration import Integration from sentry.organizations.services.organization import RpcOrganizationSummary -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from .card_builder.installation import ( build_personal_installation_confirmation_message, @@ -135,5 +135,5 @@ def post_install( class MsTeamsPipelineView(PipelineView): - def dispatch(self, request: Request, pipeline) -> Response: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: return pipeline.next_step() diff --git a/src/sentry/integrations/opsgenie/integration.py b/src/sentry/integrations/opsgenie/integration.py index ef8f2878cb3338..ec75480d78ffe0 100644 --- a/src/sentry/integrations/opsgenie/integration.py +++ b/src/sentry/integrations/opsgenie/integration.py @@ -5,9 +5,9 @@ from typing import Any from django import forms -from django.http import HttpResponse +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request from rest_framework.serializers import ValidationError from sentry.constants import ObjectStatus @@ -24,7 +24,7 @@ from sentry.integrations.opsgenie.metrics import record_event from sentry.integrations.opsgenie.tasks import migrate_opsgenie_plugin from sentry.organizations.services.organization import RpcOrganizationSummary -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.shared_integrations.exceptions import ( ApiError, ApiRateLimitedError, @@ -102,7 +102,7 @@ class InstallationForm(forms.Form): class InstallationConfigView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponse: # type: ignore[explicit-override, override] + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if request.method == "POST": form = InstallationForm(request.POST) if form.is_valid(): diff --git a/src/sentry/integrations/pagerduty/integration.py b/src/sentry/integrations/pagerduty/integration.py index b11cd0cffe829b..3b872af576572e 100644 --- a/src/sentry/integrations/pagerduty/integration.py +++ b/src/sentry/integrations/pagerduty/integration.py @@ -5,9 +5,10 @@ import orjson from django.db import router, transaction -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseRedirect +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request from sentry import options from sentry.integrations.base import ( @@ -22,7 +23,7 @@ from sentry.integrations.on_call.metrics import OnCallInteractionType from sentry.integrations.pagerduty.metrics import record_event from sentry.organizations.services.organization import RpcOrganizationSummary -from sentry.pipeline import PipelineView +from sentry.pipeline import Pipeline, PipelineView from sentry.shared_integrations.exceptions import IntegrationError from sentry.utils.http import absolute_uri @@ -223,7 +224,7 @@ def get_app_url(self, account_name=None): return f"https://{account_name}.pagerduty.com/install/integration?app_id={app_id}&redirect_url={setup_url}&version=2" - def dispatch(self, request: Request, pipeline) -> HttpResponse: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: if "config" in request.GET: pipeline.bind_state("config", request.GET["config"]) return pipeline.next_step() diff --git a/src/sentry/integrations/vsts/integration.py b/src/sentry/integrations/vsts/integration.py index c4dc1381f699e6..84830bf6cd1d93 100644 --- a/src/sentry/integrations/vsts/integration.py +++ b/src/sentry/integrations/vsts/integration.py @@ -8,7 +8,7 @@ from urllib.parse import parse_qs, quote, urlencode, urlparse from django import forms -from django.http import HttpRequest +from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.translation import gettext as _ diff --git a/src/sentry/integrations/vsts_extension/integration.py b/src/sentry/integrations/vsts_extension/integration.py index a7dbfbc3854b61..1a380d14d73dcd 100644 --- a/src/sentry/integrations/vsts_extension/integration.py +++ b/src/sentry/integrations/vsts_extension/integration.py @@ -3,8 +3,8 @@ from django.contrib import messages from django.http import HttpResponseRedirect +from django.http.request import HttpRequest from django.http.response import HttpResponseBase -from rest_framework.request import Request from sentry.integrations.vsts.integration import AccountConfigView, VstsIntegrationProvider from sentry.pipeline import Pipeline, PipelineView @@ -35,7 +35,7 @@ def build_integration(self, state: MutableMapping[str, Any]) -> Mapping[str, Any class VstsExtensionFinishedView(PipelineView): - def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: response = pipeline.finish_pipeline() integration = getattr(pipeline, "integration", None) diff --git a/src/sentry/pipeline/README.md b/src/sentry/pipeline/README.md index cb70484432a9f0..0a0a9fc923ded0 100644 --- a/src/sentry/pipeline/README.md +++ b/src/sentry/pipeline/README.md @@ -82,7 +82,7 @@ An example pipeline view might be a form that asks the user for some input. ```python class GetUserInput(PipelineView): - def dispatch(self, request, pipeline): + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: # The pipeline supports a generic error method that will render a # pipeline error view if 'my_data' not in request.POST: diff --git a/src/sentry/pipeline/base.py b/src/sentry/pipeline/base.py index 94ea14cce1c144..1a52f7f147b211 100644 --- a/src/sentry/pipeline/base.py +++ b/src/sentry/pipeline/base.py @@ -7,8 +7,6 @@ from django.http.request import HttpRequest from django.http.response import HttpResponseBase -from django.views import View -from rest_framework.request import Request from sentry import analytics from sentry.db.models import Model @@ -95,13 +93,14 @@ def unpack_state(cls, request: HttpRequest) -> PipelineRequestState | None: return PipelineRequestState(state, provider_model, organization, provider_key) - def get_provider(self, provider_key: str, **kwargs) -> PipelineProvider: - provider: PipelineProvider = self.provider_manager.get(provider_key) - return provider + def get_provider( + self, provider_key: str, *, organization: RpcOrganization | None + ) -> PipelineProvider: + return self.provider_manager.get(provider_key) def __init__( self, - request: Request | HttpRequest, + request: HttpRequest, provider_key: str, organization: Organization | RpcOrganization | None = None, provider_model: Model | None = None, @@ -111,14 +110,14 @@ def __init__( bind_organization_context(organization) self.request = request - self.organization: RpcOrganization | None = ( + self.organization = ( serialize_rpc_organization(organization) if isinstance(organization, Organization) else organization ) self.state = self.session_store_cls(request, self.pipeline_name, ttl=PIPELINE_STATE_TTL) self.provider_model = provider_model - self.provider = self.get_provider(provider_key, organization=organization) + self.provider = self.get_provider(provider_key, organization=self.organization) self.config = config or {} self.provider.set_pipeline(self) @@ -143,20 +142,18 @@ def get_pipeline_views(self) -> Sequence[PipelineView | Callable[[], PipelineVie return self.provider.get_pipeline_views() def is_valid(self) -> bool: - _is_valid: bool = ( + return ( self.state.is_valid() and self.state.signature == self.signature and self.state.step_index is not None ) - return _is_valid def initialize(self) -> None: self.state.regenerate(self.get_initial_state()) def get_initial_state(self) -> Mapping[str, Any]: - user: Any = self.request.user return { - "uid": user.id if user.is_authenticated else None, + "uid": self.request.user.id if self.request.user.is_authenticated else None, "provider_model_id": self.provider_model.id if self.provider_model else None, "provider_key": self.provider.key, "org_id": self.organization.id if self.organization else None, @@ -186,7 +183,7 @@ def current_step(self) -> HttpResponseBase: return self.dispatch_to(step) - def dispatch_to(self, step: View) -> HttpResponseBase: + def dispatch_to(self, step: PipelineView) -> HttpResponseBase: """ Dispatch to a view expected by this pipeline. @@ -221,10 +218,9 @@ def next_step(self, step_size: int = 1) -> HttpResponseBase: analytics_entry = self.get_analytics_entry() if analytics_entry and self.organization: - user: Any = self.request.user analytics.record( analytics_entry.event_type, - user_id=user.id, + user_id=self.request.user.id, organization_id=self.organization.id, integration=self.provider.key, step_index=self.step_index, diff --git a/src/sentry/pipeline/types.py b/src/sentry/pipeline/types.py index 42a18b7cf3dcc1..7d5005b8d2edc0 100644 --- a/src/sentry/pipeline/types.py +++ b/src/sentry/pipeline/types.py @@ -13,7 +13,7 @@ class PipelineRequestState: """Initial pipeline attributes from a request.""" state: PipelineSessionStore - provider_model: Model + provider_model: Model | None organization: RpcOrganization | None provider_key: str diff --git a/src/sentry/pipeline/views/base.py b/src/sentry/pipeline/views/base.py index b5f0ae4e98b0bb..1ee422b95f2c10 100644 --- a/src/sentry/pipeline/views/base.py +++ b/src/sentry/pipeline/views/base.py @@ -1,26 +1,27 @@ +from __future__ import annotations + import abc from collections.abc import Mapping from typing import TYPE_CHECKING, Any +from django.http.request import HttpRequest from django.http.response import HttpResponseBase -from rest_framework.request import Request from sentry.utils import json -from sentry.web.frontend.base import BaseView from sentry.web.helpers import render_to_response if TYPE_CHECKING: from sentry.pipeline.base import Pipeline -class PipelineView(BaseView, abc.ABC): +class PipelineView(abc.ABC): """ A class implementing the PipelineView may be used in a PipelineProviders get_pipeline_views list. """ @abc.abstractmethod - def dispatch(self, request: Request, pipeline: "Pipeline") -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: """ Called on request, the active pipeline is passed in which can and should be used to bind data and traverse the pipeline. @@ -28,7 +29,7 @@ def dispatch(self, request: Request, pipeline: "Pipeline") -> HttpResponseBase: @staticmethod def render_react_view( - request: Request, + request: HttpRequest, pipeline_name: str, props: Mapping[str, Any], ) -> HttpResponseBase: diff --git a/src/sentry/pipeline/views/nested.py b/src/sentry/pipeline/views/nested.py index 410e87c60e7d94..6b549167566a63 100644 --- a/src/sentry/pipeline/views/nested.py +++ b/src/sentry/pipeline/views/nested.py @@ -3,8 +3,8 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any +from django.http.request import HttpRequest from django.http.response import HttpResponseBase -from rest_framework.request import Request from sentry.pipeline.views.base import PipelineView @@ -45,7 +45,7 @@ def finish_pipeline(self) -> HttpResponseBase: self.pipeline_cls = NestedPipeline - def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: nested_pipeline = self.pipeline_cls( organization=pipeline.organization, request=request, diff --git a/src/sentry/snuba/spans_rpc.py b/src/sentry/snuba/spans_rpc.py index d621b707497ea9..ac35f1dc896e0b 100644 --- a/src/sentry/snuba/spans_rpc.py +++ b/src/sentry/snuba/spans_rpc.py @@ -5,14 +5,16 @@ from typing import Any import sentry_sdk +from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeries, TimeSeriesRequest +from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeAggregation, AttributeKey from sentry_protos.snuba.v1.trace_item_filter_pb2 import AndFilter, OrFilter, TraceItemFilter from sentry.api.event_search import SearchFilter, SearchKey, SearchValue from sentry.exceptions import InvalidSearchQuery from sentry.search.eap.columns import ResolvedColumn, ResolvedFunction -from sentry.search.eap.constants import MAX_ROLLUP_POINTS, VALID_GRANULARITIES +from sentry.search.eap.constants import DOUBLE, INT, MAX_ROLLUP_POINTS, STRING, VALID_GRANULARITIES from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.span_columns import SPAN_DEFINITIONS from sentry.search.eap.types import CONFIDENCES, EAPResponse, SearchResolverConfig @@ -396,3 +398,55 @@ def _process_all_timeseries( result.sample_count[index][label] = data_point.sample_count return result + + +def run_trace_query( + trace_id: str, + params: SnubaParams, + referrer: str, + config: SearchResolverConfig, +) -> list[dict[str, Any]]: + trace_attributes = [ + "parent_span", + "description", + "op", + "is_transaction", + "transaction.span_id", + "transaction", + "precise.start_ts", + "precise.finish_ts", + "project.slug", + "project.id", + "span.duration", + ] + resolver = get_resolver(params=params, config=SearchResolverConfig()) + columns, _ = resolver.resolve_attributes(trace_attributes) + meta = resolver.resolve_meta(referrer=referrer) + request = GetTraceRequest( + meta=meta, + trace_id=trace_id, + items=[ + GetTraceRequest.TraceItem( + item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN, + attributes=[col.proto_definition for col in columns], + ) + ], + ) + response = snuba_rpc.get_trace_rpc(request) + spans = [] + columns_by_name = {col.proto_definition.name: col for col in columns} + for item_group in response.item_groups: + for span_item in item_group.items: + span: dict[str, Any] = {"id": span_item.id, "children": []} + for attribute in span_item.attributes: + resolved_column = columns_by_name[attribute.key.name] + if resolved_column.proto_definition.type == STRING: + span[resolved_column.public_alias] = attribute.value.val_str + elif resolved_column.proto_definition.type == DOUBLE: + span[resolved_column.public_alias] = attribute.value.val_double + elif resolved_column.search_type == "boolean": + span[resolved_column.public_alias] = attribute.value.val_int == 1 + elif resolved_column.proto_definition.type == INT: + span[resolved_column.public_alias] = attribute.value.val_int + spans.append(span) + return spans diff --git a/src/sentry/tempest/migrations/0002_make_message_type_nullable.py b/src/sentry/tempest/migrations/0002_make_message_type_nullable.py new file mode 100644 index 00000000000000..01c121839ee992 --- /dev/null +++ b/src/sentry/tempest/migrations/0002_make_message_type_nullable.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2025-02-25 13:26 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("tempest", "0001_create_tempest_credentials_model"), + ] + + operations = [ + migrations.AlterField( + model_name="tempestcredentials", + name="message_type", + field=models.CharField(default=None, max_length=20, null=True), + ), + ] diff --git a/src/sentry/tempest/models.py b/src/sentry/tempest/models.py index 0f221bef564ea5..5bf7c01aa69e63 100644 --- a/src/sentry/tempest/models.py +++ b/src/sentry/tempest/models.py @@ -26,7 +26,7 @@ class TempestCredentials(DefaultFieldsModel): # used to communicate the status of the latest actions with credentials message = models.TextField() message_type = models.CharField( - max_length=20, choices=MessageType.choices, default=MessageType.ERROR + max_length=20, choices=MessageType.choices, default=None, null=True ) client_id = models.CharField() diff --git a/src/sentry/tempest/tasks.py b/src/sentry/tempest/tasks.py index 44c53c82ba8f16..ea20854e6f9a6f 100644 --- a/src/sentry/tempest/tasks.py +++ b/src/sentry/tempest/tasks.py @@ -63,7 +63,8 @@ def fetch_latest_item_id(credentials_id: int, **kwargs) -> None: if "latest_id" in result: credentials.latest_fetched_item_id = result["latest_id"] credentials.message = "" - credentials.save(update_fields=["message", "latest_fetched_item_id"]) + credentials.message_type = MessageType.SUCCESS + credentials.save(update_fields=["message", "latest_fetched_item_id", "message_type"]) return elif "error" in result: if result["error"]["type"] == "invalid_credentials": diff --git a/src/sentry/testutils/cases.py b/src/sentry/testutils/cases.py index 4d3291d57d7d85..f9c697188ce2a4 100644 --- a/src/sentry/testutils/cases.py +++ b/src/sentry/testutils/cases.py @@ -3406,6 +3406,7 @@ def create_event( slow_db_performance_issue: bool = False, start_timestamp: datetime | None = None, store_event_kwargs: dict[str, Any] | None = None, + is_eap: bool = False, ) -> Event: if not store_event_kwargs: store_event_kwargs = {} @@ -3468,19 +3469,19 @@ def create_event( ), ): event = self.store_event(data, project_id=project_id, **store_event_kwargs) - spans = [] + spans_to_store = [] for span in data["spans"]: if span: - span.update({"event_id": event.event_id}) - spans.append( + span.update({"segment_id": event.event_id[:16], "event_id": event.event_id}) + spans_to_store.append( self.create_span( span, start_ts=datetime.fromtimestamp(span["start_timestamp"]), duration=int(span["timestamp"] - span["start_timestamp"]) * 1000, ) ) - spans.append(self.convert_event_data_to_span(event)) - self.store_spans(spans) + spans_to_store.append(self.convert_event_data_to_span(event)) + self.store_spans(spans_to_store, is_eap=is_eap) return event def convert_event_data_to_span(self, event: Event) -> dict[str, Any]: @@ -3496,15 +3497,17 @@ def convert_event_data_to_span(self, event: Event) -> dict[str, Any]: "span_id": trace_context["span_id"], "parent_span_id": trace_context.get("parent_span_id", "0" * 12), "description": event.data["transaction"], - "segment_id": uuid4().hex[:16], + "segment_id": event.event_id[:16], "group_raw": uuid4().hex[:16], "profile_id": uuid4().hex, "is_segment": True, # Multiply by 1000 cause it needs to be ms "start_timestamp_ms": int(start_ts * 1000), "timestamp": int(start_ts * 1000), + "start_timestamp_precise": start_ts, + "end_timestamp_precise": end_ts, "received": start_ts, - "duration_ms": int(end_ts - start_ts), + "duration_ms": int((end_ts - start_ts) * 1000), } ) if "parent_span_id" in trace_context: @@ -3512,10 +3515,11 @@ def convert_event_data_to_span(self, event: Event) -> dict[str, Any]: else: del span_data["parent_span_id"] - if "sentry_tags" in span_data: - span_data["sentry_tags"]["op"] = event.data["contexts"]["trace"]["op"] - else: - span_data["sentry_tags"] = {"op": event.data["contexts"]["trace"]["op"]} + if "sentry_tags" not in span_data: + span_data["sentry_tags"] = {} + + span_data["sentry_tags"]["op"] = event.data["contexts"]["trace"]["op"] + span_data["sentry_tags"]["transaction"] = event.data["transaction"] return span_data diff --git a/src/sentry/utils/samples.py b/src/sentry/utils/samples.py index f3ccda8e798758..7341544668ad13 100644 --- a/src/sentry/utils/samples.py +++ b/src/sentry/utils/samples.py @@ -184,6 +184,9 @@ def load_data( timestamp = timestamp - timedelta(microseconds=timestamp.microsecond % 1000) timestamp = timestamp.replace(tzinfo=timezone.utc) data.setdefault("timestamp", timestamp.timestamp()) + if start_timestamp: + start_timestamp = start_timestamp.replace(tzinfo=timezone.utc) + data.setdefault("start_timestamp", start_timestamp.timestamp()) if data.get("type") == "transaction": if start_timestamp is None: @@ -206,7 +209,7 @@ def load_data( data["contexts"]["trace"]["span_id"] = span_id if trace_context is not None: data["contexts"]["trace"].update(trace_context) - if spans: + if spans is not None: data["spans"] = spans for span in data.get("spans", []): diff --git a/src/sentry/utils/snuba_rpc.py b/src/sentry/utils/snuba_rpc.py index 7aac4ac6c0e7fc..ded77e8bcc970f 100644 --- a/src/sentry/utils/snuba_rpc.py +++ b/src/sentry/utils/snuba_rpc.py @@ -15,6 +15,7 @@ CreateSubscriptionRequest, CreateSubscriptionResponse, ) +from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest, GetTraceResponse from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest, TimeSeriesResponse from sentry_protos.snuba.v1.endpoint_trace_item_attributes_pb2 import ( TraceItemAttributeNamesRequest, @@ -84,6 +85,13 @@ def timeseries_rpc(requests: list[TimeSeriesRequest]) -> list[TimeSeriesResponse return _make_rpc_requests(timeseries_requests=requests).timeseries_response +def get_trace_rpc(request: GetTraceRequest) -> GetTraceResponse: + resp = _make_rpc_request("EndpointGetTrace", "v1", referrer=request.meta.referrer, req=request) + response = GetTraceResponse() + response.ParseFromString(resp.data) + return response + + def _make_rpc_requests( table_requests: list[TraceItemTableRequest] | None = None, timeseries_requests: list[TimeSeriesRequest] | None = None, diff --git a/static/app/components/arithmeticBuilder/index.tsx b/static/app/components/arithmeticBuilder/index.tsx index 9cdb0c7c8b9757..03358bab9dce01 100644 --- a/static/app/components/arithmeticBuilder/index.tsx +++ b/static/app/components/arithmeticBuilder/index.tsx @@ -10,7 +10,7 @@ import type { AggregateFunction, FunctionArgument, } from 'sentry/components/arithmeticBuilder/types'; -import {inputStyles} from 'sentry/components/input'; +import {inputStyles} from 'sentry/components/core/input'; import PanelProvider from 'sentry/utils/panelProvider'; export interface ArithmeticBuilderProps { diff --git a/static/app/components/comboBox/index.tsx b/static/app/components/comboBox/index.tsx index 80b5b15e97e890..c81bfdf8ff7a79 100644 --- a/static/app/components/comboBox/index.tsx +++ b/static/app/components/comboBox/index.tsx @@ -16,8 +16,8 @@ import { getHiddenOptions, getItemsWithKeys, } from 'sentry/components/compactSelect/utils'; +import {Input} from 'sentry/components/core/input'; import {GrowingInput} from 'sentry/components/growingInput'; -import Input from 'sentry/components/input'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {Overlay, PositionWrapper} from 'sentry/components/overlay'; diff --git a/static/app/components/confirmDelete.tsx b/static/app/components/confirmDelete.tsx index 9f3cd8b06133c4..ff08b87eedcac2 100644 --- a/static/app/components/confirmDelete.tsx +++ b/static/app/components/confirmDelete.tsx @@ -2,8 +2,8 @@ import {Fragment} from 'react'; import Confirm from 'sentry/components/confirm'; import {Alert} from 'sentry/components/core/alert'; +import {Input} from 'sentry/components/core/input'; import FieldGroup from 'sentry/components/forms/fieldGroup'; -import Input from 'sentry/components/input'; import {t} from 'sentry/locale'; interface Props diff --git a/static/app/components/core/alert/alertLink.stories.tsx b/static/app/components/core/alert/alertLink.stories.tsx index 55cc55bcccb77e..da8f738ed8ea28 100644 --- a/static/app/components/core/alert/alertLink.stories.tsx +++ b/static/app/components/core/alert/alertLink.stories.tsx @@ -7,7 +7,7 @@ import {IconMail} from 'sentry/icons'; import storyBook from 'sentry/stories/storyBook'; // eslint-disable-next-line import/no-webpack-loader-syntax -import types from '!!type-loader!sentry/components/core/alertLink'; +import types from '!!type-loader!sentry/components/core/alert/alertLink'; const DUMMY_LINK = '/stories'; diff --git a/static/app/components/core/alert/alertLink.tsx b/static/app/components/core/alert/alertLink.tsx index ca6a289b9820af..10b6a935e92dda 100644 --- a/static/app/components/core/alert/alertLink.tsx +++ b/static/app/components/core/alert/alertLink.tsx @@ -1,126 +1,3 @@ -import type React from 'react'; -import {css, type Theme} from '@emotion/react'; -import styled from '@emotion/styled'; - -import {Alert, type AlertProps} from 'sentry/components/core/alert'; -import ExternalLink from 'sentry/components/links/externalLink'; -import Link from 'sentry/components/links/link'; -import {IconChevron} from 'sentry/icons'; -import {space} from 'sentry/styles/space'; - -interface BaseAlertLinkProps - extends Pick {} - -interface ExternalAlertLinkProps extends BaseAlertLinkProps { - href: string; - // @TODO(jonasbadalic): type definition used ot indicated this prop for all types, but it never actually worked for anything except external links - // @TODO(jonasbadalic): this type should not be optional because it has a default initializer inside ExternalLink!! - openInNewTab: boolean; - onClick?: never; - // Disable other props that are not applicable for this type - to?: never; -} - -interface InternalAlertLinkProps extends BaseAlertLinkProps { - to: string; - // Disable other props that are not applicable for this type - href?: never; - onClick?: never; - openInNewTab?: never; -} - -interface ManualAlertLinkProps extends BaseAlertLinkProps { - onClick: React.MouseEventHandler; - // Disable other props that are not applicable for this type - href?: never; - openInNewTab?: never; - to?: never; -} - -export type AlertLinkProps = - | ExternalAlertLinkProps - | InternalAlertLinkProps - | ManualAlertLinkProps; - -export function AlertLink(props: AlertLinkProps): React.ReactNode { - const alertProps: AlertProps = { - type: props.type ?? 'info', - system: props.system, - trailingItems: props.trailingItems ?? , - }; - - // @TODO(jonasbadalic): we should check for empty href and report to sentry - if ('href' in props) { - // @TODO(jonasbadalic): Should we validate that the href is a valid URL? - return ( - - {props.children} - - ); - } - - if ('onClick' in props) { - return ( - // @TODO(jonasbadalic): fix this hacky way of making the link work. It seems that doing this - // causes the link to render a path to / which probably means that even though a manual link is specified, - // the user might still be redirected to a path that they dont want to be redirected to?. Should this - // be a button in this case? - - {props.children} - - ); - } - - // @TODO(jonasbadalic): we should check for empty to value and report to sentry - return ( - - {props.children} - - ); -} - -// @TODO(jonasbadalic): the styles here are duplicated... -const ExternalLinkWithTextDecoration = styled(ExternalLink)<{ - type: AlertProps['type']; -}>` - display: block; - cursor: pointer; - ${p => textDecorationStyles({type: p.type, theme: p.theme})} -`; - -const LinkWithTextDecoration = styled(Link)<{type: AlertProps['type']}>` - display: block; - cursor: pointer; - ${p => textDecorationStyles({type: p.type, theme: p.theme})} -`; - -function textDecorationStyles({type, theme}: {theme: Theme; type: AlertProps['type']}) { - return css` - text-decoration-color: ${theme.alert[type].border}; - text-decoration-style: solid; - text-decoration-line: underline; - /* @TODO(jonasbadalic): can/should we standardize this transition? */ - transition: 0.2s border-color; - - &:hover { - text-decoration-color: ${theme.alert[type].color}; - text-decoration-style: solid; - text-decoration-line: underline; - } - `; -} - -/** - * Manages margins of AlertLink components - */ -const Container = styled('div')` - > a { - margin-bottom: ${space(2)}; - } -`; - -AlertLink.Container = Container; +/** @deprecated Use `components/core/alert/alertLink` instead */ +// biome-ignore lint/performance/noBarrelFile: deprecated +export * from './alert/alertLink'; diff --git a/static/app/components/core/alert/index.spec.tsx b/static/app/components/core/alert/index.spec.tsx new file mode 100644 index 00000000000000..e504794bab1fc1 --- /dev/null +++ b/static/app/components/core/alert/index.spec.tsx @@ -0,0 +1,42 @@ +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {Alert} from 'sentry/components/core/alert'; + +describe('Alert', () => { + it('does not render icon by default', () => { + render(Hello); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + it('renders icon when showIcon is true', () => { + render( + + Hello + + ); + expect(screen.getByRole('img')).toBeInTheDocument(); + }); + + describe('expandable', () => { + it('does not render expand text by default', async () => { + render( + More stuff here}> + Hello + + ); + + expect(screen.queryByText('More stuff here')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Expand'})); + expect(screen.getByText('More stuff here')).toBeInTheDocument(); + }); + + it('renders expand text when defaultExpanded is true', () => { + render( + More stuff here}> + Hello + + ); + + expect(screen.getByText('More stuff here')).toBeInTheDocument(); + }); + }); +}); diff --git a/static/app/components/core/alert/index.tsx b/static/app/components/core/alert/index.tsx index 26d8bd3ac476b5..1b651f21ddb17d 100644 --- a/static/app/components/core/alert/index.tsx +++ b/static/app/components/core/alert/index.tsx @@ -5,7 +5,9 @@ import styled from '@emotion/styled'; import {useHover} from '@react-aria/interactions'; import classNames from 'classnames'; +import {Button} from 'sentry/components/button'; import {IconCheckmark, IconChevron, IconInfo, IconNot, IconWarning} from 'sentry/icons'; +import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {defined} from 'sentry/utils'; import PanelProvider from 'sentry/utils/panelProvider'; @@ -91,7 +93,13 @@ export function Alert({ )} {showExpand && ( - +