Extract diagnostic info from Safari CustomEvent rejections (#8173)

Fixes #8172  
Fixes
[COMPILER-EXPLORER-DXM](https://compiler-explorer.sentry.io/issues/COMPILER-EXPLORER-DXM)

## Problem

Sentry issue COMPILER-EXPLORER-DXM has accumulated **7,018+
occurrences** with essentially no diagnostic information:
- Error: `CustomEvent: Event 'CustomEvent' (type=unhandledrejection)
captured as promise rejection`
- No stacktrace
- Safari-specific (Safari 26.0 on macOS)
- No actionable information to debug the issue

## Root Cause

Safari sometimes rejects promises with `CustomEvent`/`Event` objects
instead of Error objects. Our `unhandledrejection` handler in
`static/sentry.ts:110-122` converts non-Error rejections to Error
objects using:

```typescript
const errorMessage = typeof reason === 'string' ? reason 
    : `Non-Error rejection: ${JSON.stringify(reason)}`;
```

When `JSON.stringify()` is called on Event objects, it returns `{}`
because Event properties are **non-enumerable**. This loses all valuable
diagnostic information like:
- `event.type` - the event type
- `event.target` - what triggered the event
- `event.detail` - custom data for CustomEvents

## Evidence

- [Sentry JavaScript SDK Issue
#2210](https://github.com/getsentry/sentry-javascript/issues/2210) -
Documents the exact same problem
- [WebKit Bug #150358](https://bugs.webkit.org/show_bug.cgi?id=150358) -
Promise rejection event handling
- Multiple Safari 16.3+ reports of CustomEvent rejections

## Solution

Extract meaningful properties from Event/CustomEvent objects **before**
stringifying:

1. **Add type guard**: `isEventLike()` to detect Event/CustomEvent
objects
2. **Extract properties**: `formatEventRejection()` to get `type`,
`target`, `detail`
3. **Cleaner code**: Refactor with ternary chain and modern TypeScript
patterns
4. **Better diagnostics**: Use `Object.assign()` for type-safe property
addition

### Before
```
Non-Error rejection: {}
```

### After
```
Event rejection: type="unhandledrejection", target="Window", detail={...}
```

## Impact

This will allow us to:
1.  Identify the actual source of these Safari rejections
2.  Determine if they're from CE code, third-party libraries, or
browser extensions
3.  Decide if they need fixing or should be filtered out
4.  Get actionable diagnostic information instead of empty objects

## Test Plan

-  TypeScript type checking passes
-  Linter passes with auto-formatting
-  Related tests pass
-  Pre-commit hooks pass
- Manual testing: Wait for Safari users to trigger these errors and
verify we now get useful diagnostic info in Sentry

## Code Review Notes

The fix operates at the **right layer** - transforming Events into
meaningful Error messages at the point of rejection, before Sentry sees
them. This is different from Sentry's `ExtraErrorData` integration which
operates on already-serialized errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Matt Godbolt
2025-10-07 15:44:15 -05:00
committed by GitHub
parent eca351fa49
commit 5ca970efce

View File

@@ -55,6 +55,25 @@ export function setSentryLayout(l: GoldenLayout) {
});
}
function isEventLike(value: unknown): value is Event {
return value instanceof Event || value?.constructor?.name === 'Event' || value?.constructor?.name === 'CustomEvent';
}
function formatEventRejection(evt: Event): string {
const targetName = evt.target?.constructor?.name ?? 'unknown';
let message = `Event rejection: type="${evt.type}", target="${targetName}"`;
if ('detail' in evt && evt.detail !== undefined) {
try {
message += `, detail=${JSON.stringify(evt.detail)}`;
} catch {
message += ', detail=[Unserializable]';
}
}
return message;
}
export function SetupSentry() {
if (options.statusTrackingEnabled && options.sentryDsn) {
Sentry.init({
@@ -108,16 +127,23 @@ export function SetupSentry() {
},
});
window.addEventListener('unhandledrejection', event => {
// Convert non-Error rejection reasons to Error objects
let reason = event.reason;
if (!(reason instanceof Error)) {
const errorMessage =
typeof reason === 'string' ? reason : `Non-Error rejection: ${JSON.stringify(reason)}`;
reason = new Error(errorMessage);
// Preserve original reason for debugging
(reason as any).originalReason = event.reason;
if (!(reason instanceof Error)) {
// Safari sometimes rejects promises with CustomEvent/Event objects.
// Extract useful properties instead of stringifying to empty object.
// See: https://github.com/compiler-explorer/compiler-explorer/issues/8172
// Related: https://github.com/getsentry/sentry-javascript/issues/2210
const errorMessage =
typeof reason === 'string'
? reason
: isEventLike(reason)
? formatEventRejection(reason)
: `Non-Error rejection: ${JSON.stringify(reason)}`;
reason = Object.assign(new Error(errorMessage), {originalReason: event.reason});
}
SentryCapture(reason, 'Unhandled Promise Rejection');
});
}