Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7b812b3
feat(exceptions): a privacy-safe JS exception payload, with its signa…
krassx Sep 29, 2026
d9eb968
fix(exceptions): bound stack parsing against a hostile, super-linear …
krassx Sep 29, 2026
72c98d0
fix(exceptions): cap every payload string field and strip the message…
krassx Sep 29, 2026
7e14f3c
test(exceptions): use real captured V8/JSC stacks, and fix a JSC anon…
krassx Sep 29, 2026
e0a935e
refactor(exceptions): drop a Hermes pattern this round's NODE_RE made…
krassx Sep 29, 2026
9e993e4
docs(plan): record Task 7.1a's fix-round-1 constants and signature-pa…
krassx Sep 29, 2026
a1b11bd
fix(exceptions): make CHROME_RE/COMPONENT_STACK_RE linear, and restor…
krassx Sep 29, 2026
0e83d67
fix(exceptions): bound the whole cause chain's parse work, and stop a…
krassx Sep 29, 2026
a7eddf9
docs(plan): record fix-round-2's constants, header-strip guards and t…
krassx Sep 29, 2026
8f79daf
test(exceptions): mutation-test hardening for fix round 2's new code
krassx Sep 29, 2026
be1d467
feat(exceptions): logException and logUnhandledException, bridge stubbed
krassx Sep 30, 2026
73693be
feat(exceptions): attach source-map debug IDs to JS frames
krassx Sep 30, 2026
dd0b25d
feat(exceptions): report uncaught JS errors and unhandled rejections
krassx Sep 30, 2026
34aa58e
feat(exceptions): ErrorBoundary, and a root reporter for uncaught ren…
krassx Sep 30, 2026
55e5501
test(exceptions): type test modules with import type so lint accepts …
krassx Sep 30, 2026
5d4fd8a
fix(exceptions): apply crash capture before handlers, and validate bo…
krassx Sep 30, 2026
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
34 changes: 21 additions & 13 deletions docs/design/plans/2026-09-16-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2547,7 +2547,7 @@ Phase 7 starts after Phase 6's review gate: its root reporter lives in `Bugsee.w
- **R2. `signature` is kept, as design §9.1 lists it, and it is load-bearing.**
- Android 7.3.0 parses `signature` out of a JSON reason (`ExceptionSignature.extractFromReason`) into the client signature and into `crash.json` `additional_signature`. iOS writes `additional_signature` the same way (`sdk/reporting/bundle/crash.md`).
- Without it, every React Native exception on Android shares one client signature: the class name plus the bridge's own Java frames.
- It is SHA-1 over the chain's names and frame traces (6.x's algorithm), without 6.x's `build_id` suffix (R3).
- It is SHA-1 over the chain's names and frame traces (6.x's `concatExceptionsRecursiveForSignature`), without 6.x's `build_id` suffix (R3) and with 7.x's own source cleaning (`cleanSource` steps 3-4 subsume 6.x's `EXCEPTION_SIGNATURE_CLEANUP_REGEXPRS`). It otherwise differs from 6.x only where already documented elsewhere: `name` is `error.name` verbatim rather than a builtin-`instanceof` name (so a `class X extends TypeError` gives `TypeError` in `X`'s name, intended), and an unparsed line no longer contributes a `<unknown> () ()` frame (review M4, M3). Client-signature continuity across a 6.x-to-7.x upgrade is not expected or attempted: 6.x's signature already changed with every app version (it hashed in `appVersion-config-os`) and every bundle (minified line:column), and Android's own client signature separately hashes the bridge's Java frames, which change with the 7.x bridge regardless.
- **R3. `build_id` is not sent.** When it is absent, the worker computes `_construct_build_id` = SHA-1 of `<app version>-release-<platform>`. That is the formula 6.x used in a release build. 7.x symbolicates by debug ID (R5, Phase 13), which the worker tries first.
- **R4. Exception options are exactly `domain`, `labels` and `includeVideo`, flat** (design §9.1: "the wrapper passes all three").
- 6.x's `mergingRules.skipFrames` is not carried. It indexes the *native* stack, which for a JS exception is the bridge's own frames, and the worker computes a React Native signature from the JS frames without it.
Expand Down Expand Up @@ -2637,10 +2637,15 @@ Phase 7 starts after Phase 6's review gate: its root reporter lives in `Bugsee.w
| `REACT_NATIVE_EXCEPTION_NAME` | `'ReactNativeWebException'` | iOS `BGSRNReactNativeExceptionName`; Android simple class name | The backend routes on this exact substring. |
| `EXCEPTION_MAX_FRAMES` | `256` | `src/exceptions/payload.ts` | Per node. Bounds the payload; far above any real JS stack. |
| `EXCEPTION_MAX_CAUSE_DEPTH` | `10` | payload | Android's own nesting cap. |
| `EXCEPTION_MAX_REASON_LENGTH` | `8192` UTF-16 units | payload | Truncated with `…` (U+2026). Never splits a surrogate pair. |
| `EXCEPTION_MAX_REASON_LENGTH` | `8192` UTF-16 units | payload | Truncated with `…` (U+2026), which is not counted against the cap -- a truncated reason is 8193 units. Never splits a surrogate pair. |
| `EXCEPTION_MAX_NAME_LENGTH` | `256` UTF-16 units | payload | Same surrogate-safe cut as `reason` (review I1: `name` was uncapped). |
| `EXCEPTION_MAX_FIELD_LENGTH` | `1024` UTF-16 units | payload | Caps `traceRaw`, `data.source` and `data.member` (review I1: a hostile stack produced a 76.8 MB payload with these uncapped). |
| `STACK_MAX_INPUT_LENGTH` | `65536` (64 KiB) | `src/exceptions/stack.ts` | Bounds the whole `stack` string before it is split into lines (review C1). |
| `STACK_MAX_LINE_LENGTH` | `2048` (2 KiB) | stack | Bounds each line before any pattern runs on it. Every pattern is also linear in the line's length on its own (review N1 -- fix round 1's claim that the cap alone made a quadratic pattern's cost negligible was wrong: a genuinely quadratic pattern at this cap, multiplied by every line in a 64 KiB stack and every node in a cause chain, measured over a second). |
| `EXCEPTION_MAX_TOTAL_STACK_LENGTH` | `131072` (128 KiB) | `src/exceptions/payload.ts` | Bounds the *sum* of `error.stack`/`componentStack`/`fallbackStack` parsed across one whole cause chain (review N1): each node's own stack is separately capped at `STACK_MAX_INPUT_LENGTH`, but nothing bounded the total across the chain's up to 11 nodes plus `componentStack`, which could still reach 12x one node's own worst case. Shared via one mutable counter; once it is spent, a later node's frames are `[]`. |
| `EXCEPTION_MAX_AGGREGATE` | `10` | `src/exceptions/report.ts` | R14. |
| `EXCEPTION_DOMAIN_MAX_LENGTH` | `256` | `src/exceptions/options.ts` | A domain is a grouping tag, not text. |
| `DEBUG_IDS_MAX` | `64` | `src/exceptions/debugIds.ts` | Map entries sent. One bundle is typical. |
| `DEBUG_IDS_MAX` | `64` | `src/exceptions/debugIds.ts` | Map entries sent. One bundle is typical. `payload.ts` does not enforce this itself; it belongs to Task 7.3's `debugIds.ts` (review M7). |
| `UNHANDLED_REPORT_WAIT_MS` | `1500` | report | The most the fatal path waits for native before RN's handler runs. Android writes synchronously; iOS stores the PLCR report synchronously. |
| `ERROR_BOUNDARY_CAUSE_NAME` | `'ErrorBoundary Error'` | payload | R10, 6.x's name. |

