Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions src/domain/rum/error/CrashCollection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
62 changes: 62 additions & 0 deletions src/domain/rum/error/CrashCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand All @@ -156,6 +160,64 @@ 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, 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.
// 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<string, RumErrorEvent['error']['source_type']> = {
mac: 'macos',
linux: 'linux',
Expand Down
5 changes: 5 additions & 0 deletions src/domain/rum/rawRumData.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface RawRumError extends RecursivePartial<RumErrorEvent> {
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';
Expand Down
Loading