Skip to content
Closed
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
22 changes: 13 additions & 9 deletions docs/design/2026-09-15-sdk-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,18 +285,22 @@ What changes is the payload gaining a `debug_ids` member alongside name, message

| | Android 7.x | iOS 7.x |
|---|---|---|
| handled | `logException(Throwable, Map)` | `logException:reason:options:completion:` |
| handled | `logException(Throwable, String domain, int skipFrames)` | `logException:reason:domain:skipFrames:completion:` |
| unhandled | `logUnhandledException(Throwable, Map)` | `logUnhandledException:reason:completion:` |

`logUnhandledException` is new on Android and is a better fit than `onUncaughtException(Thread, Throwable)`, which exists to be called from a real `UncaughtExceptionHandler`.

**Both exception-logging methods are two ObjC overloads each on iOS, and the RN bridge calls the `NSString`-pair one.** Verified against `Bugsee.h:367,374,380-381` (`packages/react-native/Bugsee.xcframework/ios-arm64/Bugsee.framework/Headers/`):
**The exception-logging methods come as `NSString`-pair and `NSException` overloads on iOS, and the RN bridge calls the `NSString`-pair ones.** Verified against `Bugsee.h` of 7.0.0-beta7 (bugsee-cocoa#233; 7.0.0-beta6 and earlier took a `BugseeExceptionLoggingOptions` object instead of `domain:skipFrames:`):

```objc
+ (void)logException:(nonnull NSString *)name reason:(nonnull NSString *)reason options:(BugseeExceptionLoggingOptions *_Nullable)loggingOptions completion:(nullable BugseeEmptyBlock)completionBlock
NS_SWIFT_NAME(logException(name:reason:options:completion:));
+ (void)logException:(nonnull NSException *)exception options:(BugseeExceptionLoggingOptions *_Nullable)loggingOptions completion:(nullable BugseeEmptyBlock)completionBlock
NS_SWIFT_NAME(logException(exception:options:completion:));
+ (void)logException:(nonnull NSException *)exception completion:(nullable BugseeEmptyBlock)completionBlock
NS_SWIFT_NAME(logException(exception:completion:));
+ (void)logException:(nonnull NSException *)exception domain:(nullable NSString *)domain skipFrames:(NSInteger)skipFrames completion:(nullable BugseeEmptyBlock)completionBlock
NS_SWIFT_NAME(logException(exception:domain:skipFrames:completion:));
+ (void)logException:(nonnull NSString *)name reason:(nonnull NSString *)reason completion:(nullable BugseeEmptyBlock)completionBlock
NS_SWIFT_NAME(logException(name:reason:completion:));
+ (void)logException:(nonnull NSString *)name reason:(nonnull NSString *)reason domain:(nullable NSString *)domain skipFrames:(NSInteger)skipFrames completion:(nullable BugseeEmptyBlock)completionBlock
NS_SWIFT_NAME(logException(name:reason:domain:skipFrames:completion:));

+ (void)logUnhandledException:(nonnull NSException *)exception completion:(nullable BugseeEmptyBlock)completionBlock
NS_SWIFT_NAME(logUnhandledException(exception:completion:));
Expand All @@ -306,12 +310,12 @@ What changes is the payload gaining a `debug_ids` member alongside name, message

The table's selectors are both the `NSString`-pair overload, which is the one the RN bridge has a name and a reason to hand it (a JS `Error`'s `name` and `message`), not a real `NSException`:

- **handled** — `logException:reason:options:completion:`. Its **first** argument *is* the exception name (Swift label `name:`), the second is the reason; there is no `name:` selector segment because the first argument belongs to `logException:` itself. The `NSException`-overload's selector, `logException:options:completion:`, is the one this wrapper does not call.
- **handled** — `logException:reason:domain:skipFrames:completion:`. Its **first** argument *is* the exception name (Swift label `name:`), the second is the reason; there is no `name:` selector segment because the first argument belongs to `logException:` itself. The `NSException` overloads (`logException:completion:`, `logException:domain:skipFrames:completion:`) are the ones this wrapper does not call.
- **unhandled** — `logUnhandledException:reason:completion:`. Same shape: the first argument *is* the exception name (Swift label `name:` again), with no `name:` selector segment for the same reason. An `NSException`-taking overload also exists (`logUnhandledException:completion:`, Swift `logUnhandledException(exception:completion:)`), which this wrapper does not call either, since it never has a real `NSException` to hand it — only a JS error's name and message strings.

(An earlier draft of this document mis-stated the unhandled selector as `logUnhandledException:name:reason:completion:` — that selector does not exist, because `name:` is a Swift argument label, not an ObjC selector segment.)

The `ExceptionOptions` contract interface declares only `Domain` and `SkipFrames`, but the `logException` javadoc documents `"domain"`, `"labels"` and `"includeVideo"`. The parameter is `Map<String, Object>`, so the extra keys are accepted; the wrapper passes all three.
JS `ExceptionOptions` is `{domain?}`. Both bridges pass the domain and `skipFrames` 0: Android to `logException(Throwable, String, int)` (bugsee-android#256 deprecated `logException(Throwable, Map)`, whose `labels` and `includeVideo` were never applied), iOS to `logException:reason:domain:skipFrames:completion:` (bugsee-cocoa#233). Neither takes labels: a report is labelled in `setReportHandler`. `skipFrames` is always 0: it leaves top native frames out of the report's signature, and an RN report is keyed on the JS signature in the payload. Neither SDK can leave the video out of one report, so there is no `includeVideo`.

### 9.2 Source maps

Expand Down Expand Up @@ -349,7 +353,7 @@ Carried over: `launch` / `relaunch` / `stop`, `log`, `trace`, `event`, `showRepo

New: `setUserIdentifier` / `getUserIdentifier` / `clearUserIdentifier`, `startBlackout` / `endBlackout` / `isBlackout`, `addBreadcrumb` + `setBreadcrumbFilter`, `notify`, `addNetworkEvent`, `logUnhandledException`, `getAllAttributes`, `createReport` + `setReportHandler`, `getStatus` + `onStatusChange`, `startTransaction` / `startSpan` / `getActiveSpan`.

Removed: `setEmail` / `getEmail` / `clearEmail`, `pause` / `resume`, `setKeyboardVisibility` (no replacement on either platform), `upload(…, includeVideo)`, `getDeviceId`.
Removed: `setEmail` / `getEmail` / `clearEmail`, `pause` / `resume`, `setKeyboardVisibility` (no replacement on either platform), `upload(…, includeVideo)`, `logException`'s `labels` and `includeVideo` options (label a report in `setReportHandler`), `getDeviceId`.

Two ergonomic changes: `toggleProtected(ref, bool)` becomes a `<BugseeSecure>` component that disposes on unmount, and `initialize()` becomes `attach()` for the case where Android launched itself from manifest metadata and only the JS layer needs wiring.

Expand Down
34 changes: 14 additions & 20 deletions examples/bare/e2e/api-arguments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describeDevice(`overloads and arguments on ${TARGET_NAME}`, () => {
if (ON_SIMULATOR) {
return;
}
const result = await runFor(log!, 'api-exc-values', 'exc-values done', 5);
const result = await runFor(log!, 'api-exc-values', 'exc-values done', 3);
run = result.run;
done = result.line;
errors = result.bundles.filter(b => b.request.type === 'error');
Expand All @@ -170,21 +170,22 @@ describeDevice(`overloads and arguments on ${TARGET_NAME}`, () => {
report('payload reasons', reasons);
expect(reasons.filter(r => r === `api-exc string ${nonce}`)).toHaveLength(1);
expect(reasons.filter(r => r === 'Non-Error thrown: object')).toHaveLength(2);
// The three values plus the two includeVideo errors.
expect(errors).toHaveLength(5);
// The three values; the includeVideo calls file nothing ([API-11d]).
expect(errors).toHaveLength(3);
});

// iOS ignores includeVideo too: https://github.com/bugsee/bugsee-cocoa/issues/226
itReports('[API-11d] includeVideo is accepted and changes nothing', () => {
// includeVideo is not an option: neither 7.x SDK can leave the video out of one
// report (https://github.com/bugsee/bugsee-cocoa/issues/226, bugsee-android#247).
itReports('[API-11d] includeVideo is refused and files nothing', () => {
const nonce = run.scenario.nonce;
const video = jsonAfter<Record<string, Settled>>(done.text, 'video');
expect(video.on!.ok && video.off!.ok).toBe(true);
const on = errors.find(b => payloadReason(b) === `api-exc video-on ${nonce}`);
const off = errors.find(b => payloadReason(b) === `api-exc video-off ${nonce}`);
expect(on).toBeDefined();
expect(off).toBeDefined();
report('includeVideo files', { on: types(on!), off: types(off!) });
expect(types(on!)).toEqual(types(off!));
report('includeVideo settled', video);
for (const call of [video.on!, video.off!]) {
expect(call.ok).toBe(false);
expect(call.error?.name).toBe('TypeError');
}
const reasons = [`api-exc video-on ${nonce}`, `api-exc video-off ${nonce}`];
expect(errors.filter(b => reasons.includes(payloadReason(b)))).toHaveLength(0);
});
});

Expand All @@ -201,7 +202,7 @@ describeDevice(`overloads and arguments on ${TARGET_NAME}`, () => {
report('fallback', fallback.text.trim());
if (!ON_SIMULATOR) {
bundles = (await awaitBundles(1, 45_000)).filter(b => b.request.type === 'error');
report('boundary errors', bundles.map(b => ({ reason: reasonOf(b).slice(0, 160), labels: b.request.labels, crash: crashOf(b) === undefined ? '-' : 'yes' })));
report('boundary errors', bundles.map(b => ({ reason: reasonOf(b).slice(0, 160), crash: crashOf(b) === undefined ? '-' : 'yes' })));
}
await stopApp();
});
Expand All @@ -226,13 +227,6 @@ describeDevice(`overloads and arguments on ${TARGET_NAME}`, () => {
expect((crash.exceptionLoggingOptions as { exceptionDomain?: unknown }).exceptionDomain).toBe(`api-domain-${run.scenario.nonce}`);
}
});

/** Android 7.3.0 ignores logException labels (R10, exceptions.test.ts case 7): the same path.
* https://github.com/bugsee/bugsee-android/issues/247 */
(ON_ANDROID ? it.failing : itReports)('[API-13d] the options prop: labels reach the report', () => {
expect(bundles).toHaveLength(1);
expect(bundles[0]!.request.labels).toEqual(expect.arrayContaining([`api-label-${run.scenario.nonce}`]));
});
});