Expand All @@ -2655,19 +2660,19 @@ The reason is one JSON object. Keys appear in this order, and absent keys are om

| key | type | where | meaning |
|---|---|---|---|
| `name` | string | every node | The error's `name` if it is a non-empty string; else `'Error'`. |
| `name` | string | every node | The error's `name` if it is a non-empty string (whitespace-only counts as non-empty; not trimmed, review M7); else `'Error'`. Capped at `EXCEPTION_MAX_NAME_LENGTH`. |
| `reason` | string | every node | `message`, trimmed and truncated (constants), or R9's description. |
| `frames` | frame[] | every node | Parsed from `stack`, top first; the header line is dropped. `[]` when there is no stack. |
| `frames` | frame[] | every node | Parsed from `stack` with its own `name: message` (or bare `name`, or bare `message` when `name` is `''`) header stripped first, top first; the header is never itself parsed as a frame (review I2 -- it previously was, whenever a message happened to look enough like one). A candidate is accepted only when followed by the end of the string, `\n` or `\r` (review N2 -- a bare `name` prefix match alone let a real frame whose own function name starts with the error's name, e.g. `TypeErrorFactory` for a `TypeError`, lose its own leading text). A bare (no `"@"`) parsed frame whose file contains `": "` is dropped rather than kept, as a second guard for a header a mismatched `name`/`message` could not identify and therefore left unstripped. `[]` when there is no stack. |
| `cause` | node | when present | `error.cause` (any value, R9), to depth 10, stopping at a repeat (`WeakSet`). Then the R10 component-stack node, if any. |
| `signature` | string | root | `sha1Hex(<name><each frame's trace>…)` down the whole chain, lowercase, 40 hex characters. |
| `platform_os` | `'android'` \| `'ios'` | root | `Platform.OS`. |
| `debug_ids` | `{[file]: id}` | root, when non-empty | The R5 map, at most 64 entries. |
| `debug_ids` | `{[file]: id}` | root, when non-empty | The R5 map. `payload.ts` sends it as given; the 64-entry cap is Task 7.3's `debugIds.ts` to enforce (review M7). |

A frame has these keys, in order:
- `traceRaw`: the stack line as the engine wrote it;
- `traceRaw`: the stack line as the engine wrote it, capped at `EXCEPTION_MAX_FIELD_LENGTH`. This keeps a hostile message's own bulk out of the payload (review I1), but a *legitimate* absolute path still reaches the backend verbatim otherwise: an iOS install path (`/private/var/containers/Bundle/Application/<UUID>/…`), an iOS data-container path or an Android `/data/user/0/<pkg>/…` path. That is spec-intended (`traceRaw` is defined as verbatim, matching 6.x) and is recorded here as a privacy note, not a defect (review M5);
- `trace`: `<member> () (<source>[:line][:column])`, 6.x's form;
- `data`: `{member, source, line, column}`. `member` defaults to `'<unknown>'`; `line` and `column` are `null` when unknown;
- `user`: present when the frame has a file. It is `true` iff the frame has a line and a column, and its file contains none of `node_modules`, `native code` or `(native)`;
- `data`: `{member, source, line, column}`. `member` defaults to `'<unknown>'`; `line` and `column` are `null` when unknown. `member` and `source` are each capped at `EXCEPTION_MAX_FIELD_LENGTH`;
- `user`: present when the frame has a file (checked on the frame's own file, not the cleaned `source` -- they agree in practice, since none of `source`'s five steps can remove these substrings, but the wording here is what the code now matches, review M6). It is `true` iff the frame has a line and a column, its file contains none of `node_modules`, `native code` or `(native)`, and it is not React 19's own built-in-component sentinel: `describeBuiltInComponentFrame` (`ReactFabric-dev.js`) writes a built-in host component's frame (`View`, `Text`, ...) as `<anonymous>` with no line/column on an engine whose own stacks say "at" (already excluded, having no line/column at all), or as file `unknown`, line `0`, column `0` on one that does not (JSC) -- `0` is a real number, not absent, so this needs its own check (review N4);
- `debug_id`: present when the frame's file is in the map.

`source` is the frame's file with these removed, in order:
Expand All @@ -2679,6 +2684,8 @@ A frame has these keys, in order:

The **file key** used by R5's join is the same string with steps 1–2 only, so it matches between the registration stack and the crash stack even where step 3–5 would not.

**Known limitation (review N3):** a Hermes `"at NAME (address at FILE:LINE:COL)"` frame whose *name* itself contains `(` is not parsed (produces no frame at all). `stack.ts`'s name groups exclude `(` everywhere, on purpose (review N1/C1): letting a name contain `(` reintroduces the same super-linear scan those reviews removed, since it lets the name and the following file group compete over the same characters. A file containing `(`/`)` (an iOS app bundle such as `"My App (Beta).app"`) is parsed correctly; only the name side of this is unsupported, and no captured fixture (M2) has one.

```json
{"name":"TypeError","reason":"E2E handled 3f9a","frames":[
{"traceRaw":" at bugseeE2EThrowSite (address at index.android.bundle:1:20417)",
Expand Down Expand Up @@ -2754,7 +2761,7 @@ export function describeThrown(value: unknown): { name: string; reason: string }

`buildExceptionPayload` never throws. A getter that throws while the builder reads `name`, `message`, `stack` or `cause` is caught and treated as absent.

- [ ] **Red**
- [x] **Red**
- `stack.test.ts`:
- `parses a Hermes release frame (address at)`;
- `parses a Hermes frame from a Metro URL`;
Expand Down Expand Up @@ -2795,17 +2802,17 @@ export function describeThrown(value: unknown): { name: string; reason: string }
- `platform_os is the one given`;
- `a throwing getter is treated as absent`.
- Run → FAIL.
- [ ] **Green**
- [x] **Green**
- The three modules.
- Every `payload.test.ts` Proxy test passes.
- `yarn test` and `yarn mutate:src` (≥ 95%) are green.
- [ ] **Mutate**
- [x] **Mutate**
- (1) Serialise a non-Error object with `JSON.stringify`. `a thrown object gives its string message…` must fail.
- (2) Drop the cycle guard. `…stops at depth 10 and at a repeat` must fail, or time out.
- (3) Hash UTF-16 code units. `hashes UTF-8, not UTF-16` must fail.
- (4) Include the reason in the signature. `the signature does not change with the reason` must fail.
- Revert and record.
- [ ] **Commit** — `feat(exceptions): a privacy-safe JS exception payload, with its signature`.
- [x] **Commit** — `feat(exceptions): a privacy-safe JS exception payload, with its signature`.

---

Expand Down Expand Up @@ -4068,6 +4075,7 @@ The phase with the most native↔JS round-tripping, hence the most device testin
- **iOS beta3 compiles `logException:…` and `logUnhandledException:…` out on the simulator** (`#if !TARGET_OS_SIMULATOR`), including the call to `completion`. The no-op may be deliberate; never calling `completion` is not. Task 7.1d settles its promise on a deadline because of it.
- **iOS beta3 `BugseeExtendedReport` keeps its attributes (and `screenshotInitialized`) in file-scope globals.** Each `createReport` resets the attributes of every earlier created report. Phase 8 P5 allows one created report at a time; `testTheSdkSharesAttributesAcrossExtendedReports` (Task 8.2c) fails when this is fixed.
- **iOS beta3 `uploadWithSummary:…` files `source.type = "unknown"`** (`BGSReportingTriggerTypeUnknown`), where Android files `code_upload`. Task 8.3b case 3 is `it.failing` on iOS.
- **`cleanSource`'s CodePush paths keep the update's hash**: `files/CodePush/<hash>/CodePush/index.android.bundle` and `Library/Application Support/CodePush/<hash>/…` (steps 3-4 strip only the app-bundle/data-container prefix, not the `CodePush/<hash>/` segment itself). 6.x's `^.*\/[^.]+(\.app|CodePush|.*(?=\/))` reduced these to `/index.android.bundle`/`/main.jsbundle`, so a CodePush update did not change `source`/`trace`/the signature. Left as-is in the 7.1a review's fix round: stripping through the last `CodePush/` changes signature stability across CodePush updates, which is a bigger call than a fix round should make silently. Decide, then update `cleanSource` and its tests if so (review M4).

## Out of scope

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,18 @@ public void testCrash() {
Bugsee.testCrash();
}

// Task 7.1c — real bridge; stub for now so the TurboModule spec is complete.
@Override
public void logException(final String payloadJson, final @Nullable String optionsJson) {
// no-op
}

// Task 7.1c — real bridge; stub resolves at once.
@Override
public void logUnhandledException(final String payloadJson, final Promise promise) {
promise.resolve(null);
}

// The two-argument overload only -- severity and labels are Phase 8. JS
// has already checked both arguments are strings, so nothing is
// re-validated here.
Expand Down
13 changes: 13 additions & 0 deletions packages/react-native/ios/BugseeModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,19 @@ - (void)testCrash {
});
}

