diff --git a/devservices/config.yml b/devservices/config.yml index 51f67a698ec..2a448bc5502 100644 --- a/devservices/config.yml +++ b/devservices/config.yml @@ -20,6 +20,13 @@ x-sentry-service-config: branch: master repo_link: https://github.com/getsentry/snuba.git mode: containerized-profiles + snuba-metrics: + description: Service that provides fast aggregation and query capabilities on top of Clickhouse that includes metrics consumers + remote: + repo_name: snuba + branch: master + repo_link: https://github.com/getsentry/snuba.git + mode: containerized-metrics-dev relay: description: Service event forwarding and ingestion service remote: @@ -121,6 +128,15 @@ x-sentry-service-config: description: Post-process forwarder for transaction events post-process-forwarder-issue-platform: description: Post-process forwarder for issue platform events + # Subscription results consumers + eap-spans-subscription-results: + description: Kafka consumer for processing subscription results for spans + subscription-results-eap-items: + description: Kafka consumer for processing subscription results for eap items + metrics-subscription-results: + description: Kafka consumer for processing subscription results for metrics + generic-metrics-subscription-results: + description: Kafka consumer for processing subscription results for generic metrics # Uptime monitoring uptime-results: description: Kafka consumer for uptime monitoring results @@ -138,6 +154,29 @@ x-sentry-service-config: rabbitmq: [postgres, snuba, rabbitmq, spotlight] symbolicator: [postgres, snuba, symbolicator, spotlight] memcached: [postgres, snuba, memcached, spotlight] + tracing: + [ + postgres, + snuba-metrics, + relay, + spotlight, + ingest-events, + ingest-transactions, + ingest-metrics, + ingest-generic-metrics, + billing-metrics-consumer, + post-process-forwarder-errors, + post-process-forwarder-transactions, + post-process-forwarder-issue-platform, + eap-spans-subscription-results, + subscription-results-eap-items, + metrics-subscription-results, + generic-metrics-subscription-results, + process-spans, + ingest-occurrences, + process-segments, + worker, + ] crons: [ postgres, @@ -283,6 +322,14 @@ x-programs: command: sentry run consumer post-process-forwarder-issue-platform --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset ingest-feedback-events: command: sentry run consumer ingest-feedback-events --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset + eap-spans-subscription-results: + command: sentry run consumer eap-spans-subscription-results --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset + subscription-results-eap-items: + command: sentry run consumer subscription-results-eap-items --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset + metrics-subscription-results: + command: sentry run consumer metrics-subscription-results --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset + generic-metrics-subscription-results: + command: sentry run consumer generic-metrics-subscription-results --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset worker: command: sentry run worker -c 1 --autoreload diff --git a/src/sentry/constants.py b/src/sentry/constants.py index c5374e1dcb0..d2ef563c826 100644 --- a/src/sentry/constants.py +++ b/src/sentry/constants.py @@ -721,7 +721,7 @@ class InsightModules(Enum): TARGET_SAMPLE_RATE_DEFAULT = 1.0 SAMPLING_MODE_DEFAULT = "organization" ROLLBACK_ENABLED_DEFAULT = True -DEFAULT_AUTOFIX_AUTOMATION_TUNING_DEFAULT = "low" +DEFAULT_AUTOFIX_AUTOMATION_TUNING_DEFAULT = "off" DEFAULT_SEER_SCANNER_AUTOMATION_DEFAULT = False INGEST_THROUGH_TRUSTED_RELAYS_ONLY_DEFAULT = False diff --git a/src/sentry/feedback/usecases/feedback_summaries.py b/src/sentry/feedback/usecases/feedback_summaries.py index 27000dd16ee..7da2167d11e 100644 --- a/src/sentry/feedback/usecases/feedback_summaries.py +++ b/src/sentry/feedback/usecases/feedback_summaries.py @@ -10,12 +10,14 @@ def make_input_prompt( feedbacks, ): - feedbacks_string = "\n".join(f"- {msg}" for msg in feedbacks) + feedbacks_string = "\n------\n".join(feedbacks) return f"""Instructions: -You are an assistant that summarizes customer feedback. Given a list of customer feedback entries, generate a concise summary of 1-2 sentences that reflects the key themes. Begin the summary with "Users...", for example, "Users say...". +You are an assistant that summarizes customer feedback. Given a list of customer feedback entries, generate a concise summary of 1-2 sentences that reflects the key themes. Begin the summary with "Users...", for example, "Users say...". Don't make overly generic statements like "Users report a variety of issues." -Balance specificity and generalization based on the size of the input based *only* on the themes and topics present in the list of customer feedback entries. Prioritize brevity and clarity and trying to capture what users are saying, over trying to mention random specific topics. Please don't write overly long sentences, you can leave certain things out and the decision to mention specific topics or themes should be proportional to the number of times they appear in the user feedback entries. +Balance specificity and generalization based on the size of the input and based only on the themes and topics present in the list of customer feedback entries. Your goal is to focus on identifying and summarizing broader themes that are mentioned more frequently across different feedback entries. For example, if there are many feedback entries, it makes more sense to prioritize mentioning broader themes that apply to many feedbacks, versus mentioning one or two specific isolated concerns and leaving out others that are just as prevalent. + +The summary must be AT MOST 55 words, that is an absolute upper limit, and you must write AT MOST two sentences. You can leave certain things out, and when deciding what topics/themes to mention, make sure it is proportional to the number of times they appear in different customer feedback entries. User Feedbacks: diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py index bfcef71f5d2..c06a719e833 100644 --- a/src/sentry/grouping/parameterization.py +++ b/src/sentry/grouping/parameterization.py @@ -11,7 +11,6 @@ "ParameterizationCallableExperiment", "ParameterizationExperiment", "ParameterizationRegex", - "ParameterizationRegexExperiment", "Parameterizer", "UniqueIdExperiment", ] @@ -206,15 +205,6 @@ def run(self, content: str, callback: Callable[[str, int], None]) -> str: return content -class ParameterizationRegexExperiment(ParameterizationRegex): - def run( - self, - content: str, - callback: Callable[[re.Match[str]], str], - ) -> str: - return self.compiled_pattern.sub(callback, content) - - class _UniqueId: # just a namespace for the uniq_id logic, no need to instantiate @@ -275,7 +265,7 @@ def replace_uniq_ids_in_str(string: str) -> tuple[str, int]: ) -ParameterizationExperiment = ParameterizationCallableExperiment | ParameterizationRegexExperiment +ParameterizationExperiment = ParameterizationCallableExperiment class Parameterizer: @@ -355,10 +345,8 @@ def _handle_regex_match(match: re.Match[str]) -> str: for experiment in self._experiments: if not should_run(experiment.name): continue - if isinstance(experiment, ParameterizationCallableExperiment): - content = experiment.run(content, _incr_counter) - else: - content = experiment.run(content, _handle_regex_match) + + content = experiment.run(content, _incr_counter) return content diff --git a/src/sentry/hybridcloud/tasks/deliver_webhooks.py b/src/sentry/hybridcloud/tasks/deliver_webhooks.py index d87dec54a87..bf8c7c51e26 100644 --- a/src/sentry/hybridcloud/tasks/deliver_webhooks.py +++ b/src/sentry/hybridcloud/tasks/deliver_webhooks.py @@ -82,6 +82,7 @@ class DeliveryFailed(Exception): silo_mode=SiloMode.CONTROL, taskworker_config=TaskworkerConfig( namespace=hybridcloud_control_tasks, + processing_deadline_duration=30, ), ) def schedule_webhook_delivery() -> None: @@ -157,6 +158,7 @@ def schedule_webhook_delivery() -> None: silo_mode=SiloMode.CONTROL, taskworker_config=TaskworkerConfig( namespace=hybridcloud_control_tasks, + processing_deadline_duration=300, ), ) def drain_mailbox(payload_id: int) -> None: @@ -234,6 +236,7 @@ def drain_mailbox(payload_id: int) -> None: silo_mode=SiloMode.CONTROL, taskworker_config=TaskworkerConfig( namespace=hybridcloud_control_tasks, + processing_deadline_duration=120, ), ) def drain_mailbox_parallel(payload_id: int) -> None: diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index dc0a4382c0b..3506ca32848 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -384,8 +384,6 @@ def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow: Did you find this useful? React with a 👍 or 👎""" -MERGED_PR_SINGLE_ISSUE_TEMPLATE = "- ‼️ **{title}** `{subtitle}` [View Issue]({url})" - class GitHubPRCommentWorkflow(PRCommentWorkflow): organization_option_key = "sentry:github_pr_bot" @@ -405,10 +403,10 @@ def get_comment_body(self, issue_ids: list[int]) -> str: issue_list = "\n".join( [ - MERGED_PR_SINGLE_ISSUE_TEMPLATE.format( + self.get_merged_pr_single_issue_template( title=issue.title, - subtitle=self.format_comment_subtitle(issue.culprit or "unknown culprit"), url=self.format_comment_url(issue.get_absolute_url(), self.referrer_id), + environment=self.get_environment_info(issue), ) for issue in issues ] diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index c7afbd5f57f..0244c9a811d 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -230,8 +230,6 @@ def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow: {issue_list}""" -MERGED_PR_SINGLE_ISSUE_TEMPLATE = "- ‼️ **{title}** `{subtitle}` [View Issue]({url})" - class GitlabPRCommentWorkflow(PRCommentWorkflow): organization_option_key = "sentry:gitlab_pr_bot" @@ -253,10 +251,10 @@ def get_comment_body(self, issue_ids: list[int]) -> str: issue_list = "\n".join( [ - MERGED_PR_SINGLE_ISSUE_TEMPLATE.format( + self.get_merged_pr_single_issue_template( title=issue.title, - subtitle=self.format_comment_subtitle(issue.culprit), url=self.format_comment_url(issue.get_absolute_url(), self.referrer_id), + environment=self.get_environment_info(issue), ) for issue in issues ] diff --git a/src/sentry/integrations/source_code_management/commit_context.py b/src/sentry/integrations/source_code_management/commit_context.py index ce5483df02e..8492b931ec0 100644 --- a/src/sentry/integrations/source_code_management/commit_context.py +++ b/src/sentry/integrations/source_code_management/commit_context.py @@ -139,6 +139,10 @@ class PullRequestFile: patch: str +ISSUE_TITLE_MAX_LENGTH = 50 +MERGED_PR_SINGLE_ISSUE_TEMPLATE = "* ‼️ [**{title}**]({url}){environment}\n" + + class CommitContextIntegration(ABC): """ Base class for integrations that include commit context features: suspect commits, suspect PR comments @@ -570,6 +574,37 @@ def get_top_5_issues_by_count( ) return raw_snql_query(request, referrer=self.referrer.value)["data"] + @staticmethod + def _truncate_title(title: str, max_length: int = ISSUE_TITLE_MAX_LENGTH) -> str: + """Truncate title if it's too long and add ellipsis.""" + if len(title) <= max_length: + return title + return title[:max_length].rstrip() + "..." + + def get_environment_info(self, issue: Group) -> str: + try: + recommended_event = issue.get_recommended_event() + if recommended_event: + environment = recommended_event.get_environment() + if environment and environment.name: + return f" in `{environment.name}`" + except Exception as e: + # If anything goes wrong, just continue without environment info + logger.info( + "get_environment_info.no-environment", + extra={"issue_id": issue.id, "error": e}, + ) + return "" + + @staticmethod + def get_merged_pr_single_issue_template(title: str, url: str, environment: str) -> str: + truncated_title = PRCommentWorkflow._truncate_title(title) + return MERGED_PR_SINGLE_ISSUE_TEMPLATE.format( + title=truncated_title, + url=url, + environment=environment, + ) + class OpenPRCommentWorkflow(ABC): def __init__(self, integration: CommitContextIntegration): diff --git a/src/sentry/issues/endpoints/browser_reporting_collector.py b/src/sentry/issues/endpoints/browser_reporting_collector.py index 2bcf8976511..52fdcde5eeb 100644 --- a/src/sentry/issues/endpoints/browser_reporting_collector.py +++ b/src/sentry/issues/endpoints/browser_reporting_collector.py @@ -1,13 +1,14 @@ from __future__ import annotations import logging -from dataclasses import dataclass -from typing import Any, Literal +from typing import Any -from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt +from rest_framework import serializers from rest_framework.parsers import JSONParser from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY from sentry import options from sentry.api.api_owners import ApiOwner @@ -17,30 +18,46 @@ logger = logging.getLogger(__name__) -# Known browser report types as defined by the Browser Reporting API specification -BrowserReportType = Literal[ - # Core report types (always sent to 'default' endpoint) - "deprecation", # Deprecated API usage - "intervention", # Browser interventions/blocks - "crash", # Browser crashes - # Policy violation report types (can be sent to named endpoints) - "csp-violation", # Content Security Policy violations - "coep", # Cross-Origin-Embedder-Policy violations - "coop", # Cross-Origin-Opener-Policy violations - "document-policy-violation", # Document Policy violations - "permissions-policy", # Permissions Policy violations +BROWSER_REPORT_TYPES = [ + "deprecation", + "intervention", + "crash", + "csp-violation", + "coep", + "coop", + "document-policy-violation", + "permissions-policy", ] -@dataclass -class BrowserReport: - body: dict[str, Any] - type: BrowserReportType - url: str - user_agent: str - destination: str - timestamp: int - attempts: int +# Working Draft https://www.w3.org/TR/reporting-1/#concept-reports +# Editor's Draft https://w3c.github.io/reporting/#concept-reports +# We need to support both +class BrowserReportSerializer(serializers.Serializer[Any]): + """Serializer for validating browser report data structure.""" + + body = serializers.DictField() + type = serializers.ChoiceField(choices=BROWSER_REPORT_TYPES) + url = serializers.URLField() + user_agent = serializers.CharField() + destination = serializers.CharField() + attempts = serializers.IntegerField(min_value=1) + # Fields that do not overlap between specs + # We need to support both specs + age = serializers.IntegerField(required=False) + timestamp = serializers.IntegerField(required=False, min_value=0) + + def validate_timestamp(self, value: int) -> int: + """Validate that age is absent, but timestamp is present.""" + if self.initial_data.get("age"): + raise serializers.ValidationError("If timestamp is present, age must be absent") + return value + + def validate_age(self, value: int) -> int: + """Validate that age is present, but not timestamp.""" + if self.initial_data.get("timestamp"): + raise serializers.ValidationError("If age is present, timestamp must be absent") + return value class BrowserReportsJSONParser(JSONParser): @@ -63,17 +80,15 @@ class BrowserReportingCollectorEndpoint(Endpoint): permission_classes = () # Support both standard JSON and browser reporting API content types parser_classes = [BrowserReportsJSONParser, JSONParser] - publish_status = { - "POST": ApiPublishStatus.PRIVATE, - } + publish_status = {"POST": ApiPublishStatus.PRIVATE} owner = ApiOwner.ISSUES # CSRF exemption and CORS support required for Browser Reporting API @csrf_exempt @allow_cors_options - def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: + def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: if not options.get("issues.browser_reporting.collector_endpoint_enabled"): - return HttpResponse(status=404) + return Response(status=HTTP_404_NOT_FOUND) logger.info("browser_report_received", extra={"request_body": request.data}) @@ -86,14 +101,30 @@ def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: "browser_report_invalid_format", extra={"data_type": type(raw_data).__name__, "data": raw_data}, ) - return HttpResponse(status=422) + return Response(status=HTTP_422_UNPROCESSABLE_ENTITY) + # Validate each report in the array + validated_reports = [] for report in raw_data: - browser_report = BrowserReport(**report) + serializer = BrowserReportSerializer(data=report) + if not serializer.is_valid(): + logger.warning( + "browser_report_validation_failed", + extra={"validation_errors": serializer.errors, "raw_report": report}, + ) + return Response( + {"error": "Invalid report data", "details": serializer.errors}, + status=HTTP_422_UNPROCESSABLE_ENTITY, + ) + + validated_reports.append(serializer.validated_data) + + # Process all validated reports + for browser_report in validated_reports: metrics.incr( "browser_reporting.raw_report_received", - tags={"browser_report_type": browser_report.type}, + tags={"browser_report_type": str(browser_report["type"])}, sample_rate=1.0, # XXX: Remove this once we have a ballpark figure ) - return HttpResponse(status=200) + return Response(status=HTTP_200_OK) diff --git a/src/sentry/issues/grouptype.py b/src/sentry/issues/grouptype.py index 9680891ee13..e32414acf98 100644 --- a/src/sentry/issues/grouptype.py +++ b/src/sentry/issues/grouptype.py @@ -511,6 +511,7 @@ class DBQueryInjectionVulnerabilityGroupType(GroupType): category_v2 = GroupCategory.DB_QUERY.value enable_auto_resolve = False enable_escalation_detection = False + noise_config = NoiseConfig(ignore_limit=5) default_priority = PriorityLevel.MEDIUM diff --git a/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py b/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py index 83d1f9b6637..e8718423086 100644 --- a/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py +++ b/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py @@ -5,30 +5,15 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.migrations.state import StateApps -from sentry.models.savedsearch import Visibility from sentry.new_migrations.migrations import CheckedMigration -from sentry.utils.query import RangeQuerySetWrapperWithProgressBar def convert_org_saved_searches_to_views( apps: StateApps, schema_editor: BaseDatabaseSchemaEditor ) -> None: - SavedSearch = apps.get_model("sentry", "SavedSearch") - GroupSearchView = apps.get_model("sentry", "GroupSearchView") - - org_saved_searches = SavedSearch.objects.filter(visibility=Visibility.ORGANIZATION) - - for saved_search in RangeQuerySetWrapperWithProgressBar(org_saved_searches): - GroupSearchView.objects.update_or_create( - organization=saved_search.organization, - user_id=saved_search.owner_id, - name=saved_search.name, - defaults={ - "query": saved_search.query, - "query_sort": saved_search.sort, - "date_added": saved_search.date_added, - }, - ) + # This migration had an error and was never run. + # See 0921_convert_org_saved_searches_to_views_rerevised.py for the correct migration. + return class Migration(CheckedMigration): diff --git a/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py b/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py index 10ca0ba7251..c3cf88fda38 100644 --- a/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py +++ b/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py @@ -4,32 +4,15 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.migrations.state import StateApps -from sentry.models.savedsearch import Visibility from sentry.new_migrations.migrations import CheckedMigration -from sentry.utils.query import RangeQuerySetWrapperWithProgressBar def convert_org_saved_searches_to_views( apps: StateApps, schema_editor: BaseDatabaseSchemaEditor ) -> None: - SavedSearch = apps.get_model("sentry", "SavedSearch") - GroupSearchView = apps.get_model("sentry", "GroupSearchView") - - org_saved_searches = SavedSearch.objects.filter( - visibility=Visibility.ORGANIZATION, owner_id__isnull=False - ) - - for saved_search in RangeQuerySetWrapperWithProgressBar(org_saved_searches): - GroupSearchView.objects.update_or_create( - organization=saved_search.organization, - user_id=saved_search.owner_id, - name=saved_search.name, - defaults={ - "query": saved_search.query, - "query_sort": saved_search.sort, - "date_added": saved_search.date_added, - }, - ) + # This migration had an error and was never run. + # See 0921_convert_org_saved_searches_to_views_rerevised.py for the correct migration. + return class Migration(CheckedMigration): diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 98d4051a870..a48369b5a9d 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2624,11 +2624,6 @@ default=0.0, flags=FLAG_ADMIN_MODIFIABLE | FLAG_AUTOMATOR_MODIFIABLE | FLAG_RATE, ) -register( - "grouping.experiments.parameterization.traceparent", - default=0.0, - flags=FLAG_ADMIN_MODIFIABLE | FLAG_AUTOMATOR_MODIFIABLE | FLAG_RATE, -) # TODO: For now, only a small number of projects are going through a grouping config transition at # any given time, so we're sampling at 100% in order to be able to get good signal. Once we've fully diff --git a/src/sentry/preprod/__init__.py b/src/sentry/preprod/__init__.py index e69de29bb2d..32860f7f157 100644 --- a/src/sentry/preprod/__init__.py +++ b/src/sentry/preprod/__init__.py @@ -0,0 +1 @@ +from .analytics import * # NOQA diff --git a/src/sentry/preprod/analytics.py b/src/sentry/preprod/analytics.py new file mode 100644 index 00000000000..96ea66e74f1 --- /dev/null +++ b/src/sentry/preprod/analytics.py @@ -0,0 +1,14 @@ +from sentry import analytics + + +class PreprodArtifactApiAssembleEvent(analytics.Event): + type = "preprod_artifact.api.assemble" + + attributes = ( + analytics.Attribute("organization_id"), + analytics.Attribute("project_id"), + analytics.Attribute("user_id", required=False), + ) + + +analytics.register(PreprodArtifactApiAssembleEvent) diff --git a/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py b/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py index 20b1b2e68f7..c3ecef72cad 100644 --- a/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py +++ b/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py @@ -4,7 +4,7 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features +from sentry import analytics, features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint @@ -77,6 +77,14 @@ def post(self, request: Request, project) -> Response: """ Assembles a preprod artifact (mobile build, etc.) and stores it in the database. """ + + analytics.record( + "preprod_artifact.api.assemble", + organization_id=project.organization_id, + project_id=project.id, + user_id=request.user.id, + ) + if not features.has( "organizations:preprod-artifact-assemble", project.organization, actor=request.user ): diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index 23f793aa099..d96cf2062a9 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -202,7 +202,7 @@ register(key="sentry:tempest_fetch_dumps", default=False) # Should autofix run automatically on new issues -register(key="sentry:autofix_automation_tuning", default="low") +register(key="sentry:autofix_automation_tuning", default="off") # Should seer scanner run automatically on new issues register(key="sentry:seer_scanner_automation", default=False) diff --git a/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py b/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py index 8dd77f455b5..733b1211293 100644 --- a/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py +++ b/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py @@ -1,7 +1,7 @@ import functools import logging from collections.abc import Generator, Iterator -from typing import Any +from typing import Any, TypedDict import requests import sentry_sdk @@ -10,13 +10,17 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features +from sentry import features, nodestore from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.project import ProjectEndpoint from sentry.api.paginator import GenericOffsetPaginator +from sentry.eventstore.models import Event +from sentry.models.project import Project from sentry.replays.lib.storage import RecordingSegmentStorageMeta, storage +from sentry.replays.post_process import process_raw_response +from sentry.replays.query import query_replay_instance from sentry.replays.usecases.ingest.event_parser import as_log_message from sentry.replays.usecases.reader import fetch_segments_metadata, iter_segment_data from sentry.seer.signed_seer_api import sign_with_seer_secret @@ -25,6 +29,14 @@ logger = logging.getLogger(__name__) +class ErrorEvent(TypedDict): + id: str + title: str + message: str + timestamp: float + category: str + + @region_silo_endpoint @extend_schema(tags=["Replays"]) class ProjectReplaySummarizeBreadcrumbsEndpoint(ProjectEndpoint): @@ -37,7 +49,7 @@ def __init__(self, **options) -> None: storage.initialize_client() super().__init__(**options) - def get(self, request: Request, project, replay_id: str) -> Response: + def get(self, request: Request, project: Project, replay_id: str) -> Response: """Return a collection of replay recording segments.""" if ( not features.has( @@ -52,17 +64,117 @@ def get(self, request: Request, project, replay_id: str) -> Response: ): return self.respond(status=404) + filter_params = self.get_filter_params(request, project) + + # Fetch the replay's error IDs from the replay_id. + snuba_response = query_replay_instance( + project_id=project.id, + replay_id=replay_id, + start=filter_params["start"], + end=filter_params["end"], + organization=project.organization, + request_user_id=request.user.id, + ) + + response = process_raw_response( + snuba_response, + fields=request.query_params.getlist("field"), + ) + + error_ids = response[0].get("error_ids", []) if response else [] + + # Check if error fetching should be disabled + disable_error_fetching = ( + request.query_params.get("enable_error_context", "true").lower() == "false" + ) + + if disable_error_fetching: + error_events = [] + else: + error_events = fetch_error_details(project_id=project.id, error_ids=error_ids) + return self.paginate( request=request, paginator_cls=GenericOffsetPaginator, data_fn=functools.partial(fetch_segments_metadata, project.id, replay_id), - on_results=analyze_recording_segments, + on_results=functools.partial(analyze_recording_segments, error_events), ) +def fetch_error_details(project_id: int, error_ids: list[str]) -> list[ErrorEvent]: + """Fetch error details given error IDs and return a list of ErrorEvent objects.""" + try: + node_ids = [Event.generate_node_id(project_id, event_id=id) for id in error_ids] + events = nodestore.backend.get_multi(node_ids) + + return [ + ErrorEvent( + category="error", + id=event_id, + title=data.get("title", ""), + timestamp=data.get("timestamp", 0.0), + message=data.get("message", ""), + ) + for event_id, data in zip(error_ids, events.values()) + if data is not None + ] + except Exception as e: + sentry_sdk.capture_exception(e) + return [] + + +def generate_error_log_message(error: ErrorEvent) -> str: + title = error["title"] + message = error["message"] + timestamp = error["timestamp"] + + return f"User experienced an error: '{title}: {message}' at {timestamp}" + + +def get_request_data( + iterator: Iterator[tuple[int, memoryview]], error_events: list[ErrorEvent] +) -> list[str]: + # Sort error events by timestamp + error_events.sort(key=lambda x: x["timestamp"]) + return list(gen_request_data(iterator, error_events)) + + +def gen_request_data( + iterator: Iterator[tuple[int, memoryview]], error_events: list[ErrorEvent] +) -> Generator[str]: + """Generate log messages from events and errors in chronological order.""" + error_idx = 0 + + # Process segments + for _, segment in iterator: + events = json.loads(segment.tobytes().decode("utf-8")) + for event in events: + # Check if we need to yield any error messages that occurred before this event + while error_idx < len(error_events) and error_events[error_idx][ + "timestamp" + ] < event.get("timestamp", 0): + error = error_events[error_idx] + yield generate_error_log_message(error) + error_idx += 1 + + # Yield the current event's log message + if message := as_log_message(event): + yield message + + # Yield any remaining error messages + while error_idx < len(error_events): + error = error_events[error_idx] + yield generate_error_log_message(error) + error_idx += 1 + + @sentry_sdk.trace -def analyze_recording_segments(segments: list[RecordingSegmentStorageMeta]) -> dict[str, Any]: - request_data = json.dumps({"logs": get_request_data(iter_segment_data(segments))}) +def analyze_recording_segments( + error_events: list[ErrorEvent], + segments: list[RecordingSegmentStorageMeta], +) -> dict[str, Any]: + # Combine breadcrumbs and error details + request_data = json.dumps({"logs": get_request_data(iter_segment_data(segments), error_events)}) # XXX: I have to deserialize this request so it can be "automatically" reserialized by the # paginate method. This is less than ideal. @@ -94,15 +206,3 @@ def make_seer_request(request_data: str) -> bytes: response.raise_for_status() return response.content - - -def get_request_data(iterator: Iterator[tuple[int, memoryview]]) -> list[str]: - return list(gen_request_data(map(lambda r: r[1], iterator))) - - -def gen_request_data(segments: Iterator[memoryview]) -> Generator[str]: - for segment in segments: - for event in json.loads(segment.tobytes().decode("utf-8")): - message = as_log_message(event) - if message: - yield message diff --git a/src/sentry/replays/usecases/delete.py b/src/sentry/replays/usecases/delete.py index a0ee4783db1..91b7ed40590 100644 --- a/src/sentry/replays/usecases/delete.py +++ b/src/sentry/replays/usecases/delete.py @@ -84,6 +84,11 @@ def _delete_if_exists(filename: str) -> None: def _make_recording_filenames(project_id: int, row: MatchedRow) -> list[str]: + # Null segment_ids can cause this to fail. If no segments were ingested then we can skip + # deleting the segements. + if row["max_segment_id"] is None: + return [] + # We assume every segment between 0 and the max_segment_id exists. Its a waste of time to # delete a non-existent segment but its not so significant that we'd want to query ClickHouse # to verify it exists. @@ -104,7 +109,7 @@ def _make_recording_filenames(project_id: int, row: MatchedRow) -> list[str]: class MatchedRow(TypedDict): retention_days: int replay_id: str - max_segment_id: int + max_segment_id: int | None platform: str diff --git a/src/sentry/snuba/ourlogs.py b/src/sentry/snuba/ourlogs.py index 76eb87e0978..9b355333d92 100644 --- a/src/sentry/snuba/ourlogs.py +++ b/src/sentry/snuba/ourlogs.py @@ -154,6 +154,7 @@ def run_top_events_timeseries_query( referrer: str, config: SearchResolverConfig, sampling_mode: SAMPLING_MODES | None, + equations: list[str] | None = None, ) -> Any: return rpc_dataset_common.run_top_events_timeseries_query( get_resolver=get_resolver, @@ -166,4 +167,5 @@ def run_top_events_timeseries_query( referrer=referrer, config=config, sampling_mode=sampling_mode, + equations=equations, ) diff --git a/src/sentry/tasks/auth/check_auth.py b/src/sentry/tasks/auth/check_auth.py index 4477e1f295c..e8adb5739e1 100644 --- a/src/sentry/tasks/auth/check_auth.py +++ b/src/sentry/tasks/auth/check_auth.py @@ -73,7 +73,9 @@ def check_auth_identity(auth_identity_id: int, **kwargs): name="sentry.tasks.check_auth_identities", queue="auth.control", silo_mode=SiloMode.CONTROL, - taskworker_config=TaskworkerConfig(namespace=auth_control_tasks), + taskworker_config=TaskworkerConfig( + namespace=auth_control_tasks, processing_deadline_duration=60 + ), ) def check_auth_identities( auth_identity_id: int | None = None, diff --git a/src/sentry/workflow_engine/endpoints/validators/base/detector.py b/src/sentry/workflow_engine/endpoints/validators/base/detector.py index 1732507064c..a32020860d5 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/detector.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/detector.py @@ -5,6 +5,7 @@ from rest_framework import serializers from sentry import audit_log +from sentry.api.fields.actor import ActorField from sentry.api.serializers.rest_framework import CamelSnakeSerializer from sentry.issues import grouptype from sentry.issues.grouptype import GroupType @@ -33,6 +34,7 @@ class BaseDetectorTypeValidator(CamelSnakeSerializer): ) type = serializers.CharField() config = serializers.JSONField(default={}) + owner = ActorField(required=False, allow_null=True) def validate_type(self, value: str) -> builtins.type[GroupType]: type = grouptype.registry.get_by_slug(value) @@ -60,6 +62,22 @@ def data_conditions(self) -> BaseDataConditionValidator: def update(self, instance: Detector, validated_data: dict[str, Any]): instance.name = validated_data.get("name", instance.name) instance.type = validated_data.get("detector_type", instance.group_type).slug + + # Handle owner field update + if "owner" in validated_data: + owner = validated_data.get("owner") + if owner: + if owner.is_user: + instance.owner_user_id = owner.id + instance.owner_team_id = None + elif owner.is_team: + instance.owner_user_id = None + instance.owner_team_id = owner.id + else: + # Clear owner if None is passed + instance.owner_user_id = None + instance.owner_team_id = None + condition_group = validated_data.pop("condition_group") data_conditions: list[DataConditionType] = condition_group.get("conditions") @@ -98,12 +116,24 @@ def create(self, validated_data): type=condition["type"], condition_group=condition_group, ) + + owner = validated_data.get("owner") + owner_user_id = None + owner_team_id = None + if owner: + if owner.is_user: + owner_user_id = owner.id + elif owner.is_team: + owner_team_id = owner.id + detector = Detector.objects.create( project_id=self.context["project"].id, name=validated_data["name"], workflow_condition_group=condition_group, type=validated_data["type"].slug, config=validated_data.get("config", {}), + owner_user_id=owner_user_id, + owner_team_id=owner_team_id, created_by_id=self.context["request"].user.id, ) DataSourceDetector.objects.create(data_source=detector_data_source, detector=detector) diff --git a/src/sentry/workflow_engine/processors/delayed_workflow.py b/src/sentry/workflow_engine/processors/delayed_workflow.py index 07847099891..f627d90d036 100644 --- a/src/sentry/workflow_engine/processors/delayed_workflow.py +++ b/src/sentry/workflow_engine/processors/delayed_workflow.py @@ -354,13 +354,12 @@ def get_condition_query_groups( data_condition_groups: list[DataConditionGroup], event_data: EventRedisData, workflows_to_envs: Mapping[WorkflowId, int | None], + dcg_to_slow_conditions: dict[DataConditionGroupId, list[DataCondition]], ) -> dict[UniqueConditionQuery, set[GroupId]]: """ Map unique condition queries to the group IDs that need to checked for that query. """ condition_groups: dict[UniqueConditionQuery, set[GroupId]] = defaultdict(set) - dcg_to_slow_conditions = get_slow_conditions_for_groups(list(event_data.dcg_to_groups.keys())) - for dcg in data_condition_groups: slow_conditions = dcg_to_slow_conditions[dcg.id] workflow_id = event_data.dcg_to_workflow.get(dcg.id) @@ -412,9 +411,9 @@ def get_groups_to_fire( workflows_to_envs: Mapping[WorkflowId, int | None], event_data: EventRedisData, condition_group_results: dict[UniqueConditionQuery, QueryResult], + dcg_to_slow_conditions: dict[DataConditionGroupId, list[DataCondition]], ) -> dict[GroupId, set[DataConditionGroup]]: groups_to_fire: dict[GroupId, set[DataConditionGroup]] = defaultdict(set) - dcg_to_slow_conditions = get_slow_conditions_for_groups(list(event_data.dcg_ids)) for dcg in data_condition_groups: slow_conditions = dcg_to_slow_conditions[dcg.id] @@ -581,7 +580,7 @@ def fire_actions_for_groups( extra={ "workflow_ids": [workflow.id for workflow in workflows], "actions": [action.id for action in filtered_actions], - "event_data": event_data, + "event_data": workflow_event_data, "event_id": workflow_event_data.event.event_id, }, ) @@ -650,6 +649,18 @@ def process_delayed_workflows( workflows_to_envs = fetch_workflows_envs(list(event_data.workflow_ids)) data_condition_groups = fetch_data_condition_groups(list(event_data.dcg_ids)) + dcg_to_slow_conditions = get_slow_conditions_for_groups(list(event_data.dcg_ids)) + + no_slow_condition_groups = { + dcg_id for dcg_id, slow_conds in dcg_to_slow_conditions.items() if not slow_conds + } + if no_slow_condition_groups: + # If the DCG is being processed here, it's because we thought it had a slow condition. + # If any don't seem to have a slow condition now, that's interesting enough to log. + logger.info( + "delayed_workflow.no_slow_condition_groups", + extra={"no_slow_condition_groups": sorted(no_slow_condition_groups)}, + ) logger.info( "delayed_workflow.workflows", @@ -661,7 +672,7 @@ def process_delayed_workflows( # Get unique query groups to query Snuba condition_groups = get_condition_query_groups( - data_condition_groups, event_data, workflows_to_envs + data_condition_groups, event_data, workflows_to_envs, dcg_to_slow_conditions ) if not condition_groups: return @@ -688,6 +699,7 @@ def process_delayed_workflows( workflows_to_envs, event_data, condition_group_results, + dcg_to_slow_conditions, ) logger.info( "delayed_workflow.groups_to_fire", diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index c1683e5e100..10521166584 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -1,4 +1,3 @@ -import logging from collections.abc import Collection, Mapping from dataclasses import asdict, dataclass, replace from enum import StrEnum @@ -33,7 +32,7 @@ from sentry.workflow_engine.utils import log_context from sentry.workflow_engine.utils.metrics import metrics_incr -logger = logging.getLogger(__name__) +logger = log_context.get_logger(__name__) WORKFLOW_ENGINE_BUFFER_LIST_KEY = "workflow_engine_delayed_processing_buffer" diff --git a/static/app/components/codeSnippet.tsx b/static/app/components/codeSnippet.tsx index e6f1fba79a2..4f7ffbe50f4 100644 --- a/static/app/components/codeSnippet.tsx +++ b/static/app/components/codeSnippet.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import Prism from 'prismjs'; import {Button} from 'sentry/components/core/button'; +import {Flex} from 'sentry/components/core/layout'; import {IconCopy} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; @@ -163,12 +164,12 @@ export function CodeSnippet({ ))} - + )} {icon} {filename && {filename}} - {!hasTabs && } + {!hasTabs && } {!hideCopyButton && ( ` : ''} `; -const FlexSpacer = styled('div')` - flex-grow: 1; -`; - const CopyButton = styled(Button)<{isAlwaysVisible: boolean}>` color: var(--prism-comment); transition: opacity 0.1s ease-out; diff --git a/static/app/components/codecov/branchSelector/branchSelector.tsx b/static/app/components/codecov/branchSelector/branchSelector.tsx index 9ead2764b47..e469f5b11b2 100644 --- a/static/app/components/codecov/branchSelector/branchSelector.tsx +++ b/static/app/components/codecov/branchSelector/branchSelector.tsx @@ -5,6 +5,7 @@ import styled from '@emotion/styled'; import {useCodecovContext} from 'sentry/components/codecov/context/codecovContext'; import type {SelectOption} from 'sentry/components/core/compactSelect'; import {CompactSelect} from 'sentry/components/core/compactSelect'; +import {Flex} from 'sentry/components/core/layout'; import DropdownButton from 'sentry/components/dropdownButton'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; @@ -60,12 +61,12 @@ export function BranchSelector() { {...triggerProps} > - + {branch || t('Select branch')} - + ); @@ -95,12 +96,6 @@ const OptionLabel = styled('span')` } `; -const FlexContainer = styled('div')` - display: flex; - align-items: center; - gap: ${space(0.75)}; -`; - const IconContainer = styled('div')` flex: 1 0 14px; height: 14px; diff --git a/static/app/components/codecov/datePicker/dateSelector.tsx b/static/app/components/codecov/datePicker/dateSelector.tsx index 4089cab8f2b..de590aeca6d 100644 --- a/static/app/components/codecov/datePicker/dateSelector.tsx +++ b/static/app/components/codecov/datePicker/dateSelector.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import type {SelectOption, SingleSelectProps} from 'sentry/components/core/compactSelect'; import {CompactSelect} from 'sentry/components/core/compactSelect'; +import {Flex} from 'sentry/components/core/layout'; import DropdownButton from 'sentry/components/dropdownButton'; import {getArbitraryRelativePeriod} from 'sentry/components/timeRangeSelector/utils'; import {IconCalendar} from 'sentry/icons/iconCalendar'; @@ -80,10 +81,10 @@ export function DateSelector({relativeDate, onChange, trigger}: DateSelectorProp {...triggerProps} > - + {defaultLabel} - + ); @@ -108,9 +109,3 @@ const OptionLabel = styled('span')` margin: 0; } `; - -const FlexContainer = styled('div')` - display: flex; - align-items: center; - gap: ${space(0.75)}; -`; diff --git a/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx b/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx index 1def6fd99c4..6fe564343cd 100644 --- a/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx +++ b/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx @@ -6,6 +6,7 @@ import {useCodecovContext} from 'sentry/components/codecov/context/codecovContex import {LinkButton} from 'sentry/components/core/button/linkButton'; import type {SelectOption} from 'sentry/components/core/compactSelect'; import {CompactSelect} from 'sentry/components/core/compactSelect'; +import {Flex} from 'sentry/components/core/layout'; import DropdownButton from 'sentry/components/dropdownButton'; import {IconAdd, IconInfo} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -33,7 +34,7 @@ function OrgFooterMessage() { - +
@@ -43,7 +44,7 @@ function OrgFooterMessage() { Ensure you log in to the same GitHub identity
-
+
); } @@ -95,14 +96,14 @@ export function IntegratedOrgSelector() { {...triggerProps} > - + {integratedOrg || t('Select integrated organization')} - + ); @@ -160,21 +161,6 @@ const MenuFooterDivider = styled('div')` } `; -const FlexContainer = styled('div')` - display: flex; - flex-direction: row; - justify-content: flex-start; - gap: ${space(1)}; -`; - -const TriggerFlexContainer = styled('div')` - display: flex; - flex-direction: row; - justify-content: flex-start; - gap: ${space(0.75)}; - align-items: center; -`; - const IconContainer = styled('div')` flex: 1 0 14px; height: 14px; diff --git a/static/app/components/codecov/repoPicker/repoSelector.tsx b/static/app/components/codecov/repoPicker/repoSelector.tsx index c26916626ca..55f967834ca 100644 --- a/static/app/components/codecov/repoPicker/repoSelector.tsx +++ b/static/app/components/codecov/repoPicker/repoSelector.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {Button} from 'sentry/components/core/button'; import type {SelectOption, SingleSelectProps} from 'sentry/components/core/compactSelect'; import {CompactSelect} from 'sentry/components/core/compactSelect'; +import {Flex} from 'sentry/components/core/layout'; import DropdownButton from 'sentry/components/dropdownButton'; import Link from 'sentry/components/links/link'; import {IconInfo, IconSync} from 'sentry/icons'; @@ -118,12 +119,12 @@ export function RepoSelector({onChange, trigger, repository}: RepoSelectorProps) {...triggerProps} > - + {defaultLabel} - + ); @@ -178,12 +179,6 @@ const OptionLabel = styled('span')` } `; -const FlexContainer = styled('div')` - display: flex; - align-items: center; - gap: ${space(0.75)}; -`; - const IconContainer = styled('div')` flex: 1 0 14px; height: 14px; diff --git a/static/app/components/core/button/styles.chonk.tsx b/static/app/components/core/button/styles.chonk.tsx index b387f05c403..d8c351493db 100644 --- a/static/app/components/core/button/styles.chonk.tsx +++ b/static/app/components/core/button/styles.chonk.tsx @@ -110,7 +110,7 @@ export function DO_NOT_USE_getChonkButtonStyles( borderRadius: 'inherit', border: `1px solid ${getChonkButtonTheme(type, p.theme).background}`, transform: `translateY(-${chonkElevation(p.size)})`, - transition: 'transform 0.1s ease-in-out', + transition: 'transform 0.06s ease-in-out', }, '&:focus-visible': { diff --git a/static/app/components/events/eventAttachments.tsx b/static/app/components/events/eventAttachments.tsx index 2c1a90cd320..51fa18dbfda 100644 --- a/static/app/components/events/eventAttachments.tsx +++ b/static/app/components/events/eventAttachments.tsx @@ -6,6 +6,7 @@ import { useFetchEventAttachments, } from 'sentry/actionCreators/events'; import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {Flex} from 'sentry/components/core/layout'; import EventAttachmentActions from 'sentry/components/events/eventAttachmentActions'; import FileSize from 'sentry/components/fileSize'; import LoadingError from 'sentry/components/loadingError'; @@ -139,9 +140,10 @@ function EventAttachmentsContent({ > {attachments.map(attachment => ( - + {attachment.name} - + + @@ -198,12 +200,6 @@ const StyledPanelTable = styled(PanelTable)` grid-template-columns: 1fr auto auto; `; -const FlexCenter = styled('div')` - ${p => p.theme.overflowEllipsis}; - display: flex; - align-items: center; -`; - const Name = styled('div')` ${p => p.theme.overflowEllipsis}; white-space: nowrap; diff --git a/static/app/components/events/groupingInfo/groupingVariant.tsx b/static/app/components/events/groupingInfo/groupingVariant.tsx index 1843b1b7047..47d3d174db8 100644 --- a/static/app/components/events/groupingInfo/groupingVariant.tsx +++ b/static/app/components/events/groupingInfo/groupingVariant.tsx @@ -100,68 +100,19 @@ function GroupingVariant({event, showGroupingConfig, variant}: GroupingVariantPr switch (variant.type) { case EventGroupVariantType.COMPONENT: component = variant.component; - data.push([ - t('Type'), - - {variant.type} - - , - ]); + if (showGroupingConfig && variant.config?.id) { data.push([t('Grouping Config'), variant.config.id]); } break; case EventGroupVariantType.CUSTOM_FINGERPRINT: - data.push([ - t('Type'), - - {variant.type} - - , - ]); addFingerprintInfo(data, variant); break; case EventGroupVariantType.BUILT_IN_FINGERPRINT: - data.push([ - t('Type'), - - {variant.type} - - , - ]); addFingerprintInfo(data, variant); break; case EventGroupVariantType.SALTED_COMPONENT: component = variant.component; - data.push([ - t('Type'), - - {variant.type} - - , - ]); addFingerprintInfo(data, variant); if (showGroupingConfig && variant.config?.id) { data.push([t('Grouping Config'), variant.config.id]); @@ -173,19 +124,6 @@ function GroupingVariant({event, showGroupingConfig, variant}: GroupingVariantPr .find((c): c is EntrySpans => c.type === 'spans') ?.data?.map((span: RawSpanType) => [span.span_id, span.hash]) ?? [] ); - data.push([ - t('Type'), - - {variant.type} - - , - ]); data.push(['Performance Issue Type', variant.key]); data.push(['Span Operation', variant.evidence.op]); diff --git a/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx b/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx index e80d3f639f3..aafc5f6ddef 100644 --- a/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx +++ b/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx @@ -1,6 +1,7 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; +import {Flex} from 'sentry/components/core/layout'; import {generateStats} from 'sentry/components/events/opsBreakdown'; import {DividerSpacer} from 'sentry/components/performance/waterfall/miniHeader'; import {t} from 'sentry/locale'; @@ -32,18 +33,18 @@ function ServiceBreakdown({ if (!displayBreakdown) { return ( - +
{t('server side')}
- + {'N/A'} - -
- + + +
{t('client side')}
- + {'N/A'} - -
+ +
); } @@ -57,20 +58,20 @@ function ServiceBreakdown({ return httpDuration ? ( - +
{t('server side')}
- + {getDuration(httpDuration, 2, true)} {serverSidePct}% - -
- + + +
{t('client side')}
- + {getDuration(totalDuration - httpDuration, 2, true)} {clientSidePct}% - -
+ +
) : null; } @@ -151,18 +152,10 @@ const Pct = styled('div')` font-variant-numeric: tabular-nums; `; -const FlexBox = styled('div')` +const BreakDownWrapper = styled('div')` display: flex; -`; - -const BreakDownWrapper = styled(FlexBox)` flex-direction: column; padding: ${space(2)}; `; -const BreakDownRow = styled(FlexBox)` - align-items: center; - justify-content: space-between; -`; - export default TraceViewHeader; diff --git a/static/app/components/feedback/feedbackSummary.tsx b/static/app/components/feedback/feedbackSummary.tsx new file mode 100644 index 00000000000..8cf3c23dcb8 --- /dev/null +++ b/static/app/components/feedback/feedbackSummary.tsx @@ -0,0 +1,64 @@ +import styled from '@emotion/styled'; + +import useFeedbackSummary from 'sentry/components/feedback/list/useFeedbackSummary'; +import Placeholder from 'sentry/components/placeholder'; +import {IconSeer} from 'sentry/icons/iconSeer'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import useOrganization from 'sentry/utils/useOrganization'; + +export default function FeedbackSummary() { + const {isError, isPending, summary, tooFewFeedbacks} = useFeedbackSummary(); + + const organization = useOrganization(); + + if ( + !organization.features.includes('user-feedback-ai-summaries') || + tooFewFeedbacks || + isError + ) { + return null; + } + + if (isPending) { + return ; + } + + return ( + + + + {t('Feedback Summary')} + {summary} + + + ); +} + +const SummaryContainer = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(1)}; + width: 100%; +`; + +const SummaryHeader = styled('p')` + font-size: ${p => p.theme.fontSizeMedium}; + font-weight: ${p => p.theme.fontWeightBold}; + margin: 0; +`; + +const SummaryContent = styled('p')` + font-size: ${p => p.theme.fontSizeSmall}; + color: ${p => p.theme.subText}; + margin: 0; +`; + +const SummaryIconContainer = styled('div')` + display: flex; + gap: ${space(1)}; + padding: ${space(2)}; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + align-items: baseline; +`; diff --git a/static/app/components/feedback/list/useFeedbackSummary.tsx b/static/app/components/feedback/list/useFeedbackSummary.tsx new file mode 100644 index 00000000000..e593e29b0cc --- /dev/null +++ b/static/app/components/feedback/list/useFeedbackSummary.tsx @@ -0,0 +1,67 @@ +import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import usePageFilters from 'sentry/utils/usePageFilters'; + +type FeedbackSummaryResponse = { + numFeedbacksUsed: number; + success: boolean; + summary: string | null; +}; + +export default function useFeedbackSummary(): { + isError: boolean; + isPending: boolean; + summary: string | null; + tooFewFeedbacks: boolean; +} { + const organization = useOrganization(); + + const {selection} = usePageFilters(); + + const normalizedDateRange = normalizeDateTimeParams(selection.datetime); + + const {data, isPending, isError} = useApiQuery( + [ + `/organizations/${organization.slug}/feedback-summary/`, + { + query: { + ...normalizedDateRange, + project: selection.projects, + }, + }, + ], + { + staleTime: 5000, + enabled: + Boolean(normalizedDateRange) && + organization.features.includes('user-feedback-ai-summaries'), + retry: 1, + } + ); + + if (isPending) { + return { + summary: null, + isPending: true, + isError: false, + tooFewFeedbacks: false, + }; + } + + if (isError) { + return { + summary: null, + isPending: false, + isError: true, + tooFewFeedbacks: false, + }; + } + + return { + summary: data.summary, + isPending: false, + isError: false, + tooFewFeedbacks: data.numFeedbacksUsed === 0 && !data.success, + }; +} diff --git a/static/app/components/group/times.tsx b/static/app/components/group/times.tsx index c4f9d28f1d8..e18e4c6fc0a 100644 --- a/static/app/components/group/times.tsx +++ b/static/app/components/group/times.tsx @@ -1,6 +1,8 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; +import {Flex} from 'sentry/components/core/layout'; +import TextOverflow from 'sentry/components/textOverflow'; import TimeSince from 'sentry/components/timeSince'; import {IconClock} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -19,20 +21,28 @@ type Props = { function Times({lastSeen, firstSeen}: Props) { return ( - + {lastSeen && ( - - + + + + )} {firstSeen && lastSeen && (  —  )} {firstSeen && ( - + + + )} - + ); } @@ -42,18 +52,11 @@ const Container = styled('div')` min-width: 0; /* flex-hack for overflow-ellipsised children */ `; -const FlexWrapper = styled('div')` - ${p => p.theme.overflowEllipsis} - - /* The following aligns the icon with the text, fixes bug in Firefox */ - display: flex; - align-items: center; -`; - const StyledIconClock = styled(IconClock)` /* this is solely for optics, since TimeSince always begins with a number, and numbers do not have descenders */ margin-right: ${space(0.5)}; + min-width: 12px; `; export default Times; diff --git a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx index ee0d9b79488..5d98fc59cbb 100644 --- a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx +++ b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx @@ -111,7 +111,7 @@ function BreadcrumbItem({ onShowSnippet(); e.preventDefault(); e.stopPropagation(); - trackAnalytics('replay.view_html', { + trackAnalytics('replay.view-html', { organization, breadcrumb_type: 'category' in frame ? frame.category : 'unknown', }); diff --git a/static/app/components/replays/timeAndScrubberGrid.tsx b/static/app/components/replays/timeAndScrubberGrid.tsx index e4375dab72e..f325db0faa6 100644 --- a/static/app/components/replays/timeAndScrubberGrid.tsx +++ b/static/app/components/replays/timeAndScrubberGrid.tsx @@ -1,4 +1,4 @@ -import {useRef} from 'react'; +import {useCallback, useRef} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; @@ -14,10 +14,12 @@ import {useReplayContext} from 'sentry/components/replays/replayContext'; import {IconAdd, IconSubtract} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import {trackAnalytics} from 'sentry/utils/analytics'; import useTimelineScale, { TimelineScaleContextProvider, } from 'sentry/utils/replays/hooks/useTimelineScale'; import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext'; +import useOrganization from 'sentry/utils/useOrganization'; type TimeAndScrubberGridProps = { isCompact?: boolean; @@ -27,10 +29,27 @@ type TimeAndScrubberGridProps = { function TimelineSizeBar({isLoading}: {isLoading?: boolean}) { const {replay} = useReplayContext(); + const organization = useOrganization(); const [timelineScale, setTimelineScale] = useTimelineScale(); const durationMs = replay?.getDurationMs(); const maxScale = durationMs ? Math.ceil(durationMs / 60000) : 10; + const handleZoomOut = useCallback(() => { + const newScale = Math.max(timelineScale - 1, 1); + setTimelineScale(newScale); + trackAnalytics('replay.timeline.zoom-out', { + organization, + }); + }, [timelineScale, setTimelineScale, organization]); + + const handleZoomIn = useCallback(() => { + const newScale = Math.min(timelineScale + 1, maxScale); + setTimelineScale(newScale); + trackAnalytics('replay.timeline.zoom-in', { + organization, + }); + }, [timelineScale, maxScale, setTimelineScale, organization]); + return ( - +
) : ( - - + + SENTRY_PREVENT_TOKEN {TRUNCATED_TOKEN} - + - + ) ) : (