diff --git a/docs/design/2026-09-15-sdk-design.md b/docs/design/2026-09-15-sdk-design.md index 4cab0e69..4a0ec7d7 100644 --- a/docs/design/2026-09-15-sdk-design.md +++ b/docs/design/2026-09-15-sdk-design.md @@ -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:)); @@ -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`, 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 @@ -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 `` 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. diff --git a/examples/bare/e2e/api-arguments.test.ts b/examples/bare/e2e/api-arguments.test.ts index 7e55eaf2..3248c40c 100644 --- a/examples/bare/e2e/api-arguments.test.ts +++ b/examples/bare/e2e/api-arguments.test.ts @@ -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'); @@ -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>(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); }); }); @@ -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(); }); @@ -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 () => { diff --git a/examples/bare/e2e/exceptions.test.ts b/examples/bare/e2e/exceptions.test.ts index e255e12f..e597a2b5 100644 --- a/examples/bare/e2e/exceptions.test.ts +++ b/examples/bare/e2e/exceptions.test.ts @@ -28,11 +28,12 @@ * BUGSEE_E2E exc boundary-onError / boundary-fallback * BUGSEE_E2E exc app-handler fatal= * - * 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 @@ -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', () => { @@ -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', () => { @@ -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)', () => { diff --git a/examples/bare/scenarios/api-args.ts b/examples/bare/scenarios/api-args.ts index f7d123dd..47baca83 100644 --- a/examples/bare/scenarios/api-args.ts +++ b/examples/bare/scenarios/api-args.ts @@ -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 @@ -137,9 +137,10 @@ async function exceptionValues(nonce: string): Promise { 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)}`); } diff --git a/examples/bare/scenarios/api.tsx b/examples/bare/scenarios/api.tsx index fcdccf5f..2cef7a8b 100644 --- a/examples/bare/scenarios/api.tsx +++ b/examples/bare/scenarios/api.tsx @@ -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 ( mark(`boundary onError nonce=${nonce}`)} fallback={({ error, componentStack }) => { mark( diff --git a/examples/bare/scenarios/exceptions.tsx b/examples/bare/scenarios/exceptions.tsx index becacf8e..7db0c77d 100644 --- a/examples/bare/scenarios/exceptions.tsx +++ b/examples/bare/scenarios/exceptions.tsx @@ -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}` }); diff --git a/examples/bare/scenarios/staging.ts b/examples/bare/scenarios/staging.ts index eaba0705..728e32a5 100644 --- a/examples/bare/scenarios/staging.ts +++ b/examples/bare/scenarios/staging.ts @@ -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; diff --git a/examples/playground/README.md b/examples/playground/README.md index 33cf5315..bd35d8c3 100644 --- a/examples/playground/README.md +++ b/examples/playground/README.md @@ -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). diff --git a/examples/playground/__tests__/errors.test.tsx b/examples/playground/__tests__/errors.test.tsx index 748cada3..2f8e85e1 100644 --- a/examples/playground/__tests__/errors.test.tsx +++ b/examples/playground/__tests__/errors.test.tsx @@ -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(); expect(screen.getByText('Log handled string')).toBeTruthy(); expect(screen.getByText('Throw inside ErrorBoundary')).toBeTruthy(); diff --git a/examples/playground/__tests__/shop.flow.test.ts b/examples/playground/__tests__/shop.flow.test.ts index 6c1953fc..33b400f2 100644 --- a/examples/playground/__tests__/shop.flow.test.ts +++ b/examples/playground/__tests__/shop.flow.test.ts @@ -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')); }); diff --git a/examples/playground/__tests__/shop.screens.test.tsx b/examples/playground/__tests__/shop.screens.test.tsx index ba0798b0..43855829 100644 --- a/examples/playground/__tests__/shop.screens.test.tsx +++ b/examples/playground/__tests__/shop.screens.test.tsx @@ -74,10 +74,7 @@ test('checkout declined logs the handled exception', async () => { await render(); 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'); }); diff --git a/examples/playground/src/screens/ErrorsScreen.tsx b/examples/playground/src/screens/ErrorsScreen.tsx index 264ac28c..2f3cc1ef 100644 --- a/examples/playground/src/screens/ErrorsScreen.tsx +++ b/examples/playground/src/screens/ErrorsScreen.tsx @@ -5,7 +5,6 @@ import { NATIVE_UNAVAILABLE, PlaygroundNative } from '../../modules/playground-n import { ActionButton } from '../ui/ActionButton'; import type { Action } from '../ui/action'; import { afterTap } from '../ui/afterTap'; -import { KnownIssue } from '../ui/KnownIssue'; import { Screen } from '../ui/Screen'; import { Section } from '../ui/Section'; @@ -56,14 +55,6 @@ export const errorsActions: Action[] = [ expect: 'A handled error report whose message is the string', run: () => Bugsee.logException('a plain string'), }, - { - id: 'handled-labels', - label: 'Log handled error with labels', - call: "Bugsee.logException(new Error('labelled'), { labels: ['playground', 'handled'] })", - knownIssue: 'android-247', - expect: 'Labels playground, handled on the error', - run: () => Bugsee.logException(new Error('labelled'), { labels: ['playground', 'handled'] }), - }, { id: 'handled-domain', label: 'Log handled error with domain', @@ -71,24 +62,6 @@ export const errorsActions: Action[] = [ expect: 'Domain com.bugsee.playground on the error', run: () => Bugsee.logException(new Error('domain'), { domain: 'com.bugsee.playground' }), }, - { - id: 'handled-video-ios', - label: 'Log handled error with video', - call: "Bugsee.logException(new Error('video'), { includeVideo: true })", - platforms: ['ios'], - knownIssue: 'cocoa-226', - expect: 'A handled error report that includes video', - run: () => Bugsee.logException(new Error('video'), { includeVideo: true }), - }, - { - id: 'handled-video-android', - label: 'Log handled error with video', - call: "Bugsee.logException(new Error('video'), { includeVideo: true })", - platforms: ['android'], - knownIssue: 'android-247', - expect: 'A handled error report that includes video', - run: () => Bugsee.logException(new Error('video'), { includeVideo: true }), - }, { id: 'unhandled-api', @@ -152,9 +125,9 @@ function BoundaryDemo() { const [armed, setArmed] = useState(false); return ( <> - Expect: The fallback renders; a handled error with label boundary is reported + Expect: The fallback renders; a handled error with domain boundary is reported ( <> Fallback: {error instanceof Error ? error.message : String(error)} @@ -171,7 +144,6 @@ function BoundaryDemo() {