/// Task 7.1d — real bridge; stub for now so the TurboModule spec is complete.
- (void)logException:(NSString *)payloadJson
optionsJson:(NSString * _Nullable)optionsJson {
// no-op
}

/// Task 7.1d — real bridge; stub resolves at once.
- (void)logUnhandledException:(NSString *)payloadJson
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
resolve(nil);
}

/// The two-argument form only -- severity and labels are Phase 8. JS has
/// already checked both arguments are strings. On main, like every other SDK
/// entry point; the report it creates is a LIVE one, so its handlers run on
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native/src/NativeBugsee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ export interface Spec extends TurboModule {
name: string,
mimeType: string | null,
): Promise<void>;

/** A handled JS exception. `payloadJson` is the Task 7.1a payload; `optionsJson` is `{domain?, labels?, includeVideo?}` or null. */
logException(payloadJson: string, optionsJson: string | null): void;
/** An unhandled JS exception. Resolves once the SDK has the report; never rejects. */
logUnhandledException(payloadJson: string): Promise<void>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('Bugsee');
7 changes: 7 additions & 0 deletions packages/react-native/src/__mocks__/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const DEFAULTS: Record<string, unknown> = {
getAttribute: {},
getAllAttributes: {},
getUserIdentifier: {},
// logUnhandledException resolves by default (undefined / void).
logUnhandledException: undefined,
};

