Skip to content

Commit 4ae298e

Browse files
authored
feat(data-forwarding): Implement a frontend interface for forwarding (#102819)
This PR introduces a frontend interface to interact with the data forwarding API but there are a few limitations: - It doesn't really feel responsive yet, the setup is uneventful when complete and must be saved at the bottom of the page each time - Deleting the forwarder doesn't reset the form to empty yet - The error messaging is just dropping JSON in an alert - The update and create options are all in the same page, so it's hard to tell if you're modifying or creating a new forwarder - There is no interface for updating the project level configurations yet -- that might be better on a separate page. But at minimum we can interact with an API and this is all behind a feature flag. https://github.com/user-attachments/assets/b11d88fb-88f7-4e56-b2a4-4eeab3c39cfd
1 parent 075cf2c commit 4ae298e

File tree

6 files changed

+548
-0
lines changed

6 files changed

+548
-0
lines changed

static/app/router/routes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,11 @@ function buildRoutes(): RouteObject[] {
12341234
name: t('Stats'),
12351235
children: statsChildren,
12361236
},
1237+
{
1238+
path: 'data-forwarding/',
1239+
name: t('Data Forwarding'),
1240+
component: make(() => import('sentry/views/settings/organizationDataForwarding')),
1241+
},
12371242
];
12381243
const orgSettingsRoutes: SentryRouteObject = {
12391244
component: make(

static/app/views/settings/organization/userOrgNavigationConfiguration.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] {
126126
description: t('View the audit log for an organization'),
127127
id: 'audit-log',
128128
},
129+
{
130+
path: `${organizationSettingsPathPrefix}/data-forwarding/`,
131+
title: t('Data Forwarding'),
132+
description: t('Manage data forwarding across your organization'),
133+
id: 'data-forwarding',
134+
badge: () => <FeatureBadge type="beta" />,
135+
recordAnalytics: true,
136+
show: ({organization}) =>
137+
!!organization &&
138+
organization.features.includes('data-forwarding-revamp-access'),
139+
},
129140
{
130141
path: `${organizationSettingsPathPrefix}/relay/`,
131142
title: t('Relay'),
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import type {JsonFormObject} from 'sentry/components/forms/types';
2+
import IdBadge from 'sentry/components/idBadge';
3+
import {t} from 'sentry/locale';
4+
import type {Organization} from 'sentry/types/organization';
5+
import type {Project} from 'sentry/types/project';
6+
import {
7+
DataForwarderProviderSlug,
8+
type DataForwarder,
9+
} from 'sentry/views/settings/organizationDataForwarding/types';
10+
11+
export function getDataForwarderFormGroups({
12+
provider,
13+
organization,
14+
dataForwarder,
15+
projects,
16+
}: {
17+
organization: Organization;
18+
projects: Project[];
19+
provider: DataForwarderProviderSlug;
20+
dataForwarder?: DataForwarder;
21+
}): JsonFormObject[] {
22+
let providerFormGroups: JsonFormObject[] = [];
23+
switch (provider) {
24+
case DataForwarderProviderSlug.SQS:
25+
providerFormGroups = [SQS_GLOBAL_CONFIGURATION_FORM];
26+
break;
27+
case DataForwarderProviderSlug.SEGMENT:
28+
providerFormGroups = [SEGMENT_GLOBAL_CONFIGURATION_FORM];
29+
break;
30+
case DataForwarderProviderSlug.SPLUNK:
31+
providerFormGroups = [SPLUNK_GLOBAL_CONFIGURATION_FORM];
32+
break;
33+
default:
34+
return [];
35+
}
36+
return [
37+
getEnablementForm({organization, dataForwarder}),
38+
...providerFormGroups,
39+
getProjectConfigurationForm(projects),
40+
];
41+
}
42+
43+
const getEnablementForm = ({
44+
organization,
45+
dataForwarder,
46+
}: {
47+
organization: Organization;
48+
dataForwarder?: DataForwarder;
49+
}): JsonFormObject => {
50+
const featureSet = new Set(organization.features);
51+
const hasAccess =
52+
featureSet.has('data-forwarding-revamp-access') && featureSet.has('data-forwarding');
53+
const hasCompleteSetup = dataForwarder;
54+
55+
return {
56+
title: t('Enablement'),
57+
fields: [
58+
{
59+
name: 'is_enabled',
60+
label: t('Enable data forwarding'),
61+
type: 'boolean',
62+
defaultValue: false,
63+
help: hasCompleteSetup
64+
? t('Will override everything to shut-off data forwarding.')
65+
: hasAccess
66+
? t('Will be disabled until the initial setup is complete.')
67+
: t('Data forwarding is not available to your organization.'),
68+
disabled: !hasAccess || !hasCompleteSetup,
69+
},
70+
],
71+
};
72+
};
73+
74+
function getProjectConfigurationForm(projects: Project[]): JsonFormObject {
75+
const projectOptions = projects.map(project => ({
76+
value: project.id,
77+
label: project.slug,
78+
leadingItems: <IdBadge project={project} avatarSize={16} disableLink hideName />,
79+
}));
80+
return {
81+
title: t('Project Configuration'),
82+
fields: [
83+
{
84+
name: 'enroll_new_projects',
85+
label: 'Auto-enroll new projects',
86+
type: 'boolean',
87+
help: 'Should new projects automatically forward their data?',
88+
},
89+
{
90+
name: 'project_ids',
91+
label: 'Forwarding projects',
92+
type: 'select',
93+
multiple: true,
94+
defaultValue: [],
95+
help: 'Select the projects which should forward their data.',
96+
options: projectOptions,
97+
},
98+
],
99+
};
100+
}
101+
102+
const SQS_GLOBAL_CONFIGURATION_FORM: JsonFormObject = {
103+
title: t('Global Configuration'),
104+
fields: [
105+
{
106+
name: 'queue_url',
107+
label: 'Queue URL',
108+
type: 'text',
109+
required: true,
110+
help: 'The URL of the SQS queue to forward events to.',
111+
placeholder: 'e.g. https://sqs.us-east-1.amazonaws.com/12345678/myqueue',
112+
},
113+
{
114+
name: 'region',
115+
label: 'Region',
116+
type: 'text',
117+
required: true,
118+
help: 'The region of the SQS queue to forward events to.',
119+
placeholder: 'e.g. us-east-1',
120+
},
121+
{
122+
name: 'access_key',
123+
label: 'Access Key',
124+
type: 'text',
125+
required: true,
126+
help: 'Currently only long-term IAM access keys are supported.',
127+
placeholder: 'e.g. AKIAIOSFODNN7EXAMPLE',
128+
},
129+
{
130+
name: 'secret_key',
131+
label: 'Secret Key',
132+
type: 'text',
133+
required: true,
134+
help: 'Only visible once when the access key is created..',
135+
placeholder: 'e.g. wJalrXUtnFEMI1K7MDENGSbPxRfiCYEXAMPLEKEY',
136+
},
137+
{
138+
name: 'message_group_id',
139+
label: 'Message Group ID',
140+
type: 'text',
141+
required: false,
142+
help: 'Required for FIFO queues, exclude for standard queues',
143+
placeholder: 'e.g. my-message-group-id',
144+
},
145+
{
146+
name: 's3_bucket',
147+
label: 'S3 Bucket',
148+
type: 'text',
149+
required: false,
150+
help: 'Specify a bucket to store events in S3. The SQS message will contain a reference to the payload location in S3. If no S3 bucket is provided, events over the SQS limit of 256KB will not be forwarded.',
151+
placeholder: 'e.g. my-s3-bucket',
152+
},
153+
],
154+
};
155+
156+
const SEGMENT_GLOBAL_CONFIGURATION_FORM: JsonFormObject = {
157+
title: t('Global Configuration'),
158+
fields: [
159+
{
160+
name: 'write_key',
161+
label: 'Write Key',
162+
type: 'text',
163+
required: true,
164+
help: 'Add an HTTP API Source to your Segment workspace to generate a write key.',
165+
placeholder: 'e.g. itA5bLOPNxccvZ9ON1NYg9EXAMPLEKEY',
166+
},
167+
],
168+
};
169+
170+
const SPLUNK_GLOBAL_CONFIGURATION_FORM: JsonFormObject = {
171+
title: t('Global Configuration'),
172+
fields: [
173+
{
174+
name: 'instance_url',
175+
label: 'Instance URL',
176+
type: 'text',
177+
required: true,
178+
help: 'The HTTP Event Collector endpoint for your Splunk instance. Ensure indexer acknowledgement is disabled.',
179+
placeholder: 'e.g. https://input-foo.cloud.splunk.com:8088',
180+
},
181+
{
182+
name: 'token',
183+
label: 'Token',
184+
type: 'text',
185+
required: true,
186+
help: 'The token generated for your HTTP Event Collector.',
187+
placeholder: 'e.g. 1234567890abcdef1234567890abcdef',
188+
},
189+
{
190+
name: 'index',
191+
label: 'Index',
192+
type: 'text',
193+
required: true,
194+
defaultValue: 'main',
195+
placeholder: 'e.g. main',
196+
help: 'The index to use for the events.',
197+
},
198+
{
199+
name: 'source',
200+
label: 'Source',
201+
type: 'text',
202+
required: true,
203+
defaultValue: 'sentry',
204+
placeholder: 'e.g. sentry',
205+
help: 'The source to use for the events.',
206+
},
207+
],
208+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
2+
import {t, tct} from 'sentry/locale';
3+
import {
4+
useApiQuery,
5+
useMutation,
6+
useQueryClient,
7+
type ApiQueryKey,
8+
type UseApiQueryOptions,
9+
} from 'sentry/utils/queryClient';
10+
import type RequestError from 'sentry/utils/requestError/requestError';
11+
import useApi from 'sentry/utils/useApi';
12+
import {
13+
ProviderLabels,
14+
type DataForwarder,
15+
} from 'sentry/views/settings/organizationDataForwarding/types';
16+
17+
const makeDataForwarderQueryKey = (params: {orgSlug: string}): ApiQueryKey => [
18+
`/organizations/${params.orgSlug}/forwarding/`,
19+
];
20+
21+
function useDataForwarders({
22+
params,
23+
options,
24+
}: {
25+
params: {orgSlug: string};
26+
options?: Partial<UseApiQueryOptions<DataForwarder[]>>;
27+
}) {
28+
return useApiQuery<DataForwarder[]>(makeDataForwarderQueryKey(params), {
29+
staleTime: 30000,
30+
...options,
31+
});
32+
}
33+
34+
/**
35+
* Simplified hook to get the primary data forwarder for an organization.
36+
*/
37+
export function useDataForwarder({
38+
orgSlug,
39+
}: {
40+
orgSlug: string;
41+
}): DataForwarder | undefined {
42+
const {data: dataForwarders = []} = useDataForwarders({params: {orgSlug}});
43+
return dataForwarders[0];
44+
}
45+
46+
const makeDataForwarderMutationQueryKey = (params: {
47+
dataForwarderId: string;
48+
orgSlug: string;
49+
}): ApiQueryKey => [
50+
`/organizations/${params.orgSlug}/forwarding/${params.dataForwarderId}/`,
51+
];
52+
53+
/**
54+
* Mutate a DataForwarder. If an ID is provided, it will be updated otherwise a new one will be created.
55+
*/
56+
export function useMutateDataForwarder({
57+
params: {orgSlug, dataForwarderId},
58+
}: {
59+
params: {orgSlug: string; dataForwarderId?: string};
60+
}) {
61+
const api = useApi({persistInFlight: false});
62+
const queryClient = useQueryClient();
63+
const method = dataForwarderId ? 'PUT' : 'POST';
64+
const listQueryKey = makeDataForwarderQueryKey({orgSlug});
65+
const [endpoint] = dataForwarderId
66+
? makeDataForwarderMutationQueryKey({dataForwarderId, orgSlug})
67+
: listQueryKey;
68+
return useMutation<DataForwarder, RequestError, DataForwarder>({
69+
mutationFn: data => api.requestPromise(endpoint, {method, data}),
70+
onSuccess: (dataForwarder: DataForwarder) => {
71+
addSuccessMessage(
72+
tct('[provider] data forwarder [action]', {
73+
provider: ProviderLabels[dataForwarder.provider],
74+
action: dataForwarderId ? t('updated') : t('created'),
75+
})
76+
);
77+
queryClient.invalidateQueries({queryKey: [endpoint]});
78+
queryClient.invalidateQueries({queryKey: listQueryKey});
79+
},
80+
onError: error => {
81+
const displayError =
82+
JSON.stringify(error.responseJSON) ?? t('Failed to update data forwarder');
83+
addErrorMessage(displayError);
84+
},
85+
});
86+
}
87+
88+
export function useDeleteDataForwarder({
89+
params,
90+
}: {
91+
params: {dataForwarderId: string; orgSlug: string};
92+
}) {
93+
const api = useApi({persistInFlight: false});
94+
const queryClient = useQueryClient();
95+
return useMutation<void, RequestError, {dataForwarderId: string; orgSlug: string}>({
96+
mutationFn: ({dataForwarderId, orgSlug}) =>
97+
api.requestPromise(
98+
makeDataForwarderMutationQueryKey({dataForwarderId, orgSlug})[0],
99+
{method: 'DELETE'}
100+
),
101+
onSuccess: () => {
102+
addSuccessMessage(t('Data forwarder deleted'));
103+
queryClient.invalidateQueries({queryKey: makeDataForwarderQueryKey(params)});
104+
},
105+
onError: _error => {
106+
addErrorMessage(t('Failed to delete data forwarder'));
107+
},
108+
});
109+
}

0 commit comments

Comments
 (0)