import {Fragment, PureComponent} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {fetchTagValues} from 'sentry/actionCreators/tags'; import type {Client} from 'sentry/api'; import { OnDemandMetricAlert, OnDemandWarningIcon, } from 'sentry/components/alerts/onDemandMetricAlert'; import {Alert} from 'sentry/components/core/alert'; import {Select} from 'sentry/components/core/select'; import {getHasTag} from 'sentry/components/events/searchBar'; import { STATIC_FIELD_TAGS, STATIC_FIELD_TAGS_WITHOUT_ERROR_FIELDS, STATIC_FIELD_TAGS_WITHOUT_TRACING, STATIC_FIELD_TAGS_WITHOUT_TRANSACTION_FIELDS, STATIC_SEMVER_TAGS, STATIC_SPAN_TAGS, } from 'sentry/components/events/searchBarFieldConstants'; import {components} from 'sentry/components/forms/controls/reactSelectWrapper'; import SelectField from 'sentry/components/forms/fields/selectField'; import FormField from 'sentry/components/forms/formField'; import IdBadge from 'sentry/components/idBadge'; import ExternalLink from 'sentry/components/links/externalLink'; import ListItem from 'sentry/components/list/listItem'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import {EAPSpanSearchQueryBuilder} from 'sentry/components/performance/spanSearchQueryBuilder'; import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; import {InvalidReason} from 'sentry/components/searchSyntax/parser'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {SelectValue} from 'sentry/types/core'; import type {Tag, TagCollection} from 'sentry/types/group'; import type {InjectedRouter} from 'sentry/types/legacyReactRouter'; import type {Organization} from 'sentry/types/organization'; import type {Environment, Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import {isAggregateField, isMeasurement} from 'sentry/utils/discover/fields'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {getDisplayName} from 'sentry/utils/environment'; import { ALLOWED_EXPLORE_VISUALIZE_AGGREGATES, DEVICE_CLASS_TAG_VALUES, FieldKind, isDeviceClass, } from 'sentry/utils/fields'; import { getMeasurements, type MeasurementCollection, } from 'sentry/utils/measurements/measurements'; import {getOnDemandKeys, isOnDemandQueryString} from 'sentry/utils/onDemandMetrics'; import {hasOnDemandMetricAlertFeature} from 'sentry/utils/onDemandMetrics/features'; import withApi from 'sentry/utils/withApi'; import withProjects from 'sentry/utils/withProjects'; import withTags from 'sentry/utils/withTags'; import WizardField from 'sentry/views/alerts/rules/metric/wizardField'; import { convertDatasetEventTypesToSource, DATA_SOURCE_LABELS, DATA_SOURCE_TO_SET_AND_EVENT_TYPES, } from 'sentry/views/alerts/utils'; import type {AlertType} from 'sentry/views/alerts/wizard/options'; import {getSupportedAndOmittedTags} from 'sentry/views/alerts/wizard/options'; import { SpanTagsContext, SpanTagsProvider, } from 'sentry/views/explore/contexts/spanTagsContext'; import {hasEAPAlerts} from 'sentry/views/insights/common/utils/hasEAPAlerts'; import {getProjectOptions} from '../utils'; import {isCrashFreeAlert} from './utils/isCrashFreeAlert'; import {DEFAULT_AGGREGATE, DEFAULT_TRANSACTION_AGGREGATE} from './constants'; import {AlertRuleComparisonType, Dataset, Datasource, TimeWindow} from './types'; const TIME_WINDOW_MAP: Record<TimeWindow, string> = { [TimeWindow.ONE_MINUTE]: t('1 minute'), [TimeWindow.FIVE_MINUTES]: t('5 minutes'), [TimeWindow.TEN_MINUTES]: t('10 minutes'), [TimeWindow.FIFTEEN_MINUTES]: t('15 minutes'), [TimeWindow.THIRTY_MINUTES]: t('30 minutes'), [TimeWindow.ONE_HOUR]: t('1 hour'), [TimeWindow.TWO_HOURS]: t('2 hours'), [TimeWindow.FOUR_HOURS]: t('4 hours'), [TimeWindow.ONE_DAY]: t('24 hours'), }; type Props = { aggregate: string; alertType: AlertType; api: Client; comparisonType: AlertRuleComparisonType; dataset: Dataset; disabled: boolean; isEditing: boolean; onComparisonDeltaChange: (value: number) => void; onFilterSearch: (query: string, isQueryValid: any) => void; onTimeWindowChange: (value: number) => void; organization: Organization; project: Project; projects: Project[]; router: InjectedRouter; tags: TagCollection; thresholdChart: React.ReactNode; timeWindow: number; // optional props allowChangeEventTypes?: boolean; comparisonDelta?: number; disableProjectSelector?: boolean; isErrorMigration?: boolean; isExtrapolatedChartData?: boolean; isLowConfidenceChartData?: boolean; isOnDemandLimitReached?: boolean; isTransactionMigration?: boolean; loadingProjects?: boolean; }; type State = { environments: Environment[] | null; filterKeys: TagCollection; measurements: MeasurementCollection; }; class RuleConditionsForm extends PureComponent<Props, State> { state: State = { environments: null, measurements: {}, filterKeys: {}, }; componentDidMount() { this.fetchData(); const measurements = getMeasurements(); const filterKeys = this.getFilterKeys(); this.setState({measurements, filterKeys}); } componentDidUpdate(prevProps: Props) { if (prevProps.project.id === this.props.project.id) { return; } this.fetchData(); } getFilterKeys = () => { const {organization, dataset, tags} = this.props; const {measurements} = this.state; const measurementsWithKind = Object.keys(measurements).reduce( (measurement_tags, key) => { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message measurement_tags[key] = { ...measurements[key], kind: FieldKind.MEASUREMENT, }; return measurement_tags; }, {} ); const orgHasPerformanceView = organization.features.includes('performance-view'); const combinedTags: TagCollection = dataset === Dataset.ERRORS ? Object.assign({}, STATIC_FIELD_TAGS_WITHOUT_TRANSACTION_FIELDS) : dataset === Dataset.TRANSACTIONS ? Object.assign( {}, measurementsWithKind, STATIC_SPAN_TAGS, STATIC_FIELD_TAGS_WITHOUT_ERROR_FIELDS ) : orgHasPerformanceView ? Object.assign({}, measurementsWithKind, STATIC_SPAN_TAGS, STATIC_FIELD_TAGS) : Object.assign({}, STATIC_FIELD_TAGS_WITHOUT_TRACING); const tagsWithKind = Object.keys(tags).reduce<Record<string, Tag>>((acc, key) => { acc[key] = { ...tags[key]!, kind: FieldKind.TAG, }; return acc; }, {}); const {omitTags} = getSupportedAndOmittedTags(dataset, organization); Object.assign(combinedTags, tagsWithKind, STATIC_SEMVER_TAGS); combinedTags.has = getHasTag(combinedTags); const list = omitTags && omitTags.length > 0 ? omit(combinedTags, omitTags) : combinedTags; return list; }; formElemBaseStyle = { padding: `${space(0.5)}`, border: 'none', }; async fetchData() { const {api, organization, project} = this.props; try { const environments = await api.requestPromise( `/projects/${organization.slug}/${project.slug}/environments/`, { query: { visibility: 'visible', }, } ); this.setState({environments}); } catch (_err) { addErrorMessage(t('Unable to fetch environments')); } } getEventFieldValues = async (tag: any, query: any): Promise<string[]> => { const {api, organization, project, dataset, router} = this.props; if (isAggregateField(tag.key) || isMeasurement(tag.key)) { // We can't really auto suggest values for aggregate fields // or measurements, so we simply don't // NOTE: these in particular are for discover queries. We may not need/support these return Promise.resolve([]); } // device.class is stored as "numbers" in snuba, but we want to suggest high, medium, // and low search filter values because discover maps device.class to these values. if (isDeviceClass(tag.key)) { return Promise.resolve(DEVICE_CLASS_TAG_VALUES); } const values = await fetchTagValues({ api, orgSlug: organization.slug, tagKey: tag.key, search: query, projectIds: [project.id], endpointParams: normalizeDateTimeParams(router.location.query), // allows searching for tags on transactions as well includeTransactions: true, // allows searching for tags on sessions as well includeSessions: dataset === Dataset.SESSIONS, }); return values.filter(({name}) => defined(name)).map(({name}) => name); }; get timeWindowOptions() { let options: Record<string, string> = TIME_WINDOW_MAP; if (isCrashFreeAlert(this.props.dataset)) { options = pick(TIME_WINDOW_MAP, [ // TimeWindow.THIRTY_MINUTES, leaving this option out until we figure out the sub-hour session resolution chart limitations TimeWindow.ONE_HOUR, TimeWindow.TWO_HOURS, TimeWindow.FOUR_HOURS, TimeWindow.ONE_DAY, ]); } if (this.props.comparisonType === AlertRuleComparisonType.DYNAMIC) { options = pick(TIME_WINDOW_MAP, [ TimeWindow.FIFTEEN_MINUTES, TimeWindow.THIRTY_MINUTES, TimeWindow.ONE_HOUR, ]); } if (this.props.dataset === Dataset.EVENTS_ANALYTICS_PLATFORM) { options = pick(TIME_WINDOW_MAP, [ TimeWindow.FIVE_MINUTES, TimeWindow.TEN_MINUTES, TimeWindow.FIFTEEN_MINUTES, TimeWindow.THIRTY_MINUTES, TimeWindow.ONE_HOUR, TimeWindow.TWO_HOURS, TimeWindow.FOUR_HOURS, TimeWindow.ONE_DAY, ]); } return Object.entries(options).map(([value, label]) => ({ value: parseInt(value, 10), label: tct('[timeWindow] interval', { timeWindow: label.slice(-1) === 's' ? label.slice(0, -1) : label, }), })); } get searchPlaceholder() { switch (this.props.dataset) { case Dataset.ERRORS: return t('Filter events by level, message, and other properties\u2026'); case Dataset.METRICS: case Dataset.SESSIONS: return t('Filter sessions by release version\u2026'); default: return t('Filter transactions by URL, tags, and other properties\u2026'); } } get selectControlStyles() { return { control: (provided: {[x: string]: string | number | boolean}) => ({ ...provided, minWidth: 200, maxWidth: 300, }), container: (provided: {[x: string]: string | number | boolean}) => ({ ...provided, margin: `${space(0.5)}`, }), }; } renderEventTypeFilter() { const {organization, disabled, alertType, isErrorMigration} = this.props; const dataSourceOptions = [ { label: t('Errors'), options: [ { value: Datasource.ERROR_DEFAULT, label: DATA_SOURCE_LABELS[Datasource.ERROR_DEFAULT], }, { value: Datasource.DEFAULT, label: DATA_SOURCE_LABELS[Datasource.DEFAULT], }, { value: Datasource.ERROR, label: DATA_SOURCE_LABELS[Datasource.ERROR], }, ], }, ]; if ( organization.features.includes('performance-view') && alertType === 'custom_transactions' ) { dataSourceOptions.push({ label: t('Transactions'), options: [ { value: Datasource.TRANSACTION, label: DATA_SOURCE_LABELS[Datasource.TRANSACTION], }, ], }); } return ( <FormField name="datasource" inline={false} style={{ ...this.formElemBaseStyle, minWidth: 300, flex: 2, }} flexibleControlStateSize > {({onChange, onBlur, model}: any) => { const formDataset = model.getValue('dataset'); const formEventTypes = model.getValue('eventTypes'); const aggregate = model.getValue('aggregate'); const mappedValue = convertDatasetEventTypesToSource( formDataset, formEventTypes ); return ( <Select value={mappedValue} inFieldLabel={t('Events: ')} onChange={({value}: any) => { onChange(value, {}); onBlur(value, {}); // Reset the aggregate to the default (which works across // datatypes), otherwise we may send snuba an invalid query // (transaction aggregate on events datasource = bad). const newAggregate = value === Datasource.TRANSACTION ? DEFAULT_TRANSACTION_AGGREGATE : DEFAULT_AGGREGATE; if (alertType === 'custom_transactions' && aggregate !== newAggregate) { model.setValue('aggregate', newAggregate); } // set the value of the dataset and event type from data source const {dataset: datasetFromDataSource, eventTypes} = // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message DATA_SOURCE_TO_SET_AND_EVENT_TYPES[value] ?? {}; model.setValue('dataset', datasetFromDataSource); model.setValue('eventTypes', eventTypes); }} options={dataSourceOptions} isDisabled={disabled || isErrorMigration} /> ); }} </FormField> ); } renderProjectSelector() { const { project: _selectedProject, projects, // note: org projects disabled, organization, disableProjectSelector, } = this.props; const projectOptions = getProjectOptions({ organization, projects, isFormDisabled: disabled, }); return ( <FormField name="projectId" inline={false} style={{ ...this.formElemBaseStyle, minWidth: 300, flex: 2, }} flexibleControlStateSize > {({onChange, onBlur, model}: any) => { const selectedProject = projects.find(({id}) => id === model.getValue('projectId')) || _selectedProject; return ( <Select isDisabled={disabled || disableProjectSelector} value={selectedProject.id} options={projectOptions} onChange={({value}: {value: Project['id']}) => { // if the current owner/team isn't part of project selected, update to the first available team const nextSelectedProject = projects.find(({id}) => id === value) ?? selectedProject; const ownerId: string | undefined = model .getValue('owner') ?.split(':')[1]; if ( ownerId && nextSelectedProject.teams.find(({id}) => id === ownerId) === undefined && nextSelectedProject.teams.length ) { model.setValue('owner', `team:${nextSelectedProject.teams[0]!.id}`); } onChange(value, {}); onBlur(value, {}); }} components={{ SingleValue: (containerProps: any) => ( <components.ValueContainer {...containerProps}> <IdBadge project={selectedProject} avatarProps={{consistentWidth: true}} avatarSize={18} disableLink /> </components.ValueContainer> ), }} /> ); }} </FormField> ); } renderInterval() { const {organization, timeWindow, disabled, alertType, project, onTimeWindowChange} = this.props; return ( <Fragment> <StyledListItem> <StyledListTitle> <div>{t('Define your metric')}</div> </StyledListTitle> </StyledListItem> <FormRow> <WizardField name="aggregate" help={null} organization={organization} disabled={disabled} project={project} style={{ ...this.formElemBaseStyle, flex: 1, }} inline={false} flexibleControlStateSize columnWidth={200} alertType={alertType} required /> <Select name="timeWindow" styles={this.selectControlStyles} options={this.timeWindowOptions} isDisabled={disabled} value={timeWindow} onChange={({value}: any) => onTimeWindowChange(value)} inline={false} flexibleControlStateSize /> </FormRow> </Fragment> ); } render() { const { alertType, organization, disabled, onFilterSearch, allowChangeEventTypes, dataset, isExtrapolatedChartData, isTransactionMigration, isErrorMigration, project, comparisonType, isLowConfidenceChartData, isOnDemandLimitReached, } = this.props; const {environments, filterKeys} = this.state; const environmentOptions: Array<SelectValue<string | null>> = [ { value: null, label: t('All Environments'), }, ...(environments?.map(env => ({value: env.name, label: getDisplayName(env)})) ?? []), ]; const confidenceEnabled = hasEAPAlerts(organization); return ( <Fragment> <ChartPanel> <StyledPanelBody>{this.props.thresholdChart}</StyledPanelBody> </ChartPanel> {isTransactionMigration ? ( <Fragment> <Spacer /> <HiddenListItem /> <HiddenListItem /> </Fragment> ) : ( <Fragment> <SpanTagsProvider dataset={DiscoverDatasets.SPANS_EAP} enabled={ organization.features.includes('alerts-eap') && alertType === 'eap_metrics' } > {isExtrapolatedChartData && ( <OnDemandMetricAlert message={t( 'The chart data above is an estimate based on the stored transactions that match the filters specified.' )} /> )} {confidenceEnabled && isLowConfidenceChartData && ( <Alert.Container> <Alert showIcon type="warning"> {t( 'Your low sample count may impact the accuracy of this alert. Edit your query or increase your sampling rate.' )} </Alert> </Alert.Container> )} {!isErrorMigration && this.renderInterval()} <StyledListItem>{t('Filter events')}</StyledListItem> <FormRow noMargin columns={1 + (allowChangeEventTypes ? 1 : 0) + 1}> {this.renderProjectSelector()} <SelectField name="environment" placeholder={t('All Environments')} style={{ ...this.formElemBaseStyle, minWidth: 230, flex: 1, }} styles={{ singleValue: (base: any) => ({ ...base, }), option: (base: any) => ({ ...base, }), }} options={environmentOptions} isDisabled={ disabled || this.state.environments === null || isErrorMigration } isClearable inline={false} flexibleControlStateSize /> {allowChangeEventTypes && this.renderEventTypeFilter()} </FormRow> <FormRow noMargin> <FormField name="query" inline={false} style={{ ...this.formElemBaseStyle, flex: '6 0 500px', }} flexibleControlStateSize > {({onChange, onBlur, initialData, value}: any) => { return alertType === 'eap_metrics' ? ( <SpanTagsContext.Consumer> {tags => ( <EAPSpanSearchQueryBuilder numberTags={tags?.number ?? {}} stringTags={tags?.string ?? {}} initialQuery={value ?? ''} searchSource="alerts" onSearch={(query, {parsedQuery}) => { onFilterSearch(query, parsedQuery); onChange(query, {}); }} supportedAggregates={ALLOWED_EXPLORE_VISUALIZE_AGGREGATES} projects={[parseInt(project.id, 10)]} /> )} </SpanTagsContext.Consumer> ) : ( <SearchContainer> <SearchQueryBuilder initialQuery={initialData?.query ?? ''} getTagValues={this.getEventFieldValues} placeholder={this.searchPlaceholder} searchSource="alert_builder" filterKeys={filterKeys} disabled={disabled || isErrorMigration} onChange={onChange} invalidMessages={{ [InvalidReason.WILDCARD_NOT_ALLOWED]: t( 'The wildcard operator is not supported here.' ), [InvalidReason.FREE_TEXT_NOT_ALLOWED]: t( 'Free text search is not allowed. If you want to partially match transaction names, use glob patterns like "transaction:*transaction-name*"' ), }} onSearch={query => { onFilterSearch(query, true); onChange(query, {}); }} onBlur={(query, {parsedQuery}) => { onFilterSearch(query, parsedQuery); onBlur(query); }} // We only need strict validation for Transaction queries, everything else is fine disallowUnsupportedFilters={ organization.features.includes('alert-allow-indexed') || (hasOnDemandMetricAlertFeature(organization) && isOnDemandQueryString(value)) ? false : dataset === Dataset.GENERIC_METRICS } /> {isExtrapolatedChartData && isOnDemandQueryString(value) && (isOnDemandLimitReached ? ( <OnDemandWarningIcon color="red400" msg={tct( 'We don’t routinely collect metrics from [fields] and you’ve already reached the limit of [docLink:alerts with advanced filters] for your organization.', { fields: ( <strong> {getOnDemandKeys(value) .map(key => `"${key}"`) .join(', ')} </strong> ), docLink: ( <ExternalLink href="https://docs.sentry.io/product/alerts/create-alerts/metric-alert-config/#advanced-filters-for-transactions" /> ), } )} isHoverable /> ) : ( <OnDemandWarningIcon color="gray500" msg={tct( 'We don’t routinely collect metrics from [fields]. However, we’ll do so [strong:once this alert has been saved.]', { fields: ( <strong> {getOnDemandKeys(value) .map(key => `"${key}"`) .join(', ')} </strong> ), strong: <strong />, } )} /> ))} </SearchContainer> ); }} </FormField> </FormRow> <FormRow noMargin> <FormField name="query" inline={false} style={{ ...this.formElemBaseStyle, flex: '6 0 500px', }} flexibleControlStateSize > {(args: any) => { if ( args.value?.includes('is:unresolved') && comparisonType === AlertRuleComparisonType.DYNAMIC ) { return ( <OnDemandMetricAlert message={t( "'is:unresolved' queries are not supported by Anomaly Detection alerts." )} /> ); } return null; }} </FormField> </FormRow> </SpanTagsProvider> </Fragment> )} </Fragment> ); } } const StyledListTitle = styled('div')` display: flex; span { margin-left: ${space(1)}; } `; // This is a temporary hacky solution to hide list items without changing the numbering of the rest of the list // TODO(issues): Remove this once the migration is complete const HiddenListItem = styled(ListItem)` position: absolute; width: 0px; height: 0px; opacity: 0; pointer-events: none; `; const Spacer = styled('div')` margin-bottom: ${space(2)}; `; const ChartPanel = styled(Panel)` margin-bottom: ${space(1)}; `; const StyledPanelBody = styled(PanelBody)` ol, h4 { margin-bottom: ${space(1)}; } `; const SearchContainer = styled('div')` display: flex; align-items: center; gap: ${space(1)}; `; const StyledListItem = styled(ListItem)` margin-bottom: ${space(0.5)}; font-size: ${p => p.theme.fontSizeExtraLarge}; line-height: 1.3; `; const FormRow = styled('div')<{columns?: number; noMargin?: boolean}>` display: flex; flex-direction: row; align-items: center; flex-wrap: wrap; margin-bottom: ${p => (p.noMargin ? 0 : space(4))}; ${p => p.columns !== undefined && css` display: grid; grid-template-columns: repeat(${p.columns}, auto); `} `; export default withApi(withProjects(withTags(RuleConditionsForm)));