Two Sentry issues a millisecond apart, both of them empty, and the filter had to be written twice
CogniPrep's error inbox had a pair of issues that no amount of reading could turn into a bug report: Event `CustomEvent` (type=unhandledrejection) captured as promise rejection Object captured as promise rejection wit
CogniPrep's error inbox had a pair of issues that no amount of reading could turn into a bug report:
Event `CustomEvent` (type=unhandledrejection) captured as promise rejection
Object captured as promise rejection with keys: [object has no keys]
Same occurrence, one millisecond apart, no stack trace on either. Something on the page dispatches a synthetic unhandledrejection event: isTrusted: false, no reason property, no own keys at all. That is not the shape of a rejected promise from application code. It is the shape of an in-app browser shim or a third-party script firing a DOM event by hand, and the accessors sitting on the prototype are why Sentry ended up describing it as an object with no keys.
Two issues per occurrence, both permanently unactionable, both counting against the quota. Here is what it took to drop them, and why one filter was not enough.
There are two error handlers, and they are not the same code
The Sentry SDK on this site is not in the critical path. It is loaded by a dynamic import() scheduled on requestIdleCallback, which means there is a window between first paint and the SDK arriving where nothing would catch an error. So instrumentation-client.ts installs its own tiny listeners up front and buffers what they catch:
function handleRejection(event: PromiseRejectionEvent): void {
// Before the SDK arrives it is this listener, not Sentry's, that would turn an
// empty rejection into an issue. See isEmptyRejection.
if (isEmptyRejection(event.reason)) return;
bufferError(event.reason);
}
Once the SDK loads, it installs its own global handlers and replaces those. So the same judgement has to exist twice: once applied to a raw reason value, and once applied to the event object the SDK has already built out of it. Filtering in only one of those two places leaves a window where the noise gets through, and which window depends on how fast the user's connection is.
Deciding what "empty" means on the raw value
export function isEmptyRejection(reason: unknown): boolean {
if (reason === null || reason === undefined) return true;
if (typeof reason === 'string') return reason.trim() === '';
if (typeof reason !== 'object') return false;
const candidate = reason as { message?: unknown; stack?: unknown };
const hasDetail =
(typeof candidate.message === 'string' && candidate.message !== '') ||
typeof candidate.stack === 'string';
return !hasDetail && Object.keys(reason).length === 0;
}
The object branch is narrow on purpose, because the easy version of this function swallows real bugs. "No own keys" alone would throw away a DOMException, whose message and name live on the prototype, so an AbortError or a quota failure would vanish. Requiring both "no readable message or stack" and "no own keys" keeps those, and keeps a rejected fetch wrapper too, since anything shaped like { status: 500 } has a key worth reading.
Note also that typeof reason !== 'object' returns false, not true. A rejection with the value 0 is a terrible way to report a failure, but it is a deliberate one, and somebody wrote it.
Deciding what "empty" means after the SDK has rewritten it
By the time Sentry's own handler is done, the empty rejection is an event with a synthesised message. The second predicate is pinned to the exact strings the SDK generates, plus the absence of frames:
const EMPTY_REJECTION_VALUES = [
/^Event `[^`]*` \(type=unhandledrejection\) captured as promise rejection$/,
/^Object captured as promise rejection with keys: \[object has no keys\]$/,
/^Non-Error promise rejection captured with value: (?:undefined|null)?$/,
];
and wired into beforeSend:
beforeSend(event) {
if (
typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
) {
return null;
}
if (isEmptyRejectionEvent(event)) return null;
return event;
}
Two deliberate narrowings. The regexes are anchored, so Object captured as promise rejection with keys: message, status is reported, because that one has keys and the next person can go and read them. And any event with even one stack frame is reported regardless of its message, because a frame means there is somewhere to look.
The structural interface matters as much as the logic:
interface RejectionEvent {
exception?: { values?: { value?: string; stacktrace?: { frames?: unknown[] } }[] };
}
This module imports nothing. Not @sentry/nextjs, not even its types. The existing error-monitoring module in this codebase imports the SDK at the top level, so putting these helpers there would have pulled the whole thing back into the early bundle and undone the idle loading that made the first predicate necessary in the first place. A monitoring filter that costs you the bundle win is not a filter, it is a trade.
The tests describe the shim, not the theory
it('treats a synthetic unhandledrejection event as empty', () => {
class FakeEvent {
get type() { return 'unhandledrejection'; }
get isTrusted() { return false; }
}
expect(Object.keys(new FakeEvent())).toEqual([]);
expect(isEmptyRejection(new FakeEvent())).toBe(true);
});
The Object.keys assertion is there to pin the reason the production issue said what it said: getters on a prototype are exactly how a DOM interface is defined, and exactly how an object ends up with nothing enumerable on it. If a future refactor makes that assertion false, the premise of the whole module has changed.
The other half of the suite exists to prove the filter is not greedy: an Error, a DOMException, a { status: 500 }, a non-empty string, a stack trace, and an event with no exception at all all survive.
See it
Open cogniprep.app, give it a few seconds so the idle load has run, then open DevTools:
- In the Network tab, filter on
sentry. You will see the SDK's replay bundle come down frombrowser.sentry-cdn.com(153,163 bytes for 10.66.0) and a POST to aningest.us.sentry.ioenvelope endpoint. - Now run
window.dispatchEvent(new CustomEvent('unhandledrejection'))in the console, which is precisely the event the shim fires. - Watch the Network tab. Nothing new is sent. No new envelope, no new issue, and the page carries on.
I checked this in a real browser before publishing: identical resource lists before and after dispatching, one envelope request in both.
The site the noise was drowning out is at cogniprep.app, where timed practice tests for 56 assessment providers produce the kind of error report that does deserve a Sentry issue.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.