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
2 changes: 2 additions & 0 deletions docs/design/plans/2026-09-16-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -4078,6 +4078,8 @@ Device: WOD_LX1 `AMRJCP4718402860`, Debug, logged `BUGSEE_E2E appearance nonce=9
- [x] **13.3** Android mapping and NDK symbols — the Gradle plugin's job once applied. Write `android/bugsee.properties` with the **unprefixed** `app_token=` key (not `plugin.appToken`) at the *root* project, and default `plugin.ndk.enabled=true` for RN, since every RN app ships Hermes and `libreactnative.so` it did not write.
- [x] **13.4** Expo config plugin, with a `prebuild --clean` test — the `.xcscheme` edit is the most fragile part and Expo has no helper for it. `CI=1 yarn expo prebuild --clean` in `examples/expo` exited 0 (CocoaPods installed) and left `bugsee-xcode.sh` on *Bundle React Native code and images* plus the Archive `Upload dSYMs` post-action.
- [ ] **13.5** End-to-end: a release build whose JS stack symbolicates in the dashboard. This is the only test that proves the whole chain.

Local half only. A debuggable release APK (`:app:assembleRelease -PbugseeE2eDebuggable=true`, arm64, Hermes) on WOD_LX1 `AMRJCP4718402860` reported `debug_ids` `{"index.android.bundle":"3c110b23-2416-5bc6-acac-e35ac7e69aa6"}`, the same UUID as `debug_id` on the composed map `index.android.bundle.map` after `compose-source-maps.js` (`hermes-sourcemaps.js` injects into that map). `debug-files upload` was not executed. The dashboard symbolication half is still open: nothing was uploaded and no Bugsee application was created, so this checkbox stays unchecked. iPhone XS (KRSFT, `00008020-000554DC2641002E`) was offline, so that run was not taken. An iOS device run is skipped unless `BUGSEE_COMPOSED_MAP` names the composed map. The iOS Simulator was not run: the SDK compiles `logException` out on the simulator, so a Release simulator build would not retain a `debug_ids` payload.
- [ ] Review gate.

---
Expand Down
3 changes: 2 additions & 1 deletion examples/bare/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ afterEvaluate {
cmd.add("--hermes-arg")
cmd.add(flag)
}
project.exec {
// Gradle 9 removed Project.exec. ExecOperations is the task service that replaced it.
bundleTask.services.get(org.gradle.process.ExecOperations).exec {
commandLine cmd
}
}
Expand Down
216 changes: 216 additions & 0 deletions examples/bare/e2e/composed-debug-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* Task 13.5, local half: a release build's runtime `debug_ids` value equals
* the `debug_id` on the composed Hermes source map (the map after
* `compose-source-maps.js`, which is what `hermes-sourcemaps.js` injects
* into). A Debug Metro bundle does not go through hermesc or that compose
* step, so this file runs only with `E2E_RELEASE=1` and a release binary
* already installed. `debug-files upload` is not invoked.
*
* Android: debuggable release APK (`:app:assembleRelease
* -PbugseeE2eDebuggable=true`), airplane mode on, bundles cleared. The
* composed map is
* `android/app/build/generated/sourcemaps/react/release/index.android.bundle.map`
* unless `BUGSEE_COMPOSED_MAP` names another file.
*
* The scenario is `exc-map-id`. It does not call `registerDebugIds`, which
* would replace the inject stub. `duration` stays 90. `startRun` still
* requires `Status.Launched`.
*
* iOS Simulator cannot retain the exception payload: the SDK compiles
* `logException` out there. A Debug simulator run is not this proof. An iOS
* device run is skipped unless `BUGSEE_COMPOSED_MAP` names the composed map;
* this file does not default that path to the Android release map.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { type PulledBundle, airplane, crashOf, removePulledBundles } from './bundles';
import { ANDROID_PACKAGE, iosTarget } from './device';
import {
ON_ANDROID,
ON_IOS,
type Run,
awaitBundles,
clearBundles,
TARGET_NAME,
must,
report,
startRun,
useLog,
} from './harness';
import { type DeviceLog, IosConsole, Logcat, adb, resetScenario } from './scenario';

const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

const RELEASE = process.env.E2E_RELEASE === '1';
const ON_IOS_SIM = ON_IOS && iosTarget() === 'simulator';
const HAS_COMPOSED_MAP =
process.env.BUGSEE_COMPOSED_MAP !== undefined && process.env.BUGSEE_COMPOSED_MAP !== '';
/**
* Simulator `logException` is compiled out, so it cannot report `debug_ids`.
* An iOS device run reads whatever map `BUGSEE_COMPOSED_MAP` names; without
* that path the describe is skipped rather than opening the Android map.
*/
const describeReleaseProof =
RELEASE && (ON_ANDROID || (ON_IOS && !ON_IOS_SIM && HAS_COMPOSED_MAP)) ? describe : describe.skip;

jest.setTimeout(20 * 60_000);

let log: DeviceLog;

interface JsPayload {
readonly reason?: unknown;
readonly frames?: ReadonlyArray<{ readonly debug_id?: unknown }>;
readonly debug_ids?: unknown;
}

function composedMapPath(): string {
const override = process.env.BUGSEE_COMPOSED_MAP;
if (override !== undefined && override !== '') {
return override;
}
return join(
__dirname,
'..',
'android/app/build/generated/sourcemaps/react/release/index.android.bundle.map',
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

/** `debug_id` on the composed map. `debugId` must be the same UUID. */
function debugIdOfComposedMap(mapPath: string): string {
const map = JSON.parse(readFileSync(mapPath, 'utf8')) as {
debug_id?: unknown;
debugId?: unknown;
};
if (typeof map.debug_id !== 'string' || !UUID_RE.test(map.debug_id)) {
throw new Error(`composed map ${mapPath} has no debug_id UUID`);
}
if (map.debugId !== map.debug_id) {
throw new Error(`composed map ${mapPath} debugId does not equal debug_id`);
}
return map.debug_id;
}

function payloadOf(bundle: PulledBundle): JsPayload {
const crash = crashOf(bundle);
if (crash === undefined) {
throw new Error(`bundle ${bundle.file} has no crash capture`);
}
const exception = crash.exception;
if (exception === null || typeof exception !== 'object') {
throw new Error(`bundle ${bundle.file} crash.json has no exception object`);
}
const reason = (exception as { reason?: unknown }).reason;
if (typeof reason !== 'string' || !reason.startsWith('{')) {
throw new Error(
`bundle ${bundle.file} exception.reason is not JSON text: ${String(reason).slice(0, 120)}`,
);
}
return JSON.parse(reason) as JsPayload;
}

describeReleaseProof(`composed debug id on ${TARGET_NAME} (release)`, () => {
let run: Run;
let nonce: string;
let mapId: string;
let payload: JsPayload;

beforeAll(async () => {
mapId = debugIdOfComposedMap(composedMapPath());
report('composed map', composedMapPath());
report('composed map debug_id', mapId);

if (ON_IOS) {
log = IosConsole.start();
useLog(log, '13.5');
} else {
log = await Logcat.start();
useLog(log, '13.5');
await airplane(true);
}
await clearBundles();

run = await startRun('exc-map-id');
nonce = run.scenario.nonce;
report('banner', run.banner.text.trim());
expect(run.dev).toBe(false);

must(
await log.waitFor(
new RegExp(`BUGSEE_E2E exc map-id-sent nonce=${nonce}`),
30_000,
run.launched.index,
),
'the map-id-sent marker',
run.start,
);

const bundles = await awaitBundles(1);
report(
'bundle files',
bundles.map(b => b.file),
);
const matches = bundles.filter(bundle => {
try {
return payloadOf(bundle).reason === `E2E map-id ${nonce}`;
} catch {
return false;
}
});
expect(matches).toHaveLength(1);
payload = payloadOf(matches[0]!);
report('runtime debug_ids', payload.debug_ids);
});

afterAll(async () => {
if (!RELEASE || ON_IOS_SIM || (!ON_ANDROID && !ON_IOS)) {
return;
}
try {
if (ON_ANDROID) {
await adb('shell', 'am', 'force-stop', ANDROID_PACKAGE).catch(() => {});
}
} finally {
try {
if (ON_ANDROID) {
await airplane(false);
}
} finally {
try {
await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error)));
} finally {
try {
const { removed, kept } = removePulledBundles();
report('pulled bundle roots', { removed: removed.length, kept });
} finally {
try {
if (log !== undefined) {
log.stop();
}
} finally {
resetScenario();
}
}
}
}
}
});

it('the release bundle reports the composed map debug id', () => {
const ids = payload.debug_ids;
expect(ids).toEqual(expect.any(Object));
expect(Array.isArray(ids)).toBe(false);
const values = Object.values(ids as Record<string, unknown>);
expect(values.length).toBeGreaterThan(0);
for (const value of values) {
expect(value).toBe(mapId);
}
for (const frame of payload.frames ?? []) {
if (frame.debug_id !== undefined) {
expect(frame.debug_id).toBe(mapId);
}
}
report('matched', { runtime: values, map: mapId });
});
});
21 changes: 19 additions & 2 deletions examples/bare/scenarios/exceptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
* Every value carries the run's nonce. Before the first throw, the scenario
* registers `globalThis._bugseeDebugIds` from inside a function in this file
* (its top frame shares the bundle with every throw site), simulating Phase
* 13's inject stub. The app-level ErrorUtils handler is installed before
* `launch()` so Bugsee's handler chains to it (case 9).
* 13's inject stub. `exc-map-id` does not: a release bundle already carries
* the inject stub, and replacing it would hide that UUID. The app-level
* ErrorUtils handler is installed before `launch()` so Bugsee's handler
* chains to it (case 9).
*
* Markers (console.log → ReactNativeJS on Android):
* BUGSEE_E2E exc handled-sent nonce=<n>
Expand All @@ -28,6 +30,7 @@ export const EXCEPTION_SCENARIOS = [
'exc-root',
'exc-prelaunch',
'exc-observe',
'exc-map-id',
] as const;

export type ExceptionScenario = (typeof EXCEPTION_SCENARIOS)[number];
Expand Down Expand Up @@ -149,6 +152,20 @@ export function runExceptionScenario(
return;
}

// The release proof. `registerDebugIds` replaces the whole registration
// with a synthetic id, which would hide the UUID `sourcemaps inject` wrote
// into this bundle. Leave `globalThis._bugseeDebugIds` as the inject stub
// filled it.
if (scenario === 'exc-map-id') {
try {
throw new Error(`E2E map-id ${nonce}`);
} catch (e) {
Bugsee.logException(e);
}
mark(`map-id-sent nonce=${nonce}`);
return;
}

registerDebugIds(nonce);

if (scenario === 'exc-handled') {
Expand Down
51 changes: 49 additions & 2 deletions packages/react-native/plugin/build/gradle.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading