From 3fb5df332b1482608aee3e7bdaeae6872d48de5c Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 23 Jan 2026 16:20:45 -0600 Subject: [PATCH 1/3] feat(uptime): Display assertion compilation errors in form Add error handling for assertion compilation/serialization errors from the uptime-checker validator. When the API returns an error like `{"assertion": {"error": "compilation_error", "details": "..."}}`, the form now properly extracts and displays the error message on the Assertions field in the Verification section. --- .../rules/uptime/uptimeAlertForm.spec.tsx | 38 +++++++++++++++++++ .../alerts/rules/uptime/uptimeAlertForm.tsx | 29 ++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx index 9e7cf81f8bf474..cb53b8f3125a91 100644 --- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx @@ -524,6 +524,44 @@ describe('Uptime Alert Form', () => { ); }); + it('displays assertion compilation errors', async () => { + const orgWithAssertions = OrganizationFixture({ + features: ['uptime-runtime-assertions'], + }); + OrganizationStore.onUpdate(orgWithAssertions); + + render(, {organization: orgWithAssertions}); + await screen.findByText('Verification'); + + await selectEvent.select(input('Project'), project.slug); + await selectEvent.select(input('Environment'), 'prod'); + await userEvent.clear(input('URL')); + await userEvent.type(input('URL'), 'http://example.com'); + + const name = input('Uptime rule name'); + await userEvent.clear(name); + await userEvent.type(name, 'Rule with Invalid Assertion'); + + MockApiClient.addMockResponse({ + url: `/projects/${orgWithAssertions.slug}/${project.slug}/uptime/`, + method: 'POST', + statusCode: 400, + body: { + assertion: { + error: 'compilation_error', + details: 'Invalid JSON path expression: syntax error at position 5', + }, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Create Rule'})); + + // The error message from the assertion compilation should be displayed + expect( + await screen.findByText('Invalid JSON path expression: syntax error at position 5') + ).toBeInTheDocument(); + }); + it('preserves null assertion when editing rule without assertions', async () => { const orgWithAssertions = OrganizationFixture({ features: ['uptime-runtime-assertions'], diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx index 06723e706ff98a..5714d818bdeb90 100644 --- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx @@ -93,6 +93,34 @@ function getFormDataFromRule(rule: UptimeRule) { }; } +/** + * Maps form errors from the API response format to the format expected by FormModel. + * + * Handles special cases like assertion errors which come back as: + * {"assertion": {"error": "compilation_error", "details": "..."}} + * + * And transforms them to: {"assertion": [""]} + */ +function mapFormErrors(responseJson: any): any { + if (!responseJson) { + return responseJson; + } + + const result = {...responseJson}; + + // Handle assertion errors from the uptime-checker validator + if ( + result.assertion && + typeof result.assertion === 'object' && + !Array.isArray(result.assertion) && + 'details' in result.assertion + ) { + result.assertion = [result.assertion.details]; + } + + return result; +} + export function UptimeAlertForm({handleDelete, rule}: Props) { const navigate = useNavigate(); const organization = useOrganization(); @@ -202,6 +230,7 @@ export function UptimeAlertForm({handleDelete, rule}: Props) { saveOnBlur={false} initialData={initialData} submitLabel={rule ? t('Save Rule') : t('Create Rule')} + mapFormErrors={mapFormErrors} onPreSubmit={() => { if (!methodHasBody(formModel)) { formModel.setValue('body', null); From df81402aaef815921f07a8ffdba1e7b9db859e7f Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 23 Jan 2026 16:50:45 -0600 Subject: [PATCH 2/3] feat(uptime): Add error type titles to assertion validation errors Add descriptive titles to assertion validation errors based on error type: - "Compilation Error" for invalid JSON path syntax - "Serialization Error" for malformed assertion structure - "Validation Error" as fallback Also adds test coverage for serialization errors. --- .../rules/uptime/uptimeAlertForm.spec.tsx | 46 ++++++++++++++++++- .../alerts/rules/uptime/uptimeAlertForm.tsx | 19 +++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx index cb53b8f3125a91..60f998fa7fcc02 100644 --- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.spec.tsx @@ -556,9 +556,51 @@ describe('Uptime Alert Form', () => { await userEvent.click(screen.getByRole('button', {name: 'Create Rule'})); - // The error message from the assertion compilation should be displayed + // The error message from the assertion compilation should be displayed with title expect( - await screen.findByText('Invalid JSON path expression: syntax error at position 5') + await screen.findByText( + 'Compilation Error: Invalid JSON path expression: syntax error at position 5' + ) + ).toBeInTheDocument(); + }); + + it('displays assertion serialization errors', async () => { + const orgWithAssertions = OrganizationFixture({ + features: ['uptime-runtime-assertions'], + }); + OrganizationStore.onUpdate(orgWithAssertions); + + render(, {organization: orgWithAssertions}); + await screen.findByText('Verification'); + + await selectEvent.select(input('Project'), project.slug); + await selectEvent.select(input('Environment'), 'prod'); + await userEvent.clear(input('URL')); + await userEvent.type(input('URL'), 'http://example.com'); + + const name = input('Uptime rule name'); + await userEvent.clear(name); + await userEvent.type(name, 'Rule with Invalid Assertion'); + + MockApiClient.addMockResponse({ + url: `/projects/${orgWithAssertions.slug}/${project.slug}/uptime/`, + method: 'POST', + statusCode: 400, + body: { + assertion: { + error: 'serialization_error', + details: 'unknown variant `invalid_op`, expected one of `and`, `or`', + }, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Create Rule'})); + + // The error message from the assertion serialization should be displayed with title + expect( + await screen.findByText( + 'Serialization Error: unknown variant `invalid_op`, expected one of `and`, `or`' + ) ).toBeInTheDocument(); }); diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx index 5714d818bdeb90..fbce19ce89b22a 100644 --- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx @@ -93,13 +93,27 @@ function getFormDataFromRule(rule: UptimeRule) { }; } +/** + * Maps assertion error types to user-friendly titles. + */ +function getAssertionErrorTitle(errorType: string): string { + switch (errorType) { + case 'compilation_error': + return t('Compilation Error'); + case 'serialization_error': + return t('Serialization Error'); + default: + return t('Validation Error'); + } +} + /** * Maps form errors from the API response format to the format expected by FormModel. * * Handles special cases like assertion errors which come back as: * {"assertion": {"error": "compilation_error", "details": "..."}} * - * And transforms them to: {"assertion": [""]} + * And transforms them to: {"assertion": ["Compilation Error: "]} */ function mapFormErrors(responseJson: any): any { if (!responseJson) { @@ -115,7 +129,8 @@ function mapFormErrors(responseJson: any): any { !Array.isArray(result.assertion) && 'details' in result.assertion ) { - result.assertion = [result.assertion.details]; + const title = getAssertionErrorTitle(result.assertion.error); + result.assertion = [`${title}: ${result.assertion.details}`]; } return result; From a11387c303313dd994fc0486b9d4c3a436b2449b Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 23 Jan 2026 16:59:40 -0600 Subject: [PATCH 3/3] feat(uptime): Add assertion error handling to detector forms - Extract assertion error mapping to shared utility (assertionFormErrors.tsx) - Handle both API response formats: - Direct: {"assertion": {"error": "...", "details": "..."}} - Nested: {"dataSources": {"assertion": {"error": "...", "details": "..."}}} - Add mapAssertionFormErrors to detector create/edit forms - Add comprehensive unit tests for error mapping --- .../rules/uptime/assertionFormErrors.spec.tsx | 105 ++++++++++++++++++ .../rules/uptime/assertionFormErrors.tsx | 79 +++++++++++++ .../alerts/rules/uptime/uptimeAlertForm.tsx | 46 +------- .../components/forms/uptime/index.tsx | 3 + 4 files changed, 189 insertions(+), 44 deletions(-) create mode 100644 static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx create mode 100644 static/app/views/alerts/rules/uptime/assertionFormErrors.tsx diff --git a/static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx b/static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx new file mode 100644 index 00000000000000..e1d2de63145566 --- /dev/null +++ b/static/app/views/alerts/rules/uptime/assertionFormErrors.spec.tsx @@ -0,0 +1,105 @@ +import {mapAssertionFormErrors} from 'sentry/views/alerts/rules/uptime/assertionFormErrors'; + +describe('mapAssertionFormErrors', () => { + it('returns null/undefined as-is', () => { + expect(mapAssertionFormErrors(null)).toBeNull(); + expect(mapAssertionFormErrors(undefined)).toBeUndefined(); + }); + + it('passes through responses without assertion errors', () => { + const response = {url: ['Invalid URL']}; + expect(mapAssertionFormErrors(response)).toEqual({url: ['Invalid URL']}); + }); + + it('handles direct assertion compilation errors (uptime alerts format)', () => { + const response = { + assertion: { + error: 'compilation_error', + details: 'Invalid JSON path expression: syntax error at position 5', + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: [ + 'Compilation Error: Invalid JSON path expression: syntax error at position 5', + ], + }); + }); + + it('handles direct assertion serialization errors (uptime alerts format)', () => { + const response = { + assertion: { + error: 'serialization_error', + details: 'unknown variant `invalid_op`, expected one of `and`, `or`', + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: [ + 'Serialization Error: unknown variant `invalid_op`, expected one of `and`, `or`', + ], + }); + }); + + it('handles nested assertion errors (detector forms format)', () => { + const response = { + dataSources: { + assertion: { + error: 'compilation_error', + details: + 'JSONPath Parser Error: --> 1:3\n |\n1 | $[oooooo\n | ^---\n |\n = expected selector', + }, + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: [ + 'Compilation Error: JSONPath Parser Error: --> 1:3\n |\n1 | $[oooooo\n | ^---\n |\n = expected selector', + ], + }); + }); + + it('preserves other dataSources fields when extracting assertion error', () => { + const response = { + dataSources: { + assertion: { + error: 'compilation_error', + details: 'Invalid expression', + }, + url: ['Invalid URL format'], + method: ['Method is required'], + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: ['Compilation Error: Invalid expression'], + dataSources: { + url: ['Invalid URL format'], + method: ['Method is required'], + }, + }); + }); + + it('handles unknown error types with fallback title', () => { + const response = { + assertion: { + error: 'unknown_error_type', + details: 'Something went wrong', + }, + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: ['Validation Error: Something went wrong'], + }); + }); + + it('does not modify assertion if already an array', () => { + const response = { + assertion: ['Already formatted error'], + }; + + expect(mapAssertionFormErrors(response)).toEqual({ + assertion: ['Already formatted error'], + }); + }); +}); diff --git a/static/app/views/alerts/rules/uptime/assertionFormErrors.tsx b/static/app/views/alerts/rules/uptime/assertionFormErrors.tsx new file mode 100644 index 00000000000000..29dd05b20b7f3f --- /dev/null +++ b/static/app/views/alerts/rules/uptime/assertionFormErrors.tsx @@ -0,0 +1,79 @@ +import {t} from 'sentry/locale'; + +/** + * Maps assertion error types to user-friendly titles. + */ +function getAssertionErrorTitle(errorType: string): string { + switch (errorType) { + case 'compilation_error': + return t('Compilation Error'); + case 'serialization_error': + return t('Serialization Error'); + default: + return t('Validation Error'); + } +} + +/** + * Checks if an object is an assertion error with error type and details. + */ +function isAssertionError(obj: unknown): obj is {details: string; error: string} { + return ( + typeof obj === 'object' && + obj !== null && + !Array.isArray(obj) && + 'details' in obj && + 'error' in obj + ); +} + +/** + * Formats an assertion error into a user-friendly message. + */ +function formatAssertionError(assertionError: { + details: string; + error: string; +}): string[] { + const title = getAssertionErrorTitle(assertionError.error); + return [`${title}: ${assertionError.details}`]; +} + +/** + * Maps form errors from the API response format to the format expected by FormModel. + * + * Handles assertion errors in two formats: + * + * 1. Direct format (uptime alerts): + * {"assertion": {"error": "compilation_error", "details": "..."}} + * + * 2. Nested format (detector forms): + * {"dataSources": {"assertion": {"error": "compilation_error", "details": "..."}}} + * + * Both are transformed to: {"assertion": ["Compilation Error: "]} + */ +export function mapAssertionFormErrors(responseJson: any): any { + if (!responseJson) { + return responseJson; + } + + const result = {...responseJson}; + + // Handle direct assertion errors (uptime alerts endpoint) + if (isAssertionError(result.assertion)) { + result.assertion = formatAssertionError(result.assertion); + } + + // Handle nested assertion errors (detector forms endpoint) + if (result.dataSources && isAssertionError(result.dataSources.assertion)) { + result.assertion = formatAssertionError(result.dataSources.assertion); + // Remove assertion from dataSources but preserve other fields + const {assertion: _, ...remainingDataSources} = result.dataSources; + if (Object.keys(remainingDataSources).length > 0) { + result.dataSources = remainingDataSources; + } else { + delete result.dataSources; + } + } + + return result; +} diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx index fbce19ce89b22a..abefb7746ce3e6 100644 --- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx +++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx @@ -39,6 +39,7 @@ import {makeAlertsPathname} from 'sentry/views/alerts/pathnames'; import type {UptimeRule} from 'sentry/views/alerts/rules/uptime/types'; import {createEmptyAssertionRoot, UptimeAssertionsField} from './assertions/field'; +import {mapAssertionFormErrors} from './assertionFormErrors'; import {HTTPSnippet} from './httpSnippet'; import {TestUptimeMonitorButton} from './testUptimeMonitorButton'; import {UptimeHeadersField} from './uptimeHeadersField'; @@ -93,49 +94,6 @@ function getFormDataFromRule(rule: UptimeRule) { }; } -/** - * Maps assertion error types to user-friendly titles. - */ -function getAssertionErrorTitle(errorType: string): string { - switch (errorType) { - case 'compilation_error': - return t('Compilation Error'); - case 'serialization_error': - return t('Serialization Error'); - default: - return t('Validation Error'); - } -} - -/** - * Maps form errors from the API response format to the format expected by FormModel. - * - * Handles special cases like assertion errors which come back as: - * {"assertion": {"error": "compilation_error", "details": "..."}} - * - * And transforms them to: {"assertion": ["Compilation Error: "]} - */ -function mapFormErrors(responseJson: any): any { - if (!responseJson) { - return responseJson; - } - - const result = {...responseJson}; - - // Handle assertion errors from the uptime-checker validator - if ( - result.assertion && - typeof result.assertion === 'object' && - !Array.isArray(result.assertion) && - 'details' in result.assertion - ) { - const title = getAssertionErrorTitle(result.assertion.error); - result.assertion = [`${title}: ${result.assertion.details}`]; - } - - return result; -} - export function UptimeAlertForm({handleDelete, rule}: Props) { const navigate = useNavigate(); const organization = useOrganization(); @@ -245,7 +203,7 @@ export function UptimeAlertForm({handleDelete, rule}: Props) { saveOnBlur={false} initialData={initialData} submitLabel={rule ? t('Save Rule') : t('Create Rule')} - mapFormErrors={mapFormErrors} + mapFormErrors={mapAssertionFormErrors} onPreSubmit={() => { if (!methodHasBody(formModel)) { formModel.setValue('body', null); diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx index 54b7be14474ec8..83bf408dffba59 100644 --- a/static/app/views/detectors/components/forms/uptime/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/index.tsx @@ -4,6 +4,7 @@ import {Stack} from 'sentry/components/core/layout'; import {t} from 'sentry/locale'; import type {UptimeDetector} from 'sentry/types/workflowEngine/detectors'; import useOrganization from 'sentry/utils/useOrganization'; +import {mapAssertionFormErrors} from 'sentry/views/alerts/rules/uptime/assertionFormErrors'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; @@ -73,6 +74,7 @@ export function NewUptimeDetectorForm() { extraFooterButton={ showTestButton ? : undefined } + mapFormErrors={mapAssertionFormErrors} > @@ -92,6 +94,7 @@ export function EditExistingUptimeDetectorForm({detector}: {detector: UptimeDete extraFooterButton={ showTestButton ? : undefined } + mapFormErrors={mapAssertionFormErrors} >