diff --git a/src/sentry/apidocs/examples/integration_examples.py b/src/sentry/apidocs/examples/integration_examples.py index 32a712fc4f3b6c..65151a09526474 100644 --- a/src/sentry/apidocs/examples/integration_examples.py +++ b/src/sentry/apidocs/examples/integration_examples.py @@ -754,3 +754,150 @@ class IntegrationExamples: response_only=True, ) ] + + LIST_DATA_FORWARDERS = [ + OpenApiExample( + "List all data forwarders for an organization", + value=[ + [ + { + "id": "1", + "organizationId": "1", + "isEnabled": True, + "enrollNewProjects": True, + "enrolledProjects": [], + "provider": "sqs", + "config": { + "region": "us-east-1", + "queue_url": "https://sqs.us-east-1.amazonaws.com/01234567890/sentry-errors.fifo", + "s3_bucket": "sentry-errors-bucket", + "access_key": "AKIAIOSFODNN7EXAMPLE", + "secret_key": "wJalrXUtnFEMI1K7MDENGSbPxRfiCYEXAMPLEKEY", + "message_group_id": "sentry-errors", + }, + "projectConfigs": [], + "dateAdded": "2025-11-01T00:00:00.000000Z", + "dateUpdated": "2025-11-01T00:00:00.000000Z", + }, + { + "id": "2", + "organizationId": "1", + "isEnabled": True, + "enrollNewProjects": False, + "enrolledProjects": [ + {"id": "1", "slug": "proj-1", "platform": "javascript-react"}, + {"id": "2", "slug": "proj-2", "platform": "python-flask"}, + ], + "provider": "segment", + "config": {"write_key": "itA5bLOPNxccvZ9ON1NYg9EXAMPLEKEY"}, + "projectConfigs": [ + { + "id": "1", + "isEnabled": True, + "dataForwarderId": "2", + "project": { + "id": "1", + "slug": "proj-1", + "platform": "javascript-react", + }, + "overrides": {}, + "effectiveConfig": { + "write_key": "itA5bLOPNxccvZ9ON1NYg9EXAMPLEKEY" + }, + "dateAdded": "2025-11-01T00:00:00.000000Z", + "dateUpdated": "2025-11-01T00:00:00.000000Z", + }, + { + "id": "2", + "isEnabled": True, + "dataForwarderId": "2", + "project": { + "id": "2", + "slug": "proj-2", + "platform": "python-flask", + }, + "overrides": {}, + "effectiveConfig": { + "write_key": "itA5bLOPNxccvZ9ON1NYg9EXAMPLEKEY" + }, + "dateAdded": "2025-11-01T00:00:00.000000Z", + "dateUpdated": "2025-11-01T00:00:00.000000Z", + }, + ], + "dateAdded": "2025-11-01T00:00:00.000000Z", + "dateUpdated": "2025-11-01T00:00:00.000000Z", + }, + { + "id": "3", + "organizationId": "1", + "isEnabled": True, + "enrollNewProjects": True, + "enrolledProjects": [ + {"id": "1", "slug": "proj-1", "platform": "javascript-react"}, + ], + "provider": "splunk", + "config": { + "index": "main", + "token": "ab13cdef-45aa-1bcd-a123-bcEXAMPLEKEY", + "source": "sentry", + "instance_url": "https://prd-a-abcde.splunkcloud.com:8088", + }, + "projectConfigs": [ + { + "id": "3", + "isEnabled": True, + "dataForwarderId": "3", + "project": { + "id": "1", + "slug": "proj-1", + "platform": "javascript-react", + }, + "overrides": { + "source": "sentry-custom", + }, + "effectiveConfig": { + "index": "main", + "token": "ab13cdef-45aa-1bcd-a123-bcEXAMPLEKEY", + "source": "sentry-custom", + "instance_url": "https://prd-a-abcde.splunkcloud.com:8088", + }, + "dateAdded": "2025-11-01T00:00:00.000000Z", + "dateUpdated": "2025-11-01T00:00:00.000000Z", + } + ], + "dateAdded": "2025-11-01T00:00:00.000000Z", + "dateUpdated": "2025-11-01T00:00:00.000000Z", + }, + ] + ], + status_codes=["200"], + response_only=True, + ) + ] + + SINGLE_DATA_FORWARDER = [ + OpenApiExample( + "A data forwarder for an organization", + value={ + "id": "1", + "organizationId": "1", + "isEnabled": True, + "enrollNewProjects": True, + "enrolledProjects": [], + "provider": "sqs", + "config": { + "region": "us-east-1", + "queue_url": "https://sqs.us-east-1.amazonaws.com/01234567890/sentry-errors.fifo", + "s3_bucket": "sentry-errors-bucket", + "access_key": "AKIAIOSFODNN7EXAMPLE", + "secret_key": "wJalrXUtnFEMI1K7MDENGSbPxRfiCYEXAMPLEKEY", + "message_group_id": "sentry-errors", + }, + "projectConfigs": [], + "dateAdded": "2025-11-01T00:00:00.000000Z", + "dateUpdated": "2025-11-01T00:00:00.000000Z", + }, + status_codes=["200"], + response_only=True, + ) + ] diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 47c7b137595c66..832c8ec58f6385 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -544,6 +544,16 @@ class MetricAlertParams: ) +class DataForwarderParams: + DATA_FORWARDER_ID = OpenApiParameter( + name="data_forwarder_id", + location="path", + required=True, + type=int, + description="The ID of the data forwarder you'd like to query.", + ) + + class SentryAppParams: SENTRY_APP_ID_OR_SLUG = OpenApiParameter( name="sentry_app_id_or_slug", diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_details.py b/src/sentry/integrations/api/endpoints/data_forwarding_details.py index 7b47233cb9b9ab..f3e4539ac777d6 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_details.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_details.py @@ -18,7 +18,8 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NO_CONTENT -from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.examples.integration_examples import IntegrationExamples +from sentry.apidocs.parameters import DataForwarderParams, GlobalParams from sentry.integrations.api.serializers.models.data_forwarder import ( DataForwarderSerializer as DataForwarderModelSerializer, ) @@ -290,13 +291,14 @@ def _update_single_project_configuration( @method_decorator(never_cache) @extend_schema( operation_id="Update a Data Forwarding Configuration for an Organization", - parameters=[GlobalParams.ORG_ID_OR_SLUG], + parameters=[GlobalParams.ORG_ID_OR_SLUG, DataForwarderParams.DATA_FORWARDER_ID], request=DataForwarderSerializer, responses={ 200: DataForwarderModelSerializer, 400: RESPONSE_BAD_REQUEST, 403: RESPONSE_FORBIDDEN, }, + examples=IntegrationExamples.SINGLE_DATA_FORWARDER, ) def put( self, request: Request, organization: Organization, data_forwarder: DataForwarder @@ -332,7 +334,7 @@ def put( @extend_schema( operation_id="Delete a Data Forwarding Configuration for an Organization", - parameters=[GlobalParams.ORG_ID_OR_SLUG], + parameters=[GlobalParams.ORG_ID_OR_SLUG, DataForwarderParams.DATA_FORWARDER_ID], responses={ 204: RESPONSE_NO_CONTENT, 403: RESPONSE_FORBIDDEN, diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index.py b/src/sentry/integrations/api/endpoints/data_forwarding_index.py index 44538b9d5e54eb..f67b9e83787e0d 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_index.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index.py @@ -13,8 +13,10 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize -from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_CONFLICT, RESPONSE_FORBIDDEN +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN +from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.integrations.api.serializers.models.data_forwarder import ( DataForwarderSerializer as DataForwarderModelSerializer, ) @@ -52,8 +54,11 @@ def convert_args(self, request: Request, *args, **kwargs): operation_id="Retrieve Data Forwarding Configurations for an Organization", parameters=[GlobalParams.ORG_ID_OR_SLUG], responses={ - 200: DataForwarderModelSerializer, + 200: inline_sentry_response_serializer( + "ListDataForwarderResponse", list[DataForwarderSerializer] + ) }, + examples=IntegrationExamples.LIST_DATA_FORWARDERS, ) @set_referrer_policy("strict-origin-when-cross-origin") @method_decorator(never_cache) @@ -75,8 +80,8 @@ def get(self, request: Request, organization) -> Response: 201: DataForwarderModelSerializer, 400: RESPONSE_BAD_REQUEST, 403: RESPONSE_FORBIDDEN, - 409: RESPONSE_CONFLICT, }, + examples=IntegrationExamples.SINGLE_DATA_FORWARDER, ) @set_referrer_policy("strict-origin-when-cross-origin") @method_decorator(never_cache) diff --git a/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py b/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py index 137ea9165900ba..9ab8a4c84e4c5a 100644 --- a/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py +++ b/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py @@ -40,19 +40,35 @@ class SplunkConfig(TypedDict, total=False): class DataForwarderSerializer(Serializer): - organization_id = serializers.IntegerField() - is_enabled = serializers.BooleanField(default=True) - enroll_new_projects = serializers.BooleanField(default=False) + organization_id = serializers.IntegerField( + help_text="The ID of the organization related to the data forwarder." + ) + is_enabled = serializers.BooleanField( + default=True, help_text="Whether the data forwarder is enabled." + ) + enroll_new_projects = serializers.BooleanField( + default=False, + help_text="Whether to enroll new projects automatically, after they're created.", + ) provider = serializers.ChoiceField( choices=[ (DataForwarderProviderSlug.SEGMENT, "Segment"), (DataForwarderProviderSlug.SQS, "Amazon SQS"), (DataForwarderProviderSlug.SPLUNK, "Splunk"), - ] + ], + help_text='The provider of the data forwarder. One of "segment", "sqs", or "splunk".', + ) + config = serializers.DictField( + child=serializers.CharField(allow_blank=True), + default=dict, + help_text="The configuration for the data forwarder.", ) - config = serializers.DictField(child=serializers.CharField(allow_blank=True), default=dict) project_ids = serializers.ListField( - child=serializers.IntegerField(), allow_empty=True, required=False, default=list + child=serializers.IntegerField(), + allow_empty=True, + required=False, + default=list, + help_text="The IDs of the projects to attach the data forwarder to.", ) def validate_config(self, config) -> SQSConfig | SegmentConfig | SplunkConfig: diff --git a/static/app/views/settings/organizationDataForwarding/components/projectOverrideForm.tsx b/static/app/views/settings/organizationDataForwarding/components/projectOverrideForm.tsx index a99dd9797ff50e..756ebc7851484e 100644 --- a/static/app/views/settings/organizationDataForwarding/components/projectOverrideForm.tsx +++ b/static/app/views/settings/organizationDataForwarding/components/projectOverrideForm.tsx @@ -10,6 +10,7 @@ import JsonForm from 'sentry/components/forms/jsonForm'; import FormModel from 'sentry/components/forms/model'; import Panel from 'sentry/components/panels/panel'; import PanelHeader from 'sentry/components/panels/panelHeader'; +import {IconRefresh} from 'sentry/icons'; import {IconInfo} from 'sentry/icons/iconInfo'; import {t} from 'sentry/locale'; import type {AvatarProject} from 'sentry/types/project'; @@ -75,7 +76,20 @@ export function ProjectOverrideForm({ )} renderFooter={() => ( - + + diff --git a/static/app/views/settings/organizationDataForwarding/util/forms.tsx b/static/app/views/settings/organizationDataForwarding/util/forms.tsx index 078a11fe882499..64bf0a47582462 100644 --- a/static/app/views/settings/organizationDataForwarding/util/forms.tsx +++ b/static/app/views/settings/organizationDataForwarding/util/forms.tsx @@ -188,7 +188,7 @@ const SQS_GLOBAL_CONFIGURATION_FORM: JsonFormObject = { label: 'Secret Key', type: 'text', required: true, - help: 'Only visible once when the access key is created..', + help: 'Only visible once when the access key is created.', placeholder: 'e.g. wJalrXUtnFEMI1K7MDENGSbPxRfiCYEXAMPLEKEY', }, { @@ -241,7 +241,7 @@ const SPLUNK_GLOBAL_CONFIGURATION_FORM: JsonFormObject = { type: 'text', required: true, help: 'The token generated for your HTTP Event Collector.', - placeholder: 'e.g. 1234567890abcdef1234567890abcdef', + placeholder: 'e.g. ab13cdef-45aa-1bcd-a123-bcEXAMPLEKEY', }, { name: 'index',