diff --git a/devservices/config.yml b/devservices/config.yml index 51f67a698eca0c..2a448bc5502cce 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 c5374e1dcb0b4a..d2ef563c8267b0 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 27000dd16eee9b..7da2167d11e02d 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 bfcef71f5d2ba5..c06a719e833f2b 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 d87dec54a877ad..bf8c7c51e268f0 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 dc0a4382c0b574..3506ca3284856e 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 c7afbd5f57fa38..0244c9a811de19 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 ce5483df02e806..8492b931ec072a 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 2bcf8976511a17..52fdcde5eeb5d7 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 9680891ee1312a..e32414acf98b2c 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 83d1f9b6637151..e8718423086882 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 10ca0ba7251bda..c3cf88fda38c52 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 98d4051a870b01..a48369b5a9d2da 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 e69de29bb2d1d6..32860f7f1574f9 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 00000000000000..96ea66e74f1afb --- /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 20b1b2e68f7c93..c3ecef72cade65 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 23f793aa099305..d96cf2062a9916 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 8dd77f455b57b2..733b12112936da 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 a0ee4783db1c61..91b7ed4059028d 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 76eb87e0978f32..9b355333d9223a 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 4477e1f295c48b..e8adb5739e1659 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 1732507064c229..a32020860d5f91 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 0784709989123d..f627d90d036a5a 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 c1683e5e100c1f..10521166584e9a 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 e6f1fba79a2c0f..4f7ffbe50f4542 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 9ead2764b47722..e469f5b11b2de2 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 4089cab8f2b130..de590aeca6dbbc 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 1def6fd99c49be..6fe564343cd253 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 c26916626ca86f..55f967834ca485 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 b387f05c4039fc..d8c351493dbe71 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 2c1a90cd320b8f..51fa18dbfda3e7 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 1843b1b7047e9f..47d3d174db8c36 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 e80d3f639f32db..aafc5f6ddef213 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 00000000000000..8cf3c23dcb88e5 --- /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 00000000000000..e593e29b0cc93f --- /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 c4f9d28f1d822c..e18e4c6fc0ac9d 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 ee0d9b7948899a..5d98fc59cbba0e 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 e4375dab72e41f..f325db0faa62c7 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} - + - + ) ) : (