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 docs/design/plans/2026-09-16-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -4045,6 +4045,12 @@ The phase with the most native↔JS round-tripping, hence the most device testin

`notify(title, body, severity, fields, urgent)`; `startTransaction`, `startSpan`, `getActiveSpan`. Spans are objects with lifetime, so the bridge must not leak them — tests pin that a finished span is released. Device test: a notification arrives; a transaction appears in a report.

Device: WOD_LX1 `AMRJCP4718402860`, Debug, logged `BUGSEE_E2E apm notify=notify-63127dab6203 txn=txn-63127dab6203` (pass: the relay held the notification, and the report's performance capture held the transaction and its child span). iOS Simulator `6FA9B3E8-26C7-4232-AA2C-537D9DF32957`, Debug, logged `BUGSEE_E2E apm notify=notify-19d207323e5d txn=txn-19d207323e5d` (pass, same two artifacts). iPhone XS (KRSFT) was already running BareExample (pid 43437); waited and retried once, and that process was still there, so that run was not taken.

Setter fix: the four span setters return a boolean so codegen runs them on the JS thread, ahead of `finish`. WOD_LX1 `AMRJCP4718402860`, Debug, logged `BUGSEE_E2E apm notify=notify-f70ead5fa74c txn=txn-f70ead5fa74c` (pass: the child span's attributes held `attr-f70ead5fa74c`). iOS Simulator `6FA9B3E8-26C7-4232-AA2C-537D9DF32957`, Debug, logged `BUGSEE_E2E apm notify=notify-c88c83270b1d txn=txn-c88c83270b1d` (pass, same attribute). KRSFT was still that same BareExample process, so the handset was not run again.

Review fix: `finish` drops the handle it was called with even when `isFinished` stays false, and the first JS `finish` returns. WOD_LX1 `AMRJCP4718402860`, Debug, logged `BUGSEE_E2E apm notify=notify-fca865f68ae1 txn=txn-fca865f68ae1` (pass). iOS Simulator `6FA9B3E8-26C7-4232-AA2C-537D9DF32957`, Debug, logged `BUGSEE_E2E apm notify=notify-c852b4eca69d txn=txn-c852b4eca69d` (pass). KRSFT was still pid 43437, so the handset was not run.

---

## Phase 11 — Appearance & data
Expand Down
18 changes: 15 additions & 3 deletions examples/bare/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
* scenarios/reporting.ts, `log-filter` is scenarios/log-filter.ts,
* `breadcrumb-filter` is scenarios/breadcrumb-filter.ts,
* `network-filter` is scenarios/network-filter.ts,
* `add-network-event` is scenarios/add-network-event.ts, and
* `feedback` is scenarios/feedback.ts.
* `add-network-event` is scenarios/add-network-event.ts,
* `feedback` is scenarios/feedback.ts, and `apm` is scenarios/apm.ts.
*/
import { useEffect, useState } from 'react';
import {
Expand Down Expand Up @@ -112,6 +112,7 @@ import {
runAddNetworkEventScenario,
} from './scenarios/add-network-event';
import { isFeedbackScenario, runFeedbackScenario } from './scenarios/feedback';
import { isApmScenario, runApmScenario } from './scenarios/apm';

const STATUS_NAMES: Record<number, string> = {
[Status.Stopped]: 'Stopped',
Expand Down Expand Up @@ -144,7 +145,13 @@ function launchOptions(endpoint: string, scenario: string): LaunchOptions {
if (isBreadcrumbFilterScenario(scenario)) {
options.captureBreadcrumbs = true;
}
return BugseeLaunchOptions.serialize(options) as LaunchOptions;
const serialized = BugseeLaunchOptions.serialize(options) as LaunchOptions;
// This scenario only. APM is on by default; setting the key makes the
// launch explicit about the capture this scenario is here to produce.
if (isApmScenario(scenario)) {
serialized['com.bugsee.option.performance.enabled'] = true;
}
return serialized;
}

function appToken(): string {
Expand Down Expand Up @@ -487,6 +494,11 @@ export default function App() {
return;
}

if (isApmScenario(choice.scenario)) {
runApmScenario(choice.nonce);
return;
}

if (isExceptionScenario(choice.scenario)) {
if (isExceptionRenderScenario(choice.scenario)) {
if (!cancelled) {
Expand Down
164 changes: 164 additions & 0 deletions examples/bare/e2e/apm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Phase 10: a notification arrives, and a transaction appears in a report.
*
* The scenario (scenarios/apm.ts) notifies, starts a transaction, starts a
* child span on the same thread, finishes both, then uploads. Retention is
* airplane mode on Android and the dead endpoint on iOS, same as the other
* bundle suites. The notification is not part of the bundle: it is the
* relay file the SDK writes under its data directory.
*
* Marker, from scenarios/apm.ts:
* BUGSEE_E2E apm notify=notify-<n> txn=txn-<n>
*/
import { type PulledBundle, airplane, relayTexts, removePulledBundles, terminateIosApp } from './bundles';
import { ANDROID_PACKAGE } from './device';
import {
ON_IOS,
type Run,
TARGET_NAME,
awaitBundles,
clearBundles,
describeDevice,
must,
report,
startRun,
useLog,
} from './harness';
import { type DeviceLog, IosConsole, Logcat, adb, resetScenario } from './scenario';

jest.setTimeout(5 * 60_000);

describeDevice(`a notification and a transaction on ${TARGET_NAME}`, () => {
let log: DeviceLog;
let run: Run;
let nonce: string;
let bundles: PulledBundle[];
let relay: string[];

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

run = await startRun('apm');
nonce = run.scenario.nonce;
report('banner', run.banner.text.trim());
report('build', run.dev ? 'Debug' : 'Release');

must(
await log.waitFor(
new RegExp(`BUGSEE_E2E apm notify=notify-${nonce} txn=txn-${nonce}`),
20_000,
run.launched.index,
),
'the notification and the transaction being sent',
run.start,
);

relay = await awaitRelay(`notify-${nonce}`);
report('relay files', relay.length);

bundles = await awaitBundles(1);
report(
'bundle files',
bundles.map(bundle => bundle.file),
);
});

afterAll(async () => {
try {
if (ON_IOS) {
await terminateIosApp();
} else {
await adb('shell', 'am', 'force-stop', ANDROID_PACKAGE).catch(() => {});
}
await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error)));
} finally {
try {
if (!ON_IOS) {
await airplane(false);
}
} 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 notification is still in the relay', () => {
const title = `notify-${nonce}`;
const hits = relay.filter(text => text.includes(title));
expect(hits).toHaveLength(1);
expect(hits[0]).toContain(`body-${nonce}`);
});

it('the transaction and its child span are in the report', () => {
expect(bundles).toHaveLength(1);
const bundle = bundles[0]!;
const text = bundle.captures.get('performance');
expect(text).toBeDefined();
expect(text).toContain(`txn-${nonce}`);
expect(text).toContain(`span-${nonce}`);
const child = spanByDescription(text!, `span-${nonce}`);
expect(child).toBeDefined();
// The capture's span objects carry `attributes`. `attr-<nonce>` is the
// value setAttribute wrote, which the transaction start does not.
expect(child!.attributes).toBeDefined();
expect(JSON.stringify(child!.attributes)).toContain(`attr-${nonce}`);
});
});

/** The span object whose `description` is `wanted`, anywhere in the capture. */
function spanByDescription(capture: string, wanted: string): { attributes?: unknown } | undefined {
let found: { attributes?: unknown } | undefined;
const walk = (node: unknown): void => {
if (found !== undefined || node === null || typeof node !== 'object') {
return;
}
if (Array.isArray(node)) {
for (const item of node) {
walk(item);
}
return;
}
const record = node as { description?: unknown; attributes?: unknown };
if (record.description === wanted) {
found = record;
return;
}
for (const value of Object.values(record)) {
walk(value);
}
};
walk(JSON.parse(capture) as unknown);
return found;
}

async function awaitRelay(title: string, timeoutMs = 20_000): Promise<string[]> {
const deadline = Date.now() + timeoutMs;
let texts: string[] = [];
while (Date.now() < deadline) {
texts = await relayTexts(ON_IOS);
if (texts.some(text => text.includes(title))) {
return texts;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
return texts;
}
70 changes: 70 additions & 0 deletions examples/bare/e2e/bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,76 @@ export async function pullIosBundles(): Promise<PulledBundle[]> {
return bundles;
}

/** Where Android persists a notification until its batch upload. */
const ANDROID_RELAY = 'files/bugsee_data/relay';
/** Where iOS persists a notification (`kNotificationsFolder`). */
const IOS_NOTIFICATIONS = `${IOS_SDK_DATA}/notifications`;

/**
* The notification files still on the device, as text.
*
* A notification does not ride inside the bug-report bundle. Airplane mode
* (Android) and the dead endpoint (iOS) keep the upload from completing, so
* the file is what "a notification arrived" means.
*/
export async function relayTexts(ios: boolean): Promise<string[]> {
return ios ? iosRelayTexts() : androidRelayTexts();
}

async function androidRelayTexts(): Promise<string[]> {
const listed = await adbStatus('shell', 'run-as', ANDROID_PACKAGE, 'ls', ANDROID_RELAY);
if (listed.code !== 0) {
return [];
}
const files = listed.output
.split(/\s+/)
.map(name => name.trim())
.filter(name => name.endsWith('.json'));
const texts: string[] = [];
for (const file of files) {
const { stdout } = await execFileAsync(
ADB,
['-s', ANDROID_SERIAL, 'exec-out', 'run-as', ANDROID_PACKAGE, 'cat', `${ANDROID_RELAY}/${file}`],
{ encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
);
texts.push(stdout);
}
return texts;
}

async function iosRelayTexts(): Promise<string[]> {
if (onDevice()) {
const names = ((await deviceEntries(IOS_NOTIFICATIONS)) ?? []).filter(name => name.endsWith('.json'));
const root = mkdtempSync(join(tmpdir(), 'bugsee-relay-'));
const texts: string[] = [];
try {
for (const name of names) {
const dest = join(root, name);
await devicectl(
'copy',
'from',
...CONTAINER,
'--source',
`${IOS_NOTIFICATIONS}/${name}`,
'--destination',
dest,
);
texts.push(readFileSync(dest, 'utf8'));
}
} finally {
rmSync(root, { recursive: true, force: true });
}
return texts;
}
const dir = join(await simulatorContainer(), IOS_NOTIFICATIONS);
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter(name => name.endsWith('.json'))
.map(name => readFileSync(join(dir, name), 'utf8'));
}

/**
* A minimal zip reader. The iOS SDK stores most entries with zstd (method
* 93), which neither macOS's `unzip` nor its `bsdtar` can read; Node's zlib
Expand Down
33 changes: 33 additions & 0 deletions examples/bare/scenarios/apm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Phase 10: a notification and a transaction, after launch.
*
* The notification title and the transaction name both carry the nonce.
* The child span is started on the same thread as the transaction, which
* is what makes it a child: the SDK's active span is thread-local. The
* line is the device test's contract; the report's performance capture
* and the notification relay are what the test reads back.
*/
import Bugsee, { IssueSeverity } from '@bugsee/react-native';

export const APM_SCENARIOS = ['apm'] as const;

export type ApmScenario = (typeof APM_SCENARIOS)[number];

export function isApmScenario(name: string): name is ApmScenario {
return (APM_SCENARIOS as readonly string[]).includes(name);
}

export function runApmScenario(nonce: string): void {
const title = `notify-${nonce}`;
const transactionName = `txn-${nonce}`;
Bugsee.notify(title, `body-${nonce}`, IssueSeverity.High, { nonce }, true);
const transaction = Bugsee.startTransaction(transactionName, 'user.flow', { nonce });
const span = Bugsee.startSpan('db.query', `span-${nonce}`);
// A value the transaction's own attributes do not carry, so the report
// can only have it if this setter reached the SDK before finish.
span.setAttribute('nonce', `attr-${nonce}`);
span.finish();
transaction.finish();
console.log(`BUGSEE_E2E apm notify=${title} txn=${transactionName}`);
Bugsee.upload(`apm-${nonce}`, '');
}
Loading
Loading