From f9f497674782bca22f7f44e270db9e2dd6674427 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 10 Aug 2026 00:04:34 -0700 Subject: [PATCH 1/2] fix: fingerprint minidump crashes by crash site for Error Tracking --- CHANGELOG.md | 6 + src/domain/rum/error/CrashCollection.spec.ts | 177 +++++++++++++++++++ src/domain/rum/error/CrashCollection.ts | 59 +++++++ src/domain/rum/rawRumData.types.ts | 5 + 4 files changed, 247 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f81cc5..155b0ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `@flashcatcloud/electron-sdk` are documented here. +## [0.2.1] + +### 🐛 Bug Fixes + +- Minidump crash events now carry `error.fingerprint`, so Error Tracking groups native crashes **per crash site** instead of merging every crash of one exception type into a single issue. The fingerprint is built from the exception type plus the top non-system frame of the crashed thread — module basename and module offset (`SIGSEGV|MyApp|0x12ab3c`), normalized so equivalent offset spellings group together. The offset, not the instruction address, is what identifies the site: ASLR rebases modules on every launch, while an offset is stable across runs of the same build. Offsets drift between builds, so a new app version opens fresh issues — the same trade-off as Android NDK top-frame grouping. The backend uses an event-provided fingerprint verbatim and skips similarity grouping when one is present, so this needs **no backend change**. Crashes with no identified crashed thread (dumps written without an exception stream) carry no fingerprint, as before they carry no stack. + ## [0.2.0] ### ✨ Features diff --git a/src/domain/rum/error/CrashCollection.spec.ts b/src/domain/rum/error/CrashCollection.spec.ts index 02c50137..0f839f1c 100644 --- a/src/domain/rum/error/CrashCollection.spec.ts +++ b/src/domain/rum/error/CrashCollection.spec.ts @@ -605,4 +605,181 @@ describe('CrashCollection', () => { const data = rawRumEvents[0].data as RawRumError; expect(data.error.meta!.exception_codes).toBe('0x00007fff6f41333a'); }); + + it('builds the fingerprint from the first non-system frame of the crashed thread', async () => { + mockDmpFile(); + vi.mocked(processMinidump).mockResolvedValue( + createMinidumpResult({ + threads: [ + { + thread_index: 0, + frame_count: 2, + frames: [ + { + module: '/usr/lib/libSystem.B.dylib', + function: 'start', + instruction: '0x1', + module_offset: '0x50', + trust: 'context', + }, + { + module: '/Applications/MyApp.app/Contents/MacOS/MyApp', + function: 'crash', + instruction: '0x2', + module_offset: '0x0012ab3c', + trust: 'cfi', + }, + ], + }, + ], + }) + ); + + await startAndFlush(eventManager); + + const data = rawRumEvents[0].data as RawRumError; + expect(data.error.fingerprint).toBe('SIGSEGV|MyApp|0x12ab3c'); + }); + + it('skips frames without a module when picking the fingerprint frame', async () => { + mockDmpFile(); + vi.mocked(processMinidump).mockResolvedValue( + createMinidumpResult({ + threads: [ + { + thread_index: 0, + frame_count: 2, + frames: [ + { module: '', function: 'unknown', instruction: '0x1', module_offset: '0x10', trust: 'context' }, + { + module: '/Applications/MyApp.app/Contents/MacOS/MyApp', + function: 'crash', + instruction: '0x2', + module_offset: '0x20', + trust: 'cfi', + }, + ], + }, + ], + }) + ); + + await startAndFlush(eventManager); + + const data = rawRumEvents[0].data as RawRumError; + expect(data.error.fingerprint).toBe('SIGSEGV|MyApp|0x20'); + }); + + it('falls back to the first frame when every frame of the crashed thread is a system module', async () => { + mockDmpFile(); + vi.mocked(processMinidump).mockResolvedValue( + createMinidumpResult({ + threads: [ + { + thread_index: 0, + frame_count: 2, + frames: [ + { + module: '/usr/lib/libA.dylib', + function: 'crash', + instruction: '0x1', + module_offset: '0x30', + trust: 'context', + }, + { + module: '/System/Library/Frameworks/CoreFoundation', + function: 'run', + instruction: '0x2', + module_offset: '0x40', + trust: 'cfi', + }, + ], + }, + ], + }) + ); + + await startAndFlush(eventManager); + + const data = rawRumEvents[0].data as RawRumError; + expect(data.error.fingerprint).toBe('SIGSEGV|libA.dylib|0x30'); + }); + + it('emits no fingerprint when crash_info is missing', async () => { + mockDmpFile(); + const report = createMinidumpResult(); + delete report.crash_info; + delete report.crashing_thread; + vi.mocked(processMinidump).mockResolvedValue(report); + + await startAndFlush(eventManager); + + const data = rawRumEvents[0].data as RawRumError; + expect(data.error).not.toHaveProperty('fingerprint'); + }); + + it('emits no fingerprint when the crashing thread is not identified', async () => { + mockDmpFile(); + vi.mocked(processMinidump).mockResolvedValue( + createMinidumpResult({ crash_info: { type: 'SIGSEGV', address: '0x0', crashing_thread: null } }) + ); + + await startAndFlush(eventManager); + + const data = rawRumEvents[0].data as RawRumError; + expect(data.error).not.toHaveProperty('fingerprint'); + }); + + it('emits no fingerprint when the crashed thread has no frames', async () => { + mockDmpFile(); + vi.mocked(processMinidump).mockResolvedValue( + createMinidumpResult({ + threads: [{ thread_index: 0, frame_count: 0, frames: [] }], + }) + ); + + await startAndFlush(eventManager); + + const data = rawRumEvents[0].data as RawRumError; + expect(data.error).not.toHaveProperty('fingerprint'); + }); + + it('normalizes offset spellings so equivalent offsets produce the same fingerprint', async () => { + mfs.readdir.mockResolvedValue([ + { name: 'a.dmp', isFile: () => true, isDirectory: () => false }, + { name: 'b.dmp', isFile: () => true, isDirectory: () => false }, + ]); + mfs.stat.mockResolvedValue({ birthtimeMs: 0 }); + mfs.readFile.mockResolvedValue(new Uint8Array([1])); + mfs.unlink.mockResolvedValue(undefined); + const reportWithOffset = (moduleOffset: string) => + createMinidumpResult({ + threads: [ + { + thread_index: 0, + frame_count: 1, + frames: [ + { + module: '/Applications/MyApp.app/Contents/MacOS/MyApp', + function: 'crash', + instruction: '0x1', + module_offset: moduleOffset, + trust: 'context', + }, + ], + }, + ], + }); + vi.mocked(processMinidump) + .mockResolvedValueOnce(reportWithOffset('0x0012AB3C')) + .mockResolvedValueOnce(reportWithOffset('0x12ab3c')); + + await startAndFlush(eventManager); + + expect(rawRumEvents).toHaveLength(2); + const first = rawRumEvents[0].data as RawRumError; + const second = rawRumEvents[1].data as RawRumError; + expect(first.error.fingerprint).toBe('SIGSEGV|MyApp|0x12ab3c'); + expect(second.error.fingerprint).toBe(first.error.fingerprint); + }); }); diff --git a/src/domain/rum/error/CrashCollection.ts b/src/domain/rum/error/CrashCollection.ts index 20c1e9a9..9964528f 100644 --- a/src/domain/rum/error/CrashCollection.ts +++ b/src/domain/rum/error/CrashCollection.ts @@ -122,6 +122,7 @@ function buildCrashErrorEvent(crashReport: CrashReport, crashTime: TimeStamp): R const threads = formatThreads(crashReport); const crashedThread = threads.find((t) => t.crashed); const exceptionType = crashReport.crash_info?.type; + const fingerprint = computeCrashFingerprint(crashReport); // The faulting address, under the RUM schema's field for "CPU specific information about the // exception encoded into 64-bit hexadecimal number". It goes here rather than under a name of // our own because the intake decodes `error.meta` into a fixed set of fields and drops the rest, @@ -142,6 +143,9 @@ function buildCrashErrorEvent(crashReport: CrashReport, crashTime: TimeStamp): R category: 'Exception', type: exceptionType, was_truncated: false, + // Spread rather than `fingerprint` directly: an explicit `undefined` would still serialize + // as an own property, and the backend treats any present fingerprint as authoritative. + ...(fingerprint !== undefined ? { fingerprint } : {}), meta: { code_type: crashReport.system_info.cpu, process: app.getName(), @@ -156,6 +160,61 @@ function buildCrashErrorEvent(crashReport: CrashReport, crashTime: TimeStamp): R }; } +/** + * Compute the Error Tracking fingerprint for a native crash. + * + * Backend contract: the intake stores `error.fingerprint` verbatim on the error row, issue + * grouping prefers an event-provided fingerprint over any computed one, and similarity/embedding + * grouping is skipped entirely when an event carries one. Without it every crash of the same + * exception type lands in a single issue, no matter where the process faulted. + * + * Format: `{exceptionType}|{moduleBasename}|{normalizedModuleOffset}` — e.g. + * `SIGSEGV|MyApp|0x12ab3c`. The site is the first non-system frame of the crashed thread + * (falling back to the first frame when every frame is a system module), identified by module + * and offset rather than instruction address: ASLR rebases modules on every launch, while an + * offset is stable across runs of the same build. Offsets drift between builds, so a new app + * version opens fresh issues — the same trade-off the Android NDK top-frame grouping makes. + * + * Returns undefined when there is nothing to pin a site to: no crash_info, no identified + * crashing thread, or a crashed thread without usable frames. Such an event carries no stack + * either, and the backend's frames==0 gate already keeps it out of issue grouping. + */ +function computeCrashFingerprint(crashReport: CrashReport): string | undefined { + // `crashing_thread` is a thread_index, matched the same way `formatThreads` flags threads. + // Absent crash_info gives undefined, an unidentified thread gives null — both find nothing. + const crashingThread = crashReport.crash_info?.crashing_thread; + const crashedThread = crashReport.threads.find((thread) => thread.thread_index === crashingThread); + if (!crashedThread) { + return undefined; + } + + const candidates = crashedThread.frames.filter((frame) => frame.module); + const frame = candidates.find((candidate) => !isSystemModule(candidate.module)) ?? candidates[0]; + const normalizedOffset = frame ? normalizeModuleOffset(frame.module_offset) : undefined; + if (!frame || !normalizedOffset) { + return undefined; + } + + const exceptionType = crashReport.crash_info?.type ?? 'unknown'; + return `${exceptionType}|${path.basename(frame.module)}|${normalizedOffset}`; +} + +/** + * Normalize a module offset like `0x0012AB3C` to `0x12ab3c` — lowercase, no leading zeros — + * so equivalent spellings of the same offset group into one issue. BigInt keeps this exact + * for offsets beyond 2^53. Returns undefined for missing or non-hex input. + */ +function normalizeModuleOffset(moduleOffset: string | undefined): string | undefined { + if (!moduleOffset) { + return undefined; + } + const hex = moduleOffset.toLowerCase().replace(/^0x/, ''); + if (!/^[0-9a-f]+$/.test(hex)) { + return undefined; + } + return `0x${BigInt(`0x${hex}`).toString(16)}`; +} + const OS_TO_SOURCE_TYPE: Record = { mac: 'macos', linux: 'linux', diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index 3d726d83..010c1e78 100644 --- a/src/domain/rum/rawRumData.types.ts +++ b/src/domain/rum/rawRumData.types.ts @@ -27,6 +27,11 @@ export interface RawRumError extends RecursivePartial { handling: 'unhandled' | 'handled'; stack?: string; type?: string; + /** + * Error Tracking grouping fingerprint. The backend uses it verbatim for issue grouping + * and skips similarity grouping when it is present. Set for native crashes. + */ + fingerprint?: string; is_crash?: boolean; was_truncated?: boolean; category?: 'Exception'; From da67f2b7822695da55c58dc328852bd5860ff5fd Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 10 Aug 2026 00:18:57 -0700 Subject: [PATCH 2/2] docs: correct what happens to a crash with no identifiable site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note claimed the backend's zero-frame gate keeps these events out of issue grouping. It no longer does: an exception-less crash now reaches Error Tracking and groups by exception type and message. The behaviour here is unchanged and still right — with no exception stream nothing records which thread died, so there is no site to fingerprint, and building one from an arbitrary thread would invent a crash site the dump does not contain. Only the stated consequence was wrong, and it is the kind of mistake that outlives the reader who could catch it. --- src/domain/rum/error/CrashCollection.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/domain/rum/error/CrashCollection.ts b/src/domain/rum/error/CrashCollection.ts index 9964528f..95a24c93 100644 --- a/src/domain/rum/error/CrashCollection.ts +++ b/src/domain/rum/error/CrashCollection.ts @@ -177,7 +177,10 @@ function buildCrashErrorEvent(crashReport: CrashReport, crashTime: TimeStamp): R * * Returns undefined when there is nothing to pin a site to: no crash_info, no identified * crashing thread, or a crashed thread without usable frames. Such an event carries no stack - * either, and the backend's frames==0 gate already keeps it out of issue grouping. + * either, so there is no site to key on and the backend groups it by exception type and + * message alone. That is coarser than a per-site fingerprint, and it is all the dump supports: + * without an exception stream nothing records which thread died, so a finer split would have + * to be invented. Sending a fingerprint built from an arbitrary thread would do exactly that. */ function computeCrashFingerprint(crashReport: CrashReport): string | undefined { // `crashing_thread` is a thread_index, matched the same way `formatThreads` flags threads.