Skip to content

feat(uptime-form-errors): Improving assertion error handling#109352

Merged
Abdkhan14 merged 27 commits intomasterfrom
abdk/uptime-form-errors
Feb 27, 2026
Merged

feat(uptime-form-errors): Improving assertion error handling#109352
Abdkhan14 merged 27 commits intomasterfrom
abdk/uptime-form-errors

Conversation

@Abdkhan14
Copy link
Contributor

@Abdkhan14 Abdkhan14 commented Feb 25, 2026

Added previewCheckContext:

  • Populated by the TestUptimeMonitor Button and the form submission
  • Both places hit uptime-checker errors
  • Deliberately separated data and error in the state, to prevent having to use typeguards. The setters also respect exclusivity between data and error.
  • Open to be used by other form fields, didn't couple with assertion errors logic.

Used the value from previewCheckContext to resolve the errored assertion op:

  • Added logic to resolve errored op from assert_path in preview check errors and check_results for when the /preview -check/ was succeeded but the check-in didn't. Ex: Evaluation and verification errors
  • Passed the erroredOp to AssertionOpGroup to render errors in-line
  • Updated form level toasts to mention the error group

Added tests

Screenshot 2026-02-26 at 2 16 46 PM Screenshot 2026-02-26 at 2 16 25 PM

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Feb 25, 2026
@Abdkhan14 Abdkhan14 changed the title Abdk/uptime form errors feat(uptime-form-errors): Improving error handling Feb 25, 2026
Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Non-assertion field validation errors silently swallowed by mapFormErrors
    • I added mapUptimeFormErrors to preserve and flatten non-assertion validation errors (including nested dataSources errors) when no preview-check assertion error is present, and wired all uptime form mapFormErrors callbacks to use it.

Create PR

Or push these changes by commenting:

@cursor push 33d7390b32
Preview (33d7390b32)
diff --git a/static/app/views/alerts/rules/uptime/formErrors.spec.tsx b/static/app/views/alerts/rules/uptime/formErrors.spec.tsx
--- a/static/app/views/alerts/rules/uptime/formErrors.spec.tsx
+++ b/static/app/views/alerts/rules/uptime/formErrors.spec.tsx
@@ -4,6 +4,7 @@
   AssertionFormError,
   extractPreviewCheckError,
   mapPreviewCheckErrorToMessage,
+  mapUptimeFormErrors,
   resolveErroredAssertionOp,
 } from 'sentry/views/alerts/rules/uptime/formErrors';
 import {
@@ -86,6 +87,26 @@
   });
 });
 