const lifecycleListeners = new Set<(event: { name: string; reportId?: string }) => void>();
Expand Down Expand Up @@ -187,6 +189,11 @@ export const native = {
reportAddDataAttachment:
jest.fn<Promise<void>, [string, string, string, string | null]>(),

/** `payloadJson` / `optionsJson` are JSON text (`encodeBridgeObject` / `encodeExceptionOptions`). */
logException: jest.fn<void, [string, string | null]>(),
/** Resolves by default; tests that hang native replace the implementation. */
logUnhandledException: jest.fn<Promise<void>, [string]>(),

/**
* Resets every mock on this object, found rather than listed.
*
Expand Down
73 changes: 73 additions & 0 deletions packages/react-native/src/__tests__/exceptions-facade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// The package entry exports the options model, which reads Platform.OS, so
// importing it loads `react-native` -- which jest cannot parse.
jest.mock('react-native', () => ({ Platform: { OS: 'android' } }));
jest.mock('../NativeBugsee', () => require('../__mocks__/native').nativeMock);

import Bugsee, { type ExceptionOptions } from '../index';
import { native } from '../__mocks__/native';

beforeEach(() => native.reset());

describe('exceptions facade', () => {
it('logException validates options before crossing', () => {
const error = new Error('x');
expect(() => Bugsee.logException(error, { domain: '' })).toThrow(RangeError);
expect(native.logException).not.toHaveBeenCalled();
});

it('a non-Error logged from a named function has that function as its first frame', () => {
function namedCaller(): void {
Bugsee.logException({ message: 'obj-throw' });
}
namedCaller();
expect(native.logException).toHaveBeenCalledTimes(1);
const payloadJson = native.logException.mock.calls[0]![0] as string;
const payload = JSON.parse(payloadJson) as {
frames: Array<{ data: { member: string } }>;
};
expect(payload.frames.length).toBeGreaterThan(0);
expect(payload.frames[0]!.data.member).toBe('namedCaller');
});

it('a non-Error unhandled from a named function has that function as its first frame', async () => {
async function namedUnhandledCaller(): Promise<void> {
await Bugsee.logUnhandledException({ message: 'obj-throw' });
}
await namedUnhandledCaller();
expect(native.logUnhandledException).toHaveBeenCalledTimes(1);
const payloadJson = native.logUnhandledException.mock.calls[0]![0] as string;
const payload = JSON.parse(payloadJson) as {
frames: Array<{ data: { member: string } }>;
};
expect(payload.frames.length).toBeGreaterThan(0);
expect(payload.frames[0]!.data.member).toBe('namedUnhandledCaller');
});

it("logUnhandledException returns reportUnhandled's promise", async () => {
jest.resetModules();
jest.doMock('react-native', () => ({ Platform: { OS: 'android' } }));
jest.doMock('../NativeBugsee', () => require('../__mocks__/native').nativeMock);
const sentinel = Promise.resolve();
jest.doMock('../exceptions/report', () => ({
reportUnhandled: jest.fn(() => sentinel),
reportHandled: jest.fn(),
markReported: jest.fn(() => true),
UNHANDLED_REPORT_WAIT_MS: 1500,
EXCEPTION_MAX_AGGREGATE: 10,
}));
let bugsee!: typeof Bugsee;
jest.isolateModules(() => {
bugsee = require('../index').default;
});
const result = bugsee.logUnhandledException(new Error('x'));
expect(result).toBe(sentinel);
await result;
});

it('ExceptionOptions is exported', () => {
const _probe: ExceptionOptions = { domain: 'd' };
expect(_probe.domain).toBe('d');
expect(typeof Bugsee.logException).toBe('function');
expect(typeof Bugsee.logUnhandledException).toBe('function');
});
});
Loading
Loading