it('[API-15b-d] notify() carries severity, fields and urgent into the relay record; a sixth argument throws', async () => {
Expand Down
21 changes: 5 additions & 16 deletions examples/bare/e2e/exceptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@
* BUGSEE_E2E exc boundary-onError / boundary-fallback
* BUGSEE_E2E exc app-handler fatal=<bool>
*
* Case 7 is `it.failing` on Android for a documented gap (labels ignored,
* still in 7.3.1; https://github.com/bugsee/bugsee-android/issues/247). Do not weaken it. Case 10 was `it.failing` on 7.3.0, which
* Case 7 (labels on a handled exception) is gone: neither SDK's logException
* takes labels any more (bugsee-android#256, bugsee-cocoa#233); a report is
* labelled in setReportHandler. Case 10 was `it.failing` on 7.3.0, which
* also filed an error report for an unhandled exception; 7.3.1 files the
* crash only (bugsee-android d390b5203, #192), so it is a plain `it`.
* On iOS both are plain `it` (labels apply; Debug files no second report).
* On iOS it is a plain `it` (Debug files no second report).
*
* Case 12's red-box line must stay on Android. Gated release R2/R3 (controller
* ruling): R13's second Bugsee crash was not retained on SDK 7.3.0; logcat
Expand Down Expand Up @@ -400,13 +401,6 @@ describeDevice(`JS exceptions on ${TARGET_NAME}`, () => {
handledPayload.signature,
);
});

// Known SDK bug (R10): https://github.com/bugsee/bugsee-android/issues/247
itAndroid.failing('labels reach the report', () => {
const labels = handled.request.labels;
report('case 7 actual labels', labels);
expect(labels).toEqual(expect.arrayContaining([`lbl-${nonce}`]));
});
});

describeDebug('exc-rejection', () => {
Expand Down Expand Up @@ -959,7 +953,7 @@ describeDevice(`JS exceptions on ${TARGET_NAME}`, () => {
| undefined;
expect(opts).toBeDefined();
expect(opts!.exceptionDomain).toBe(`e2e-${nonce}`);
expect(opts!.includeVideo).toBe(true);
expect(opts).not.toHaveProperty('includeVideo');
});

itIosDevice('the client signature carries the JS signature', () => {
Expand All @@ -968,11 +962,6 @@ describeDevice(`JS exceptions on ${TARGET_NAME}`, () => {
);
});

itIosDevice('labels reach the report', () => {
const labels = handled.request.labels;
report('case 7 iOS labels', labels);
expect(labels).toEqual(expect.arrayContaining([`lbl-${nonce}`]));
});
});

describeIosDeviceDebug('exc-rejection (iPhone)', () => {
Expand Down
9 changes: 5 additions & 4 deletions examples/bare/scenarios/api-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* api-token before launch(): launch('') / ' ' / 42 must reject
* without starting anything; then the app launches.
* api-subs remove() on onLifecycleEvent and onStatusChange.
* api-exc-values logException(string | plain object | null) and
* {includeVideo: true | false}.
* api-exc-values logException(string | plain object | null), and
* {includeVideo: true | false}, which must be refused.
* api-notify notify() with severity, fields and urgent, and bare.
* api-spans every SpanStatus through finish(status), startChildSpan,
* setStatus then finish(), a dead handle, startSpan with no
Expand Down Expand Up @@ -137,9 +137,10 @@ async function exceptionValues(nonce: string): Promise<void> {
null: await settled(() => Bugsee.logException(null)),
};
await delay(1_500);
// Not an option any more: each call throws TypeError and files nothing.
const video = {
on: await settled(() => Bugsee.logException(new Error(`api-exc video-on ${nonce}`), { includeVideo: true })),
off: await settled(() => Bugsee.logException(new Error(`api-exc video-off ${nonce}`), { includeVideo: false })),
on: await settled(() => Bugsee.logException(new Error(`api-exc video-on ${nonce}`), { includeVideo: true } as never)),
off: await settled(() => Bugsee.logException(new Error(`api-exc video-off ${nonce}`), { includeVideo: false } as never)),
};
mark(`exc-values done nonce=${nonce} results=${JSON.stringify(results)} video=${JSON.stringify(video)}`);
}
Expand Down
4 changes: 2 additions & 2 deletions examples/bare/scenarios/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,13 @@ function Thrower({ nonce }: { nonce: string }): never {

/**
* N-08: an ErrorBoundary whose fallback is a function and whose `options`
* carry a domain and labels, around a child that throws on render.
* carry a domain, around a child that throws on render.
*/
function BoundaryStage({ nonce }: { nonce: string }) {
return (
<View style={[StyleSheet.absoluteFill, styles.white]}>
<ErrorBoundary
options={{ domain: `api-domain-${nonce}`, labels: [`api-label-${nonce}`] }}
options={{ domain: `api-domain-${nonce}` }}
onError={() => mark(`boundary onError nonce=${nonce}`)}
fallback={({ error, componentStack }) => {
mark(
Expand Down
2 changes: 0 additions & 2 deletions examples/bare/scenarios/exceptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,6 @@ export function runExceptionScenario(
} catch (e) {
Bugsee.logException(e, {
domain: `e2e-${nonce}`,
labels: ['e2e', `lbl-${nonce}`],
includeVideo: true,
});
}
Bugsee.logException({ password: `pw-${nonce}`, message: `obj-${nonce}` });
Expand Down
2 changes: 1 addition & 1 deletion examples/bare/scenarios/staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export async function runStagingScenario(scenario: StagingScenario, nonce: strin
try {
stagingThrowSite(nonce, 'js handled');
} catch (error) {
Bugsee.logException(error, { labels: ['campaign', `nonce-${nonce}`] });
Bugsee.logException(error);
}
mark(`handled-sent text="${campaignText(nonce, 'js handled')}" nonce=${nonce}`);
return;
Expand Down
2 changes: 0 additions & 2 deletions examples/playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,6 @@ The app shows a note next to each affected button.
| Platform | Issue |
|---|---|
| iOS | [A second `createReport` wipes the first](https://github.com/bugsee/bugsee-cocoa/issues/224) |
| iOS | [`logException` `includeVideo` ignored](https://github.com/bugsee/bugsee-cocoa/issues/226) |
| Android | [`logException` labels and `includeVideo` ignored](https://github.com/bugsee/bugsee-android/issues/247) |
| iOS | [`upload()` source type shows as unknown](https://github.com/bugsee/bugsee-cocoa/issues/223) |

For manual test sessions, see [VALIDATION.md](./VALIDATION.md).
2 changes: 1 addition & 1 deletion examples/playground/__tests__/errors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ test('actions have valid call targets and unique ids', () => {
errorsActions.forEach((a) => expect(() => callTarget(a.call)).not.toThrow());
expect(new Set(errorsActions.map((a) => a.id)).size).toBe(errorsActions.length);
});
test('renders labels and the boundary demo', async () => {
test('renders the actions and the boundary demo', async () => {
await render(<ErrorsScreen />);
expect(screen.getByText('Log handled string')).toBeTruthy();
expect(screen.getByText('Throw inside ErrorBoundary')).toBeTruthy();
Expand Down
7 changes: 2 additions & 5 deletions examples/playground/__tests__/shop.flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,10 @@ test('filter returns events without a body unchanged', () => {
expect(SHOP_NETWORK_FILTER(n)).toBe(n);
});

test('pay(declined) logs a handled exception with labels and domain', async () => {
test('pay(declined) logs a handled exception with a domain', async () => {
startCheckoutFlow();
expect(await pay('declined', { total: 5 })).toBe('declined');
expect(bugsee.logException).toHaveBeenCalledWith(expect.any(Error), {
labels: ['checkout', 'payment'],
domain: 'payment',
});
expect(bugsee.logException).toHaveBeenCalledWith(expect.any(Error), { domain: 'payment' });
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/status/402'));
});

Expand Down
5 changes: 1 addition & 4 deletions examples/playground/__tests__/shop.screens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,7 @@ test('checkout declined logs the handled exception', async () => {
await render(<Checkout />);
await fireEvent.press(screen.getByText('Declined'));
await fireEvent.press(screen.getByText('Pay'));
await waitFor(() => expect(bugsee.logException).toHaveBeenCalledWith(expect.any(Error), {
labels: ['checkout', 'payment'],
domain: 'payment',
}));
await waitFor(() => expect(bugsee.logException).toHaveBeenCalledWith(expect.any(Error), { domain: 'payment' }));
expect(alert).toHaveBeenCalledWith('Checkout', 'Payment declined');
});

Expand Down
Loading
Loading