+describe('mapUptimeFormErrors', () => {
+  it('passes through top-level field validation errors', () => {
+    const body = {url: ['Invalid URL']};
+    expect(mapUptimeFormErrors(body)).toEqual(body);
+  });
+
+  it('flattens dataSources field validation errors', () => {
+    const body = {
+      dataSources: {url: ['Invalid URL format'], method: ['Method is required']},
+      nonFieldErrors: ['Request invalid'],
+    };
+
+    expect(mapUptimeFormErrors(body)).toEqual({
+      nonFieldErrors: ['Request invalid'],
+      url: ['Invalid URL format'],
+      method: ['Method is required'],
+    });
+  });
+});
+
 describe('resolveErroredAssertionOp', () => {
   it('resolves errored op from assertion_failure_data by matching value', () => {
     const target = makeStatusCodeOp({

diff --git a/static/app/views/alerts/rules/uptime/formErrors.tsx b/static/app/views/alerts/rules/uptime/formErrors.tsx
--- a/static/app/views/alerts/rules/uptime/formErrors.tsx
+++ b/static/app/views/alerts/rules/uptime/formErrors.tsx
@@ -41,6 +41,31 @@
   };
 }
 
+function flattenDataSourceFormErrors(responseJson: any): any {
+  if (!responseJson || typeof responseJson !== 'object' || Array.isArray(responseJson)) {
+    return responseJson;
+  }
+
+  const dataSources = responseJson.dataSources;
+  if (!dataSources || typeof dataSources !== 'object' || Array.isArray(dataSources)) {
+    return responseJson;
+  }
+
+  const {dataSources: _, ...rest} = responseJson;
+  return {...rest, ...dataSources};
+}
+
+export function mapUptimeFormErrors(
+  responseJson: any,
+  previewCheckError?: PreviewCheckError | null
+): any {
+  const mappedPreviewCheckError = mapToFormErrors(
+    previewCheckError ?? extractPreviewCheckError(responseJson)
+  );
+
+  return mappedPreviewCheckError ?? flattenDataSourceFormErrors(responseJson);
+}
+
 function isPreviewCheckError(value: any): value is PreviewCheckError {
   return (
     value !== null &&

diff --git a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx
--- a/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx
+++ b/static/app/views/alerts/rules/uptime/uptimeAlertForm.tsx
@@ -41,7 +41,7 @@
 
 import {createEmptyAssertionRoot, UptimeAssertionsField} from './assertions/field';
 import {AssertionSuggestionsButton} from './assertionSuggestionsButton';
-import {extractPreviewCheckError, mapToFormErrors} from './formErrors';
+import {extractPreviewCheckError, mapUptimeFormErrors} from './formErrors';
 import {HTTPSnippet} from './httpSnippet';
 import {PreviewCheckResultProvider, usePreviewCheckResult} from './previewCheckContext';
 import {TestUptimeMonitorButton} from './testUptimeMonitorButton';
@@ -221,7 +221,7 @@
       mapFormErrors={responseJson => {
         const extractedError = extractPreviewCheckError(responseJson);
         previewCheckResult?.setPreviewCheckError(extractedError);
-        return mapToFormErrors(extractedError);
+        return mapUptimeFormErrors(responseJson, extractedError);
       }}
       onFieldChange={onFieldChange}
       onPreSubmit={() => {

diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx
--- a/static/app/views/detectors/components/forms/uptime/index.tsx
+++ b/static/app/views/detectors/components/forms/uptime/index.tsx
@@ -7,7 +7,7 @@
 import type {UptimeDetector} from 'sentry/types/workflowEngine/detectors';
 import {
   extractPreviewCheckError,
-  mapToFormErrors,
+  mapUptimeFormErrors,
 } from 'sentry/views/alerts/rules/uptime/formErrors';
 import {
   PreviewCheckResultProvider,
@@ -100,7 +100,7 @@
       mapFormErrors={responseJson => {
         const extractedError = extractPreviewCheckError(responseJson);
         previewCheckResult?.setPreviewCheckError(extractedError);
-        return mapToFormErrors(extractedError);
+        return mapUptimeFormErrors(responseJson, extractedError);
       }}
     >
       <UptimeDetectorForm />
@@ -139,7 +139,7 @@
       mapFormErrors={responseJson => {
         const extractedError = extractPreviewCheckError(responseJson);
         previewCheckResult?.setPreviewCheckError(extractedError);
-        return mapToFormErrors(extractedError);
+        return mapUptimeFormErrors(responseJson, extractedError);
       }}
     >
       <UptimeDetectorForm />
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@Abdkhan14 Abdkhan14 changed the title feat(uptime-form-errors): Improving error handling feat(uptime-form-errors): Improving assertion error handling Feb 26, 2026
Copy link
Member

@jaydgoss jaydgoss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feature — the context-based approach for sharing preview check results and the tree-walking logic to resolve errored assertion ops are well-designed. Two null-safety bugs need fixing before merge, plus a few code quality suggestions inline.

}

export function mapToFormErrors(responseJson: any) {
const error = extractPreviewCheckError(responseJson);
Copy link
Member

@jaydgoss jaydgoss Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapToFormErrors crashes when responseJson is null/undefined.

The deleted mapAssertionFormErrors had an explicit if (!responseJson) return responseJson; guard at the top. Here, extractPreviewCheckError uses optional chaining so it survives null, but line 47 accesses responseJson.dataSources directly — that will throw a TypeError.

FormModel.submitError passes err.responseJSON which can be undefined when the API returns no body.

Suggestion: add if (!responseJson) return responseJson; at the top of this function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

'assertion' in value &&
'error' in value.assertion
);
}
Copy link
Member

@jaydgoss jaydgoss Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isPreviewCheckError crashes when value.assertion is null.

The in operator throws TypeError when the right-hand side is null, undefined, or any primitive. If the API error response contains {assertion: null}, this will blow up and break all error handling paths.

Suggestion: add null/type guards before the 'error' in value.assertion check:

function isPreviewCheckError(value: any): value is PreviewCheckError {
  return (
    value !== null &&
    typeof value === 'object' &&
    'assertion' in value &&
    typeof value.assertion === 'object' &&
    value.assertion !== null &&
    'error' in value.assertion
  );
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

mapFormErrors={responseJson => {
previewCheckResult?.setPreviewCheckError(extractPreviewCheckError(responseJson));
return mapToFormErrors(responseJson);
}}
Copy link
Member

@jaydgoss jaydgoss Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: duplicated callback, this exact inline lambda appears in 3 places (here and twice in detectors/.../index.tsx). Consider extracting a shared helper in formErrors.tsx to reduce duplication and keep the behavior consistent:

export function createMapFormErrors(
  previewCheckResult: ReturnType<typeof usePreviewCheckResult>
) {
  return (responseJson: any) => {
    previewCheckResult?.setPreviewCheckError(extractPreviewCheckError(responseJson));
    return mapToFormErrors(responseJson);
  };
}

Then each call site becomes just mapFormErrors={createMapFormErrors(previewCheckResult)}.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, done

</Tooltip>
</span>
);
}
Copy link
Member

@jaydgoss jaydgoss Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Inline style prop, the frontend guide recommends using design system primitives or Emotion over inline styles. This could use <Flex> or a small styled component to stay consistent with the rest of the codebase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a Flex

<PreviewCheckResultContext.Provider
value={{
...state,
setPreviewCheckData,
Copy link
Member

@jaydgoss jaydgoss Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unstable context value, the setPreviewCheckData, setPreviewCheckError, and resetPreviewCheckResult callbacks are plain functions recreated every render, and the spread {...state, ...} creates a new object reference each time. This causes all context consumers to re-render on every provider render.

Consider wrapping callbacks in useCallback and the value object in useMemo:

const setPreviewCheckData = useCallback(
  (data: PreviewCheckResult | null) => setState({data, error: null}),
  []
);
// ... same for the others

const value = useMemo(
  () => ({...state, setPreviewCheckData, setPreviewCheckError, resetPreviewCheckResult}),
  [state, setPreviewCheckData, setPreviewCheckError, resetPreviewCheckResult]
);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

msg: 'bad path',
assertPath: ['0', '0'], // slice(1) = ['0'] → children[0] = first
},
},
Copy link
Member

@jaydgoss jaydgoss Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: The frontend testing guide recommends against mocking hooks. For the AssertionFormError component tests, you could wrap the component in <PreviewCheckResultProvider> and set state through the provider's API rather than mocking usePreviewCheckResult via jest.spyOn. This would test closer to how the component actually runs.

That said, since this is a custom context (not a standard Sentry hook like useOrganization), this is more pragmatic, totally fine to leave as-is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, pushed a Setter that sets the values on mount, thanks!

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Copy link
Member

@jaydgoss jaydgoss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Abdkhan14 Abdkhan14 merged commit d4a5a9b into master Feb 27, 2026
60 checks passed
@Abdkhan14 Abdkhan14 deleted the abdk/uptime-form-errors branch February 27, 2026 19:06
@github-actions github-actions bot locked and limited conversation to collaborators Mar 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants