This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
On 22 November 2021, @novocaine opened issue #32242 on Element Web. He diagnosed it correctly, named the exact Sentry integration that would fix it, and left one open question he couldn't answer.
It sat there for four years and nine months.
On 12 August 2026 I shipped the fix — and answered his question.
Project Overview
Element Web is the flagship Matrix client — secure, decentralised, end-to-end-encrypted collaboration. 13.4k stars, 2.7k forks, ~3,600 open issues, AGPL-3.0. It ships as a web app and, packaged with Electron, as Element Desktop.
The user base is what makes this interesting. Element is deployed by governments, hospitals, universities and armed forces — organisations that choose Matrix precisely because they can't hand their communications to a US SaaS vendor. Many of those deployments are desktop-first.
Which makes it a genuine production-observability problem that, for nearly five years, Element Desktop was the one platform whose crash reports the maintainers couldn't actually read.
I landed three merged fixes and one that's design-approved and still in review. This post is mostly about the Sentry one.
Bug Fix or Performance Improvement
The main event: Element Desktop's crash reports were minified garbage
Element Desktop doesn't serve the app over https://. Electron serves it from a custom protocol, vector://vector/webapp. So every stack frame Sentry captured on desktop looked like this:
vector://vector/webapp/bundles/abc123/bundle.js
Sentry's source-map resolver has no idea what vector:// is. It can't match that path against the uploaded artifacts for the release. Two things followed, and the second is the expensive one:
1. Desktop crashes arrived unsymbolicated. Minified function names, no original file, no real line numbers. t.default.a is not a function at bundle.js:1:284915. Technically a crash report; practically a shrug.
2. Desktop and web crashes stopped grouping. Sentry groups issues partly by stack frame filenames. Web reported https://app.element.io/bundles/abc123/bundle.js; desktop reported vector://vector/webapp/bundles/abc123/bundle.js. Same crash, same release, same line of source — two separate issues in Sentry.
That second one quietly corrupts prioritisation. An issue hitting 600 users looks like two issues hitting 400 and 200. One is legible and one isn't, so the legible one gets triaged and the illegible one gets ignored — and the illegible one is the platform enterprise customers are running.
novocaine spotted all of this in 2021:
Sentry errors emitted by Element Desktop aren't being source-mapped, which means they aren't being grouped with the same exceptions on Element Web, and are harder to debug.
He even linked Sentry's RewriteFrames docs and said that's what should be used. Then he added the part that I think is why nothing happened for five years:
The bundle hash is also in the pathname, and it doesn't match web on the same release (not sure why this is) … I'm interested in why 2 is happening at all, it may lead to issues later on
An unanswered question inside a bug report is a very effective blocker. The issue got labelled S-Minor and O-Uncommon and went to sleep.
The others
- #34213 — in Element's Light High Contrast theme, hovering a Spotlight search result rendered light grey text on a light grey background. Measured contrast 1.38:1; WCAG AA requires 4.5:1. The high-contrast theme was the illegible one.
- #34463 — the Threads panel header was 60px while the Pinned Messages banner above it was 64px, so their separators didn't line up. A 4px bug you can't unsee once you've seen it.
- #34294 — the room-list submenu opened on hover but never dismissed on hover-out. This one got rejected, reopened, and rebuilt, and it taught me more than the other three combined.
Code
| PR | Status | What |
|---|---|---|
| #34667 | ✅ Merged 12 Aug 2026 |
Sentry rewriteFramesIntegration for Element Desktop — closes a 2021 issue. 2 files, +89 |
| #34465 | ✅ Merged 13 Aug 2026 | High-contrast Spotlight legibility + cross-theme axe regression suite. 4 files, +83 −12 |
| #34566 | ✅ Merged 7 Aug 2026 | Threads panel header alignment + Playwright height assertion. 3 files, +46 −1 |
| #34468 | 🔄 Open — design-approved | Submenu hover-out dismissal. Closed, reopened, rebuilt after my first attempt turned out to be wrong |
My Improvements
The Sentry work is in its own section below. Here's the rest, and the through-line: in three of these four, widening the test found a bug nobody had reported.
A semantic token that means the opposite thing in a different theme
My first instinct on #34213 was a missing hover colour. Wrong — the colour was there and it was deliberate:
.mx_SpotlightDialog_option {
&:hover,
&[aria-selected="true"] {
background-color: $quinary-content !important;
color: $background !important; /* ← the bug */
}
}
$background is the theme's canvas colour; $quinary-content is the hover fill. In a dark theme this is correct — light pill, dark canvas colour, readable. In light-high-contrast, $background is white-ish. So is $quinary-content. Same rule, inverted theme, invisible text.
| Before | After |
|---|---|
![]() |
![]() |
This is a design-token failure, not a typo, which is why it survived review for years. color: $background reads as intentional. It's only wrong once you know what the token resolves to in that specific theme. The correct token is $primary-content — changed in 12 places across search results, recently-viewed rows, the filter chip, the keyboard-shortcut hint and the generic dropdown menu.
The !important on nearly every one of those lines is its own signal. Rules that had to be fought into place don't get re-derived when someone adds a new theme.
Widening the test caught a second, unreported bug
My first test computed the WCAG ratio by hand in Playwright — 1.38:1 before, ≥4.5:1 after. @t3chguy pushed me to use axe-core instead, and he was right for a reason I'd underweighted: my version only checked the four elements I thought to check. It was a spot check wearing the costume of a general one.
Then @Half-Shot pushed further — don't test only high contrast:
for (const theme of ["light", "dark", "light-high-contrast"]) {
test.describe(`${theme} theme legibility`, () => {
// Regression tests for https://github.com/element-hq/element-web/issues/34213
// Hovered/selected Spotlight results must not render light text on a light
// background (or vice versa) in any theme, not just high contrast ones.
Running axe against the ordinary light and dark themes flagged that publicRoomAlias and publicRoomDescription, styled $secondary-content / $tertiary-content, also failed 4.5:1 against the hover background. Not in high contrast. In the themes essentially everyone uses. That became a second fix in the base stylesheet.
The reported bug was one theme. The real bug was "hover states in this dialog were never contrast-checked against anything."
I also scoped something out: axe surfaced a pre-existing nested-interactive violation (focusable end-adornments inside clickable rows). Real problem, unrelated to colour, would have meant restructuring markup in a PR about a colour token. So — axe.disableRules("nested-interactive") with an XXX comment naming exactly why. A named exclusion with a reason is a signpost; a silent one is a bug you hid.
The one that got rejected — and deserved to
PR #34468 was closed on 13 August 2026. My first draft of this post called it "correct code, wrong product call." I was wrong about that, and how I found out is the most useful thing here.
The submenu opens on hover but doesn't close on hover-out. My first fix dismissed it 300ms after the pointer left both trigger and content. Half-Shot flagged that time-based auto-dismissal can be an accessibility problem and that his OS doesn't do this at all. Design agreed it was a step back for mouse users. Closed.
Then the reporter pushed back, and Element's designer @gaelledel responded with something better than a verdict — a specification, with screen recordings of Google Drive and Figma:
We indeed should not have the submenu persist IF the user has not explicitly hovered on any item of the submenu. However, once the user has hovered on an item of the submenu, we should keep the submenu open, up until the user has explicitly clicked off.
Two states, not one. My fix implemented one of them, and dismissed the submenu even after the user had clearly committed to it. That's why it read as a regression. The rejection wasn't a product disagreement; it was a correct read of a fix that did the wrong thing.
My root-cause description was also wrong. The PR claimed Radix only closes a submenu on click-outside, Escape, selection, or hovering a sibling trigger. So I probed five exit paths against unmodified code:
| Pointer leaves the trigger toward… | Submenu |
|---|---|
| far outside the menu | dismissed ✅ |
| through the trigger→submenu gap | dismissed ✅ |
| straight up and out | dismissed ✅ |
| into the submenu, then away | stays open ✅ (correct) |
| parent-menu space that isn't an item — the gap between items, the padding | stays open ❌ |
Radix dismisses submenus in most cases, as a side effect of hover moving focus. The defect is one narrow case. My original description was broad enough to sound authoritative and wrong enough that any reviewer who tested it would find it didn't hold.
Then the part I'd tattoo on a wall. I wrote unit tests with @testing-library/user-event, ran them in real Chromium via @vitest/browser-playwright, and they went green. Then I reverted the fix and ran them again.
They still passed. All of them.
user-event dispatches synthetic pointer events, and synthetic pointer events carry no coordinates and no direction. Radix decides "is the pointer heading for the submenu?" from exactly those two things — so under synthetic events its grace-area logic can't function and it tears the submenu down by itself. My tests were observing Radix's fallback, not my code.
A test that passes without the fix isn't a weak test. It's not a test.
The real coverage is a Playwright e2e spec driving the actual room list with an actual mouse — and the number that matters isn't that it passes, it's that it fails when the fix is reverted.
Deleting the worthless unit tests then broke CI in a way I didn't predict:
Diff coverage 67.0% is below the threshold of 80.0%
diff-cover reads lcov from vitest and jest. Playwright e2e tests don't contribute to it at all. So a change whose only real verification is a browser test fails the coverage gate no matter how well tested it is. The resolution wasn't to re-add theatre — I extracted the hover state machine into its own module and unit-tested that with fake timers: eight tests covering the non-mouse early return, the closed-submenu return, the sticky path, cancellation inside the grace period, per-cycle reset, and timer cleanup on unmount. 100% of changed lines, every one a real assertion, because at that layer there's no Radix and no geometry to lie to me.
Which produced a division of labour I'd now reach for by default: unit tests verify the state machine; one e2e test verifies what a real mouse produces. Neither substitutes for the other. Trying to make one do both jobs is what cost me the first attempt.
Dismissal is now mouse-only (pointerType === "mouse"), so keyboard- and touch-opened submenus are never timed out — which answers Half-Shot's accessibility objection directly rather than arguing with it. On 7 September 2026, gaelledel approved: "Brilliant! Thank you very much." It's still open pending @florianduros's reasonable request that it live in compound-web's Menu component so every future submenu inherits it. He's right; that version is written; where it ships is the maintainers' call.
Best Use of Sentry
Sentry tools used
-
Error Monitoring — the integration pipeline itself (
Sentry.init, integration list,processEvent) - Source Maps / Releases & Artifacts — artifact path matching, which is what was actually broken
- Issue Grouping — restoring cross-platform grouping between desktop and web
- A live Sentry project as a verification harness — firing an identical fabricated crash through both configs and diffing the resulting issues
I'm not claiming Session Replay, Distributed Tracing or Seer. I didn't use them. What I did was repair the layer everything else in Sentry sits on: if your frames don't resolve, nothing downstream of them is trustworthy either.
Root cause: an opt-out that silently freezes in time
apps/web/src/sentry.ts initialises Sentry with defaultIntegrations: false and a hand-picked list:
const integrations = [
Sentry.inboundFiltersIntegration(),
Sentry.functionToStringIntegration(),
Sentry.breadcrumbsIntegration(),
Sentry.httpContextIntegration(),
Sentry.dedupeIntegration(),
];
defaultIntegrations: false is the actual root cause, and it's worth dwelling on because plenty of apps have this pattern.
It's chosen for good reasons — bundle size, no surprise behaviour, explicit over implicit. But it converts "you get sensible defaults" into "you get exactly this list, forever." When the SDK ships a new integration, or when your app grows a deployment target that would have needed one, nothing tells you. No warning, no deprecation, no failing test. The list just quietly stops describing what you need.
rewriteFramesIntegration was never on that list. So frames went to Sentry exactly as the Electron renderer produced them, custom protocol and all.
The fix
Eight lines:
// Element Desktop serves the app from the custom `vector://vector/webapp` origin rather
// than a normal web origin, so its stack frames don't match Element Web's and end up
// ungrouped and unsymbolicated. Normalize both to the same relative form so desktop
// crashes group with, and source-map against, the same release as the web app.
Sentry.rewriteFramesIntegration({
root: "vector://vector/webapp",
prefix: "app://",
}),
app:// is Sentry's documented convention for custom-origin apps — the pattern Sentry's own Electron guidance uses. It's a no-op for Element Web, whose frames never start with that prefix, which is what makes it safe to ship globally rather than gating it behind a platform check.
The two-character detail that would have shipped a fake fix
prefix: "app://". Two slashes. Not three.
This looks like bikeshedding. It isn't, and it's the part of this PR I'd defend hardest.
Here's what rewriteFrames does to a filename. It strips root off the front:
vector://vector/webapp/bundles/abc123/bundle.js
─────── root ────────┘
leaving /bundles/abc123/bundle.js — with the leading slash still attached. Then it prepends prefix.
So prefix: "app://" yields the canonical app:///bundles/abc123/bundle.js. But the string app:/// is what you see in every Sentry doc and every Stack Overflow answer, so writing it into the config is the natural mistake. Do that and you get:
app:////bundles/abc123/bundle.js
Four slashes. And here's why that's dangerous rather than merely wrong: it fails completely silently. Sentry accepts the event. The issue appears in your dashboard. The stack trace renders. The paths even look plausible at a glance. The only symptom is that artifacts still don't match — so you'd have closed a four-year-old issue, told the maintainers it was fixed, and swapped one broken path for a differently broken path nobody would notice for months.
There is no error message anywhere in that failure mode. The only defence is asserting on the output.
So I tested the output, not the config
Most tests of a Sentry integration check that it's wired up. Mine does that — and then does the thing that actually matters.
Layer 1: mocked SDK — is the integration wired in?
jest.mock("@sentry/browser", () => ({
init: jest.fn(),
inboundFiltersIntegration: jest.fn().mockReturnValue({ name: "InboundFilters" }),
functionToStringIntegration: jest.fn().mockReturnValue({ name: "FunctionToString" }),
breadcrumbsIntegration: jest.fn().mockReturnValue({ name: "Breadcrumbs" }),
httpContextIntegration: jest.fn().mockReturnValue({ name: "HttpContext" }),
dedupeIntegration: jest.fn().mockReturnValue({ name: "Dedupe" }),
rewriteFramesIntegration: jest.fn().mockReturnValue({ name: "RewriteFrames" }),
}));
it("normalizes Element Desktop's vector:// stack frames so they group with Element Web", async () => {
await initSentry({ dsn: "https://[email protected]/0", environment: "test" });
expect(Sentry.rewriteFramesIntegration).toHaveBeenCalledWith({
root: "vector://vector/webapp",
prefix: "app://",
});
const { integrations } = jest.mocked(Sentry.init).mock.calls[0][0]!;
expect(integrations).toEqual(expect.arrayContaining([{ name: "RewriteFrames" }]));
});
This guards against the defaultIntegrations: false failure mode repeating — if someone edits that array later and drops the integration, this fails. Given that the original bug was "something isn't in the integrations list," a regression test on the list itself is the right shape.
Layer 2: the real SDK — what string actually comes out?
describe("rewriteFramesIntegration output", () => {
// Uses the real Sentry SDK integration (not the mock above) to verify the actual rewritten
// path, since it's easy to get the exact prefix/slash count wrong (e.g. `app:///` here would
// double up with the leading slash already present in the frame, producing `app:////...`).
// Relies on this test file's jsdom environment providing a real `window`, which is what makes
// the SDK's browser root/prefix substitution apply in the first place.
it("rewrites a vector:// frame to a clean app:/// path", async () => {
const RealSentry = jest.requireActual<typeof Sentry>("@sentry/browser");
const integration = RealSentry.rewriteFramesIntegration({
root: "vector://vector/webapp",
prefix: "app://",
});
const event: any = {
exception: { values: [{ stacktrace: { frames: [
{ filename: "vector://vector/webapp/bundles/abc123/bundle.js" },
] } }] },
};
const processed = integration.processEvent!(event, {}, {} as any) as any;
expect(processed.exception.values[0].stacktrace.frames[0].filename)
.toBe("app:///bundles/abc123/bundle.js");
});
});
jest.requireActual pulls the genuine @sentry/browser past the mock, so this drives Sentry's real processEvent pipeline with a hand-built event and asserts the exact resulting filename — every slash of it.
A mock-only suite would pass forever with app:/// sitting in the config. That's the whole argument for layer 2: test the observable output of the SDK, not your belief about what you configured.
The comment about jsdom is load-bearing too. rewriteFrames' root/prefix substitution branch only applies when it detects a browser environment via a real window. Run this under a node test environment and the assertion silently stops testing what you think — so the reason it works is written down for whoever touches it next.
Verified against a real Sentry project
Unit tests prove the transform. They don't prove Sentry's ingestion agrees with you. So before opening the PR I sent an identical fabricated crash through both configs — pre-fix and post-fix — into a live Sentry project, and compared the resulting issues side by side.
Before — vector:// frames, unsymbolicated:
After — app:/// frames, source-mapped against the release:
That step is what turned "this should work" into "I watched it work," and it's what I'd insist on for any change to error-reporting plumbing. Observability code is uniquely bad at telling you when it's broken, because its failure mode is producing something that looks like data.
Answering the question that had been open since 2021
I originally scoped novocaine's second point out. The PR said so explicitly:
This PR addresses the path-prefix normalization only; the bundle-hash discrepancy is a separate, unrelated build/packaging question this PR doesn't investigate.
Then @t3chguy dropped a hint in review:
Back in '21 the build system was probably significantly different, nowadays the desktop build process literally downloads the release asset in the
fetchscript
So I went and read apps/desktop/scripts/fetch-package.ts. The modern desktop build pulls element-<version>.tar.gz directly from the GitHub release and repacks that exact tarball into webapp.asar. It doesn't build its own bundle at all.
Which means the bundle hash can't diverge any more — the mismatch novocaine saw was an artifact of the 2021 pipeline, and that pipeline is gone.
And that materially changes what this PR does. If the hashes still differed, stripping the vector:// prefix would only have fixed symbolication. Because they don't, once the prefix is normalised the remaining path — hash included — is identical to the web app's frames for the same release. So the fix closes both halves of the original report:
| Before | After | |
|---|---|---|
| Desktop stack traces | Minified, unsymbolicated | Source-mapped against the release |
| Same crash on desktop + web | Two unrelated Sentry issues | One issue, correct event volume |
| Issue #32242 | Open since Nov 2021 | Closed |
Twenty minutes of reading a build script. That's the difference between closing a symptom and closing an issue — and between "here's a patch" and "here's the answer to the thing you asked in 2021."
Merged via the merge queue on 12 August 2026.
Takeaways
defaultIntegrations: false has a maintenance cost nobody budgets for. You've opted out of every future SDK improvement, silently, forever. If you use it, put a calendar reminder on reviewing that list.
Observability bugs are the worst kind, because broken instrumentation still produces output. A four-slash path renders a perfectly convincing Sentry issue. Assert on the exact output string, using the real SDK — not on your config.
Grouping failures are more expensive than symbolication failures. Unsymbolicated crashes are annoying. Split crashes distort which bugs you fix at all.
Revert your fix and re-run your tests. If they still pass, they were never testing your change. A thirty-second check invalidated an entire suite I'd been proud of.
Synthetic events can't test pointer-intent UI. user-event sends no coordinates and no direction, so anything reasoning about pointer trajectory behaves differently under it than under a real mouse.
Know which gate measures which kind of test. diff-cover reads unit-test lcov only; Playwright contributes nothing. A well-tested change can still fail an 80% coverage gate.
Read the build script. The unanswered question in a five-year-old issue was answerable in twenty minutes, and answering it turned a partial fix into a complete one.
Semantic tokens carry meaning, not values — and meaning flips across themes. If your design system has themes, contrast belongs in CI.
Assert relationships, not constants. expect(header).toBe(64) protects nothing. expect(header).toEqual(banner) protects the actual invariant.
Credits
To @novocaine, who wrote a bug report in 2021 so precise that it named the fix — and was honest enough to flag the part he couldn't explain. Five years later, that report was still the best documentation of the problem.
To the Element maintainers who reviewed these as community PRs: @t3chguy, whose one-line aside about the fetch script is why the Sentry PR closes the whole issue instead of half of it, and who told me three separate times that CI was still red; @Half-Shot, whose "test all the themes" note directly caused a second contrast bug to be found, and who escalated the submenu change to design rather than rubber-stamping it; @gaelledel, who answered a rejected PR with a specification and screen recordings instead of a "no" — a spec with a video in it is worth ten rounds of review comments; @florianduros, for pointing out that a submenu fix belongs in the design system rather than one call site; and @americanrefugee and @dbkr for review and triage.
Thanks to Sentry for rewriteFramesIntegration and for documenting the custom-origin pattern clearly enough that a 2021 bug reporter could link to the right page from memory.





Top comments (0)