diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index f7c3a890..d57be30a 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -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 diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index 4bf0526b..f8e8d08f 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -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 { @@ -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 = { [Status.Stopped]: 'Stopped', @@ -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 { @@ -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) { diff --git a/examples/bare/e2e/apm.test.ts b/examples/bare/e2e/apm.test.ts new file mode 100644 index 00000000..567203d7 --- /dev/null +++ b/examples/bare/e2e/apm.test.ts @@ -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- txn=txn- + */ +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-` 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 { + 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; +} diff --git a/examples/bare/e2e/bundles.ts b/examples/bare/e2e/bundles.ts index 09217f87..c483fd95 100644 --- a/examples/bare/e2e/bundles.ts +++ b/examples/bare/e2e/bundles.ts @@ -482,6 +482,76 @@ export async function pullIosBundles(): Promise { 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 { + return ios ? iosRelayTexts() : androidRelayTexts(); +} + +async function androidRelayTexts(): Promise { + 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 { + 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 diff --git a/examples/bare/scenarios/apm.ts b/examples/bare/scenarios/apm.ts new file mode 100644 index 00000000..7b523237 --- /dev/null +++ b/examples/bare/scenarios/apm.ts @@ -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}`, ''); +} diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 3e4f0d42..f810284d 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -12,6 +12,9 @@ import com.bugsee.library.contracts.options.IssueSeverity; import com.bugsee.library.contracts.options.Options; import com.bugsee.library.contracts.options.OptionsContainer; +import com.bugsee.library.contracts.performance.Span; +import com.bugsee.library.contracts.performance.SpanStatus; +import com.bugsee.library.contracts.performance.Transaction; import com.bugsee.library.contracts.reporting.Report; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; @@ -29,10 +32,15 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + /** * The Android half of the `Bugsee` TurboModule. * @@ -94,6 +102,9 @@ public void logUnhandledException( */ private final DataRequestBridge.Sink dataRequestSink = this::emitRequest; + /** Spans this module is holding. {@link #invalidate()} drops them. */ + private final SpanHandles spanHandles = new SpanHandles(); + public BugseeModule(final ReactApplicationContext context) { super(context); originTracker = new ReactRootOriginTracker(context, SecureRectangleStore.shared()); @@ -146,6 +157,9 @@ public void invalidate() { // unanswered one is not recorded; a reply into this module after it // is gone has nowhere to land. BreadcrumbFilterBridge.shared().detach(this); + // The next runtime cannot know these handles. Drop them without + // finishing: a reload must not close a transaction the SDK still has. + spanHandles.releaseAll(); SecureRectanglePulls.shared().clearRefresher(pullRefresher); originTracker.dispose(); super.invalidate(); @@ -1309,4 +1323,278 @@ private static Map toOptions(@Nullable final ReadableMap o } return result; } + + // --- Notify and spans ------------------------------------------------ + // Span calls stay on this thread. Both SDKs keep the active span in + // thread-local storage, so hopping to another thread would make + // startSpan miss the transaction startTransaction just opened. + // The setters return a boolean for that same thread: codegen queues a + // void TurboModule method, and a setAttribute then finish in one turn + // would release the handle before the setter ran. + + @Override + public void notify( + final String title, + @Nullable final String body, + final double severity, + @Nullable final String fieldsJson, + final boolean urgent + ) { + Map fields = null; + if (fieldsJson != null) { + try { + final Map parsed = BridgeJson.parseObject(fieldsJson); + fields = new LinkedHashMap<>(); + for (final Map.Entry entry : parsed.entrySet()) { + if (entry.getValue() instanceof String) { + fields.put(entry.getKey(), (String) entry.getValue()); + } + } + } catch (final BridgeJson.BadJson error) { + Log.w(TAG, "notify dropped: " + error.getMessage()); + return; + } + } + Bugsee.notify(title, body, ReportArgs.severity((int) severity), fields, urgent); + } + + @Override + public WritableMap startTransaction( + final String name, + final String operation, + @Nullable final String attributesJson + ) { + final Map attributes = attributes(attributesJson, "startTransaction"); + final Transaction transaction = attributes == null + ? Bugsee.startTransaction(name, operation) + : Bugsee.startTransaction(name, operation, attributes); + if (transaction == null) { + return noSpan(); + } + return snapshot(spanHandles.retain(transaction, new LiveSpan(transaction)), transaction); + } + + @Override + public WritableMap startSpan(final String operation, @Nullable final String description) { + final Span span = Bugsee.startSpan(operation, description); + if (span == null) { + return noSpan(); + } + return snapshot(spanHandles.retain(span, new LiveSpan(span)), span); + } + + @Override + public WritableMap getActiveSpan() { + final Span span = Bugsee.getActiveSpan(); + if (span == null) { + return noSpan(); + } + return snapshot(spanHandles.retain(span, new LiveSpan(span)), span); + } + + @Override + public boolean spanSetName(final String handle, final String name) { + final LiveSpan live = live(handle); + if (live == null) { + return false; + } + live.span.setName(name); + return true; + } + + @Override + public boolean spanSetDescription(final String handle, @Nullable final String description) { + final LiveSpan live = live(handle); + if (live == null) { + return false; + } + live.span.setDescription(description); + return true; + } + + @Override + public boolean spanSetAttribute(final String handle, final String key, final String valueJson) { + final LiveSpan live = live(handle); + if (live == null) { + return false; + } + final Object value = jsonValue(valueJson); + if (value == null) { + Log.w(TAG, "span attribute dropped: value is not a string, number or boolean"); + return false; + } + live.span.setAttribute(key, value); + return true; + } + + @Override + public boolean spanSetStatus(final String handle, final double status) { + final LiveSpan live = live(handle); + final SpanStatus parsed = SpanHandles.status((int) status); + if (live == null || parsed == null) { + if (parsed == null) { + Log.w(TAG, "span status " + (int) status + " is outside 0..5"); + } + return false; + } + live.span.setStatus(parsed); + return true; + } + + @Override + public WritableMap spanStartChild( + final String handle, + final String operation, + @Nullable final String description + ) { + final LiveSpan live = live(handle); + if (live == null) { + return noSpan(); + } + final Span child = description == null + ? live.span.startChildSpan(operation) + : live.span.startChildSpan(operation, description); + if (child == null) { + return noSpan(); + } + return snapshot(spanHandles.retain(child, new LiveSpan(child)), child); + } + + @Override + public WritableArray spanFinish(final String handle, final double status, final boolean statusSet) { + final WritableArray released = Arguments.createArray(); + final SpanStatus parsed = statusSet ? SpanHandles.status((int) status) : null; + if (statusSet && parsed == null) { + Log.w(TAG, "span finish status " + (int) status + " is outside 0..5"); + return released; + } + for (final String id : spanHandles.finish(handle, parsed)) { + released.pushString(id); + } + return released; + } + + @Nullable + private LiveSpan live(final String handle) { + final SpanHandles.Retained retained = spanHandles.get(handle); + if (retained instanceof LiveSpan) { + return (LiveSpan) retained; + } + return null; + } + + private static WritableMap noSpan() { + final WritableMap map = Arguments.createMap(); + map.putString("handle", ""); + return map; + } + + private static WritableMap snapshot(final String handle, final Span span) { + final WritableMap map = Arguments.createMap(); + map.putString("handle", handle); + map.putString("spanId", span.getSpanId() == null ? "" : span.getSpanId()); + map.putString("traceId", span.getTraceId() == null ? "" : span.getTraceId()); + map.putString("operation", span.getOperation() == null ? "" : span.getOperation()); + if (span.getDescription() == null) { + map.putNull("description"); + } else { + map.putString("description", span.getDescription()); + } + final SpanStatus status = span.getStatus(); + map.putInt("status", status == null ? 0 : status.ordinal()); + map.putBoolean("finished", span.isFinished()); + map.putString("attributesJson", attributesJson(span.getAttributes())); + if (span instanceof Transaction) { + final Transaction transaction = (Transaction) span; + map.putString("name", transaction.getName() == null ? "" : transaction.getName()); + map.putBoolean("sampled", transaction.isSampled()); + } + return map; + } + + private static String attributesJson(@Nullable final Map attributes) { + if (attributes == null || attributes.isEmpty()) { + return "{}"; + } + final JSONObject object = new JSONObject(); + for (final Map.Entry entry : attributes.entrySet()) { + final Object value = entry.getValue(); + if (value instanceof String || value instanceof Boolean || value instanceof Number) { + try { + object.put(entry.getKey(), value); + } catch (final JSONException ignored) { + // A key the JSON writer refuses is omitted, not fatal. + } + } + } + return object.toString(); + } + + @Nullable + private static Map attributes( + @Nullable final String json, + final String method + ) { + if (json == null) { + return null; + } + try { + return BridgeJson.parseObject(json); + } catch (final BridgeJson.BadJson error) { + Log.w(TAG, method + " dropped attributes: " + error.getMessage()); + return null; + } + } + + /** One JSON value, or null when it is missing, null, an object or an array. */ + @Nullable + private static Object jsonValue(@Nullable final String json) { + if (json == null) { + return null; + } + final JSONTokener tokener = new JSONTokener(json); + final Object value; + try { + value = tokener.nextValue(); + } catch (final JSONException error) { + Log.w(TAG, "span attribute dropped: malformed JSON (" + json.length() + " characters)"); + return null; + } + if (value == null || value == JSONObject.NULL) { + return null; + } + if (value instanceof String || value instanceof Boolean) { + return value; + } + if (value instanceof Integer || value instanceof Long) { + return value; + } + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + /** The SDK span plus the registry's finish/isFinished surface. */ + private static final class LiveSpan implements SpanHandles.Retained { + final Span span; + + LiveSpan(final Span span) { + this.span = span; + } + + @Override + public void finish(@Nullable final SpanStatus status) { + if (status == null) { + span.finish(); + } else { + span.finish(status); + } + } + + @Override + public boolean isFinished() { + return span.isFinished(); + } + } } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SpanHandles.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SpanHandles.java new file mode 100644 index 00000000..c6f3d650 --- /dev/null +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SpanHandles.java @@ -0,0 +1,143 @@ +package com.bugsee.reactnative; + +import androidx.annotation.Nullable; + +import com.bugsee.library.contracts.performance.SpanStatus; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The spans the bridge is holding onto. + * + *

A span is an object with a lifetime. The registry is the strong + * reference that would leak it: {@link #finish} calls through to the span + * and then drops every entry whose span is now finished, including a child + * a parent finish cancelled. {@link #size} and {@link #contains} are what + * the tests assert, so a finish that forgets to remove the entry fails them. + */ +final class SpanHandles { + + /** What the registry needs from a span. The module adapts the SDK's {@code Span}. */ + interface Retained { + /** {@code status} null is the no-arg {@code finish}. */ + void finish(@Nullable SpanStatus status); + + boolean isFinished(); + } + + private static final class Entry { + final Object identity; + final Retained retained; + + Entry(final Object identity, final Retained retained) { + this.identity = identity; + this.retained = retained; + } + } + + private final Map byHandle = new LinkedHashMap<>(); + private final Map byIdentity = new IdentityHashMap<>(); + private int next = 1; + + /** + * Holds {@code retained} for {@code identity}. The same identity returns + * the handle already issued and does not store {@code retained} again. + * A null identity is not a span: the map would reject it, and nothing is + * stored. Synchronized like {@code CreatedReports}: {@code invalidate} + * calls {@link #releaseAll} on another thread. + */ + synchronized String retain(@Nullable final Object identity, @Nullable final Retained retained) { + if (identity == null || retained == null) { + return ""; + } + final String existing = byIdentity.get(identity); + if (existing != null) { + return existing; + } + final String handle = "sp-" + next; + next += 1; + byHandle.put(handle, new Entry(identity, retained)); + byIdentity.put(identity, handle); + return handle; + } + + /** + * Finishes {@code handle}, then drops that handle even when + * {@code isFinished()} is still false (a no-op span can leave the flag + * down). Other retained spans are dropped only when they now report + * finished. Empty when {@code handle} is not held. + */ + synchronized List finish(final String handle, @Nullable final SpanStatus status) { + final Entry entry = byHandle.get(handle); + if (entry == null) { + return Collections.emptyList(); + } + entry.retained.finish(status); + return releaseFinished(handle); + } + + /** Drops every handle. Does not finish the spans. Used when the module goes away. */ + synchronized void releaseAll() { + byHandle.clear(); + byIdentity.clear(); + } + + synchronized boolean contains(final String handle) { + return byHandle.containsKey(handle); + } + + synchronized int size() { + return byHandle.size(); + } + + @Nullable + synchronized Retained get(final String handle) { + final Entry entry = byHandle.get(handle); + return entry == null ? null : entry.retained; + } + + /** + * The SDK status for a wire integer, or null when it is outside the + * enum. The integer is the ordinal: Android's {@code SpanStatus} has no + * separate value, and iOS declares the same numbers from 0. + */ + @Nullable + static SpanStatus status(final int wire) { + final SpanStatus[] values = SpanStatus.values(); + if (wire < 0 || wire >= values.length) { + return null; + } + final SpanStatus status = values[wire]; + if (status.ordinal() != wire) { + return null; + } + return status; + } + + /** Caller holds the lock. {@code called} is removed whether or not it reports finished. */ + private List releaseFinished(final String called) { + final List released = new ArrayList<>(); + final Entry calledEntry = byHandle.remove(called); + if (calledEntry != null) { + byIdentity.remove(calledEntry.identity); + released.add(called); + } + final Iterator> entries = byHandle.entrySet().iterator(); + while (entries.hasNext()) { + final Map.Entry entry = entries.next(); + if (!entry.getValue().retained.isFinished()) { + continue; + } + released.add(entry.getKey()); + byIdentity.remove(entry.getValue().identity); + entries.remove(); + } + return released; + } +} diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SpanHandlesTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SpanHandlesTest.java new file mode 100644 index 00000000..a5086ba9 --- /dev/null +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SpanHandlesTest.java @@ -0,0 +1,158 @@ +package com.bugsee.reactnative; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.bugsee.library.contracts.performance.SpanStatus; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A finished span must leave the registry. These fail if {@code finish} + * keeps the native object: {@code contains} would still be true and + * {@code size} would not drop to zero. + */ +public class SpanHandlesTest { + + @Test + public void aFinishedSpanIsReleased() { + final SpanHandles handles = new SpanHandles(); + final Fake span = new Fake(); + final String handle = handles.retain(span, span); + final List released = handles.finish(handle, null); + assertEquals(Arrays.asList(handle), released); + assertTrue(span.finished); + assertNull(span.finishedWith); + assertFalse(handles.contains(handle)); + assertEquals(0, handles.size()); + } + + @Test + public void finishingAParentReleasesAFinishedChild() { + final SpanHandles handles = new SpanHandles(); + final Fake parent = new Fake(); + final Fake child = new Fake(); + parent.children.add(child); + final String parentHandle = handles.retain(parent, parent); + final String childHandle = handles.retain(child, child); + final List released = handles.finish(parentHandle, null); + assertTrue(released.contains(parentHandle)); + assertTrue(released.contains(childHandle)); + assertTrue(child.finished); + assertEquals(SpanStatus.CANCELLED, child.finishedWith); + assertFalse(handles.contains(childHandle)); + assertEquals(0, handles.size()); + } + + @Test + public void anUnfinishedSpanStaysRetained() { + final SpanHandles handles = new SpanHandles(); + final Fake finished = new Fake(); + final Fake live = new Fake(); + final String finishedHandle = handles.retain(finished, finished); + final String liveHandle = handles.retain(live, live); + handles.finish(finishedHandle, SpanStatus.ERROR); + assertFalse(handles.contains(finishedHandle)); + assertTrue(handles.contains(liveHandle)); + assertEquals(1, handles.size()); + assertEquals(SpanStatus.ERROR, finished.finishedWith); + } + + @Test + public void theSameSpanIsOneHandle() { + final SpanHandles handles = new SpanHandles(); + final Fake span = new Fake(); + final String first = handles.retain(span, span); + final String second = handles.retain(span, new Fake()); + assertEquals(first, second); + assertEquals(1, handles.size()); + } + + @Test + public void aSpanThatStaysUnfinishedIsStillReleased() { + final SpanHandles handles = new SpanHandles(); + final Sticky span = new Sticky(); + final String handle = handles.retain(span, span); + final List released = handles.finish(handle, null); + assertEquals(Arrays.asList(handle), released); + assertTrue(span.finishCalled); + assertFalse(span.isFinished()); + assertFalse(handles.contains(handle)); + assertEquals(0, handles.size()); + } + + @Test + public void aNullSpanIsNotRetained() { + final SpanHandles handles = new SpanHandles(); + assertEquals("", handles.retain(null, new Fake())); + assertEquals(0, handles.size()); + } + + @Test + public void anUnknownHandleReleasesNothing() { + final SpanHandles handles = new SpanHandles(); + assertTrue(handles.finish("sp-nope", null).isEmpty()); + assertEquals(0, handles.size()); + } + + @Test + public void spanStatusWireIsTheOrdinal() { + assertEquals(SpanStatus.OK, SpanHandles.status(0)); + assertEquals(SpanStatus.ERROR, SpanHandles.status(1)); + assertEquals(SpanStatus.TIMEOUT, SpanHandles.status(2)); + assertEquals(SpanStatus.CANCELLED, SpanHandles.status(3)); + assertEquals(SpanStatus.DEADLINE_EXCEEDED, SpanHandles.status(4)); + assertEquals(SpanStatus.UNKNOWN, SpanHandles.status(5)); + assertNull(SpanHandles.status(-1)); + assertNull(SpanHandles.status(6)); + assertEquals(0, SpanStatus.OK.ordinal()); + assertEquals(5, SpanStatus.UNKNOWN.ordinal()); + } + + /** A stand-in for the SDK span. Finishing a parent cancels its children. */ + private static final class Fake implements SpanHandles.Retained { + boolean finished; + SpanStatus finishedWith; + final List children = new ArrayList<>(); + + @Override + public void finish(final SpanStatus status) { + if (finished) { + return; + } + finished = true; + finishedWith = status; + for (final Fake child : children) { + if (!child.finished) { + child.finish(SpanStatus.CANCELLED); + } + } + } + + @Override + public boolean isFinished() { + return finished; + } + } + + /** {@code finish} runs, and {@code isFinished} stays false. A no-op span can do that. */ + private static final class Sticky implements SpanHandles.Retained { + boolean finishCalled; + + @Override + public void finish(final SpanStatus status) { + finishCalled = true; + } + + @Override + public boolean isFinished() { + return false; + } + } +} diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 12971ba6..10f20724 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -8,6 +8,7 @@ // cascade of undeclared identifiers that hides the real cause. #import #import +#import // CocoaPods compiles BugseeRNSupport's sources straight into this pod, so its // headers arrive flat; under SPM it is a separate target and they arrive under @@ -35,6 +36,7 @@ #import #import #import +#import #else #import "BGSRNMainThread.h" #import "BGSRNWrapper.h" @@ -58,6 +60,7 @@ #import "BGSRNReportArgs.h" #import "BGSRNCreatedReports.h" #import "BGSRNCreatedReportOps.h" +#import "BGSRNSpanHandles.h" #endif /// The conformance lives here rather than in the Support package so that the @@ -690,7 +693,109 @@ static BOOL BGSRNApplyBreadcrumbKeep(id breadcrumb, NSDictionary return YES; } -@implementation BugseeModule +@interface BGSRNLiveSpan : NSObject +@property (nonatomic, readonly) id span; +- (instancetype)initWithSpan:(id)span; +@end + +@implementation BGSRNLiveSpan { + id _span; +} + +- (instancetype)initWithSpan:(id)span { + self = [super init]; + if (self != nil) { + _span = span; + } + return self; +} + +- (id)span { + return _span; +} + +- (void)bgsrnFinishWithStatus:(NSNumber *)status { + if (status == nil) { + [_span finish]; + return; + } + [_span finishWithStatus:(BGSSpanStatus)status.integerValue]; +} + +- (BOOL)bgsrnIsFinished { + return _span.isFinished; +} + +@end + +/// One JSON value, or nil when it is missing, null, an object or an array. +static id BGSRNJSONScalar(NSString *json) { + if (json == nil) { + return nil; + } + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) { + return nil; + } + NSError *error = nil; + id value = [NSJSONSerialization JSONObjectWithData:data + options:NSJSONReadingFragmentsAllowed + error:&error]; + if (error != nil || value == nil || value == [NSNull null]) { + return nil; + } + if ([value isKindOfClass:[NSDictionary class]] || [value isKindOfClass:[NSArray class]]) { + return nil; + } + return value; +} + +static NSString *BGSRNAttributesJSON(NSDictionary *attributes) { + if (attributes == nil || attributes.count == 0) { + return @"{}"; + } + if (![NSJSONSerialization isValidJSONObject:attributes]) { + return @"{}"; + } + NSData *data = [NSJSONSerialization dataWithJSONObject:attributes options:0 error:nil]; + if (data == nil) { + return @"{}"; + } + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}"; +} + +static NSDictionary *BGSRNSpanSnapshot(NSString *handle, id span) { + NSMutableDictionary *map = [NSMutableDictionary dictionary]; + map[@"handle"] = handle ?: @""; + map[@"spanId"] = span.spanId ?: @""; + map[@"traceId"] = span.traceId ?: @""; + map[@"operation"] = span.operation ?: @""; + map[@"description"] = span.spanDescription ?: [NSNull null]; + map[@"status"] = @((NSInteger)span.status); + map[@"finished"] = @(span.isFinished); + map[@"attributesJson"] = BGSRNAttributesJSON(span.attributes); + if ([span conformsToProtocol:@protocol(BGSTransaction)]) { + id transaction = (id)span; + map[@"name"] = transaction.name ?: @""; + map[@"sampled"] = @(transaction.isSampled); + } + return map; +} + +static NSDictionary *BGSRNNoSpan(void) { + return @{@"handle": @""}; +} + +@interface BugseeModule () +@property (nonatomic, strong) BGSRNSpanHandles *spanHandles; +@end + +@implementation BugseeModule { + os_unfair_lock _spanRegistryLock; + BOOL _spansRetired; +} + +@synthesize spanHandles = _spanHandles; RCT_EXPORT_MODULE(Bugsee) @@ -701,6 +806,7 @@ @implementation BugseeModule /// emitter is unset before then, and this call does not emit. - (instancetype)init { if ((self = [super init])) { + _spanRegistryLock = OS_UNFAIR_LOCK_INIT; BGSRNSetWrapper((id)[BGSRNWrapper wrapperWithoutJsRuntime], YES); BGSRNInstallConsoleCapture(); BGSRNInstallLogEventFilter(); @@ -822,6 +928,16 @@ - (void)invalidate { if (BGSRNBreadcrumbFilterModule == self) { BGSRNBreadcrumbFilterModule = nil; } + // The next runtime cannot know these handles. Drop them without finishing: + // a reload must not close a transaction the SDK still has. The getter + // must not allocate a fresh registry after this: a span call still in + // flight would adopt into an object invalidate already abandoned. + os_unfair_lock_lock(&_spanRegistryLock); + _spansRetired = YES; + BGSRNSpanHandles *previous = _spanHandles; + _spanHandles = [BGSRNSpanHandles closedRegistry]; + os_unfair_lock_unlock(&_spanRegistryLock); + [previous releaseAll]; } /// The SDK touches UIKit during start-up, so it must not be constructed on a @@ -1944,6 +2060,207 @@ - (void)createdReportUpload:(NSString *)handleId }); } +- (BGSRNSpanHandles *)spanHandles { + os_unfair_lock_lock(&_spanRegistryLock); + BGSRNSpanHandles *handles = _spansRetired ? [BGSRNSpanHandles closedRegistry] : _spanHandles; + if (handles == nil) { + handles = [BGSRNSpanHandles new]; + _spanHandles = handles; + } + os_unfair_lock_unlock(&_spanRegistryLock); + return handles; +} + +- (BGSRNLiveSpan *)liveSpan:(NSString *)handle { + id adapter = [self.spanHandles adapterForHandle:handle]; + return [adapter isKindOfClass:[BGSRNLiveSpan class]] ? adapter : nil; +} + +- (NSDictionary *)adoptSpan:(id)span { + if (span == nil) { + return BGSRNNoSpan(); + } + BGSRNLiveSpan *adapter = [[BGSRNLiveSpan alloc] initWithSpan:span]; + NSString *handle = [self.spanHandles retainSpan:span adapter:adapter]; + return BGSRNSpanSnapshot(handle, span); +} + +/// On main, synchronously. The active span is pthread-local and does not +/// cross dispatch_async, and every span call has to see the same thread. +- (void)notify:(NSString *)title + body:(NSString *)body + severity:(double)severity + fieldsJson:(NSString *)fieldsJson + urgent:(BOOL)urgent { + NSDictionary *parsed = nil; + if (fieldsJson != nil) { + NSError *error = nil; + parsed = BGSRNJSONObject(fieldsJson, &error); + if (parsed == nil) { + NSLog(@"BugseeRN notify dropped: %@", error); + return; + } + } + NSMutableDictionary *fields = nil; + if (parsed != nil) { + fields = [NSMutableDictionary dictionary]; + for (NSString *key in parsed) { + id value = parsed[key]; + if ([value isKindOfClass:[NSString class]]) { + fields[key] = value; + } + } + } + NSInteger wire = (NSInteger)llround(severity); + BGSRNRunOnMain(^{ + [Bugsee notifyWithTitle:title + body:body + severity:(BugseeSeverityLevel)wire + fields:fields + urgent:urgent]; + }); +} + +- (NSDictionary *)startTransaction:(NSString *)name + operation:(NSString *)operation + attributesJson:(NSString *)attributesJson { + __block NSDictionary *snapshot = nil; + BGSRNRunOnMainSync(^{ + NSDictionary *attributes = nil; + if (attributesJson != nil) { + NSError *error = nil; + attributes = BGSRNJSONObject(attributesJson, &error); + if (attributes == nil) { + NSLog(@"BugseeRN startTransaction dropped attributes: %@", error); + } + } + id transaction = attributes == nil + ? [Bugsee startTransactionWithName:name operation:operation] + : [Bugsee startTransactionWithName:name operation:operation attributes:attributes]; + snapshot = [self adoptSpan:transaction]; + }); + return snapshot; +} + +- (NSDictionary *)startSpan:(NSString *)operation description:(NSString *)description { + __block NSDictionary *snapshot = nil; + BGSRNRunOnMainSync(^{ + snapshot = [self adoptSpan:[Bugsee startSpanWithOperation:operation description:description]]; + }); + return snapshot; +} + +- (NSDictionary *)getActiveSpan { + __block NSDictionary *snapshot = nil; + BGSRNRunOnMainSync(^{ + snapshot = [self adoptSpan:[Bugsee getActiveSpan]]; + }); + return snapshot; +} + +/// `NSNumber`, not void: codegen queues a void method, so a setter then +/// `spanFinish` in one turn would release the handle before the setter ran. +/// The hop stays synchronous so the active span's thread is still main. +/// Codegen's boolean is `NSNumber *`, the same as `addBreadcrumb`. +- (NSNumber *)spanSetName:(NSString *)handle name:(NSString *)name { + __block BOOL applied = NO; + BGSRNRunOnMainSync(^{ + BGSRNLiveSpan *live = [self liveSpan:handle]; + if (live == nil) { + return; + } + [live.span setName:name]; + applied = YES; + }); + return @(applied); +} + +- (NSNumber *)spanSetDescription:(NSString *)handle description:(NSString *)description { + __block BOOL applied = NO; + BGSRNRunOnMainSync(^{ + BGSRNLiveSpan *live = [self liveSpan:handle]; + if (live == nil) { + return; + } + [live.span setSpanDescription:description]; + applied = YES; + }); + return @(applied); +} + +- (NSNumber *)spanSetAttribute:(NSString *)handle key:(NSString *)key valueJson:(NSString *)valueJson { + __block BOOL applied = NO; + BGSRNRunOnMainSync(^{ + id value = BGSRNJSONScalar(valueJson); + if (value == nil) { + NSLog(@"BugseeRN span attribute dropped: value is not a string, number or boolean"); + return; + } + BGSRNLiveSpan *live = [self liveSpan:handle]; + if (live == nil) { + return; + } + [live.span setAttribute:key value:value]; + applied = YES; + }); + return @(applied); +} + +- (NSNumber *)spanSetStatus:(NSString *)handle status:(double)status { + __block BOOL applied = NO; + BGSRNRunOnMainSync(^{ + NSInteger wire = (NSInteger)llround(status); + if (wire < BGSSpanStatusOK || wire > BGSSpanStatusUnknown) { + NSLog(@"BugseeRN span status %ld is outside 0..5", (long)wire); + return; + } + BGSRNLiveSpan *live = [self liveSpan:handle]; + if (live == nil) { + return; + } + [live.span setStatus:(BGSSpanStatus)wire]; + applied = YES; + }); + return @(applied); +} + +- (NSDictionary *)spanStartChild:(NSString *)handle + operation:(NSString *)operation + description:(NSString *)description { + __block NSDictionary *snapshot = nil; + BGSRNRunOnMainSync(^{ + BGSRNLiveSpan *live = [self liveSpan:handle]; + if (live == nil) { + snapshot = BGSRNNoSpan(); + return; + } + id child = description == nil + ? [live.span startChildSpanWithOperation:operation] + : [live.span startChildSpanWithOperation:operation description:description]; + snapshot = [self adoptSpan:child]; + }); + return snapshot; +} + +- (NSArray *)spanFinish:(NSString *)handle + status:(double)status + statusSet:(BOOL)statusSet { + __block NSArray *released = @[]; + BGSRNRunOnMainSync(^{ + NSNumber *wire = nil; + if (statusSet) { + NSInteger value = (NSInteger)llround(status); + if (value < BGSSpanStatusOK || value > BGSSpanStatusUnknown) { + NSLog(@"BugseeRN span finish status %ld is outside 0..5", (long)value); + return; + } + wire = @(value); + } + released = [self.spanHandles finishHandle:handle status:wire]; + }); + return released; +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { auto module = std::make_shared(params); diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSpanHandles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSpanHandles.m new file mode 100644 index 00000000..9f7638dc --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSpanHandles.m @@ -0,0 +1,136 @@ +#import "BGSRNSpanHandles.h" + +#import + +@interface BGSRNSpanEntry : NSObject +@property (nonatomic, strong) id identity; +@property (nonatomic, strong) id adapter; +@end + +@implementation BGSRNSpanEntry +@end + +@implementation BGSRNSpanHandles { + os_unfair_lock _lock; + BOOL _closed; + NSMutableDictionary *_byHandle; + NSMapTable *_byIdentity; + NSUInteger _next; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _byHandle = [NSMutableDictionary dictionary]; + _byIdentity = [NSMapTable strongToStrongObjectsMapTable]; + _next = 1; + } + return self; +} + +/// After invalidate. `retainSpan` stores nothing. ++ (BGSRNSpanHandles *)closedRegistry { + static BGSRNSpanHandles *closed; + static dispatch_once_t once; + dispatch_once(&once, ^{ + closed = [self new]; + closed->_closed = YES; + }); + return closed; +} + +- (NSString *)retainSpan:(id)span adapter:(id)adapter { + os_unfair_lock_lock(&_lock); + NSString *handle = @""; + if (!_closed && span != nil && adapter != nil) { + NSString *existing = [_byIdentity objectForKey:span]; + if (existing != nil) { + handle = existing; + } else { + handle = [NSString stringWithFormat:@"sp-%lu", (unsigned long)_next]; + _next += 1; + BGSRNSpanEntry *entry = [BGSRNSpanEntry new]; + entry.identity = span; + entry.adapter = adapter; + _byHandle[handle] = entry; + [_byIdentity setObject:handle forKey:span]; + } + } + os_unfair_lock_unlock(&_lock); + return handle; +} + +- (NSArray *)finishHandle:(NSString *)handle status:(NSNumber *)status { + // The adapter's finish is outside the lock: os_unfair_lock does not + // re-enter, and a parent finish runs the child's finish on this thread. + os_unfair_lock_lock(&_lock); + BGSRNSpanEntry *entry = (_closed || handle == nil) ? nil : _byHandle[handle]; + id adapter = entry.adapter; + os_unfair_lock_unlock(&_lock); + if (adapter == nil) { + return @[]; + } + [adapter bgsrnFinishWithStatus:status]; + os_unfair_lock_lock(&_lock); + NSArray *released = _closed ? @[] : [self dropCalled:handle]; + os_unfair_lock_unlock(&_lock); + return released; +} + +- (void)releaseAll { + os_unfair_lock_lock(&_lock); + _closed = YES; + [_byHandle removeAllObjects]; + [_byIdentity removeAllObjects]; + os_unfair_lock_unlock(&_lock); +} + +- (BOOL)containsHandle:(NSString *)handle { + os_unfair_lock_lock(&_lock); + const BOOL contains = handle != nil && _byHandle[handle] != nil; + os_unfair_lock_unlock(&_lock); + return contains; +} + +- (NSUInteger)liveCount { + os_unfair_lock_lock(&_lock); + const NSUInteger count = _byHandle.count; + os_unfair_lock_unlock(&_lock); + return count; +} + +- (id)adapterForHandle:(NSString *)handle { + os_unfair_lock_lock(&_lock); + BGSRNSpanEntry *entry = (_closed || handle == nil) ? nil : _byHandle[handle]; + id adapter = entry.adapter; + os_unfair_lock_unlock(&_lock); + return adapter; +} + +/// Caller holds the lock. `called` is removed whether or not it reports finished. +- (NSArray *)dropCalled:(NSString *)called { + NSMutableArray *released = [NSMutableArray array]; + BGSRNSpanEntry *calledEntry = called == nil ? nil : _byHandle[called]; + if (calledEntry != nil) { + [_byIdentity removeObjectForKey:calledEntry.identity]; + [_byHandle removeObjectForKey:called]; + [released addObject:called]; + } + NSMutableArray *finished = [NSMutableArray array]; + for (NSString *handle in _byHandle) { + BGSRNSpanEntry *entry = _byHandle[handle]; + if ([entry.adapter bgsrnIsFinished]) { + [finished addObject:handle]; + } + } + for (NSString *handle in finished) { + BGSRNSpanEntry *entry = _byHandle[handle]; + [_byIdentity removeObjectForKey:entry.identity]; + [_byHandle removeObjectForKey:handle]; + [released addObject:handle]; + } + return released; +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSpanHandles.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSpanHandles.h new file mode 100644 index 00000000..99ae663b --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSpanHandles.h @@ -0,0 +1,49 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * The little surface the registry needs from a span. The module adapts + * `id` to this so the registry, and its leak test, do not import + * the SDK's span type. + */ +@protocol BGSRNRetainedSpan +/// `status` nil is the no-arg finish. +- (void)bgsrnFinishWithStatus:(NSNumber *_Nullable)status; +- (BOOL)bgsrnIsFinished; +@end + +/** + * The spans the bridge is holding onto. + * + * A span is an object with a lifetime. This registry is the strong + * reference that would leak it: `finishHandle` calls through to the span + * and then drops that handle even when it still reports unfinished, plus + * any other span that now reports finished. `liveCount` and `containsHandle` are what the + * tests assert, so a finish that forgets to remove the entry fails them. + */ +@interface BGSRNSpanHandles : NSObject + +/// Holds `adapter` for `span`. The same `span` returns the handle already +/// issued and does not store `adapter` again. +- (NSString *)retainSpan:(id)span adapter:(id)adapter; + +/// Finishes `handle`, then drops that handle even when it still reports +/// unfinished. Other retained spans are dropped only when they now report +/// finished. Empty when `handle` is not held. +- (NSArray *)finishHandle:(NSString *)handle + status:(NSNumber *_Nullable)status; + +/// Drops every handle and refuses later retains. Does not finish the spans. +- (void)releaseAll; + +/// The registry `invalidate` hands out. It retains nothing. ++ (BGSRNSpanHandles *)closedRegistry; + +- (BOOL)containsHandle:(NSString *)handle; +- (NSUInteger)liveCount; +- (nullable id)adapterForHandle:(NSString *)handle; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSpanHandlesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSpanHandlesTests.m new file mode 100644 index 00000000..c6362985 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSpanHandlesTests.m @@ -0,0 +1,153 @@ +@import XCTest; +@import Bugsee; +@import BugseeRNSupport; + +@interface BGSRNFakeSpan : NSObject +@property (nonatomic) BOOL finished; +@property (nonatomic, strong) NSNumber *finishedWith; +@property (nonatomic, strong) NSMutableArray *children; +@end + +@implementation BGSRNFakeSpan + +- (instancetype)init { + self = [super init]; + if (self) { + _children = [NSMutableArray array]; + } + return self; +} + +- (void)bgsrnFinishWithStatus:(NSNumber *)status { + if (_finished) { + return; + } + _finished = YES; + _finishedWith = status; + for (BGSRNFakeSpan *child in _children) { + if (!child.finished) { + [child bgsrnFinishWithStatus:@(BGSSpanStatusCancelled)]; + } + } +} + +- (BOOL)bgsrnIsFinished { + return _finished; +} + +@end + +@interface BGSRNStickySpan : NSObject +@property (nonatomic) BOOL finishCalled; +@end + +@implementation BGSRNStickySpan +- (void)bgsrnFinishWithStatus:(NSNumber *)status { + _finishCalled = YES; +} +- (BOOL)bgsrnIsFinished { + return NO; +} +@end + +@interface BGSRNSpanHandlesTests : XCTestCase +@end + +@implementation BGSRNSpanHandlesTests + +/// The pin: after finish the registry must not still hold the span. +- (void)testAFinishedSpanIsReleased { + BGSRNSpanHandles *handles = [BGSRNSpanHandles new]; + BGSRNFakeSpan *span = [BGSRNFakeSpan new]; + NSString *handle = [handles retainSpan:span adapter:span]; + NSArray *released = [handles finishHandle:handle status:nil]; + XCTAssertEqualObjects(released, @[handle]); + XCTAssertTrue(span.finished); + XCTAssertNil(span.finishedWith); + XCTAssertFalse([handles containsHandle:handle]); + XCTAssertEqual(handles.liveCount, 0u); +} + +- (void)testFinishingAParentReleasesAFinishedChild { + BGSRNSpanHandles *handles = [BGSRNSpanHandles new]; + BGSRNFakeSpan *parent = [BGSRNFakeSpan new]; + BGSRNFakeSpan *child = [BGSRNFakeSpan new]; + [parent.children addObject:child]; + NSString *parentHandle = [handles retainSpan:parent adapter:parent]; + NSString *childHandle = [handles retainSpan:child adapter:child]; + NSArray *released = [handles finishHandle:parentHandle status:nil]; + XCTAssertTrue([released containsObject:parentHandle]); + XCTAssertTrue([released containsObject:childHandle]); + XCTAssertTrue(child.finished); + XCTAssertEqual(child.finishedWith.integerValue, (NSInteger)BGSSpanStatusCancelled); + XCTAssertFalse([handles containsHandle:childHandle]); + XCTAssertEqual(handles.liveCount, 0u); +} + +- (void)testAnUnfinishedSpanStaysRetained { + BGSRNSpanHandles *handles = [BGSRNSpanHandles new]; + BGSRNFakeSpan *finished = [BGSRNFakeSpan new]; + BGSRNFakeSpan *live = [BGSRNFakeSpan new]; + NSString *finishedHandle = [handles retainSpan:finished adapter:finished]; + NSString *liveHandle = [handles retainSpan:live adapter:live]; + [handles finishHandle:finishedHandle status:@(BGSSpanStatusError)]; + XCTAssertFalse([handles containsHandle:finishedHandle]); + XCTAssertTrue([handles containsHandle:liveHandle]); + XCTAssertEqual(handles.liveCount, 1u); + XCTAssertEqual(finished.finishedWith.integerValue, (NSInteger)BGSSpanStatusError); +} + +- (void)testTheSameSpanIsOneHandle { + BGSRNSpanHandles *handles = [BGSRNSpanHandles new]; + BGSRNFakeSpan *span = [BGSRNFakeSpan new]; + NSString *first = [handles retainSpan:span adapter:span]; + NSString *second = [handles retainSpan:span adapter:[BGSRNFakeSpan new]]; + XCTAssertEqualObjects(first, second); + XCTAssertEqual(handles.liveCount, 1u); +} + +/// `finish` runs, and `isFinished` stays false. A no-op span can do that. +- (void)testASpanThatStaysUnfinishedIsStillReleased { + BGSRNSpanHandles *handles = [BGSRNSpanHandles new]; + BGSRNStickySpan *span = [BGSRNStickySpan new]; + NSString *handle = [handles retainSpan:span adapter:span]; + NSArray *released = [handles finishHandle:handle status:nil]; + XCTAssertEqualObjects(released, @[handle]); + XCTAssertTrue(span.finishCalled); + XCTAssertFalse(span.bgsrnIsFinished); + XCTAssertFalse([handles containsHandle:handle]); + XCTAssertEqual(handles.liveCount, 0u); +} + +- (void)testANilSpanIsNotRetained { + BGSRNSpanHandles *handles = [BGSRNSpanHandles new]; + XCTAssertEqualObjects([handles retainSpan:nil adapter:[BGSRNFakeSpan new]], @""); + XCTAssertEqual(handles.liveCount, 0u); +} + +/// The object `invalidate` returns. A later retain must not store anything. +- (void)testAClosedRegistryAdoptsNothing { + BGSRNSpanHandles *handles = [BGSRNSpanHandles closedRegistry]; + BGSRNFakeSpan *span = [BGSRNFakeSpan new]; + XCTAssertEqualObjects([handles retainSpan:span adapter:span], @""); + XCTAssertEqual(handles.liveCount, 0u); + XCTAssertEqualObjects([handles finishHandle:@"sp-1" status:nil], @[]); +} + +- (void)testAnUnknownHandleReleasesNothing { + BGSRNSpanHandles *handles = [BGSRNSpanHandles new]; + XCTAssertEqualObjects([handles finishHandle:@"sp-nope" status:nil], @[]); + XCTAssertEqual(handles.liveCount, 0u); +} + +/// The wire integer is the SDK enum, not a second numbering. +- (void)testSpanStatusValuesMatchTheWire { + XCTAssertEqual((NSInteger)BGSSpanStatusOK, 0); + XCTAssertEqual((NSInteger)BGSSpanStatusError, 1); + XCTAssertEqual((NSInteger)BGSSpanStatusTimeout, 2); + XCTAssertEqual((NSInteger)BGSSpanStatusCancelled, 3); + XCTAssertEqual((NSInteger)BGSSpanStatusDeadlineExceeded, 4); + XCTAssertEqual((NSInteger)BGSSpanStatusUnknown, 5); +} + +@end diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index 926d99ad..638013e6 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -463,6 +463,66 @@ export interface Spec extends TurboModule { logException(payloadJson: string, optionsJson: string | null): void; /** An unhandled JS exception. Resolves once the SDK has the report; never rejects. */ logUnhandledException(payloadJson: string): Promise; + + /** + * Queues a notification for the app's messaging integrations. Not a bug + * report. `severity` `0` means unset (Android passes null; iOS passes 0, + * which the SDK omits). `fieldsJson` is a JSON object of strings, or null. + * `urgent` false is the shorter overloads. Does nothing useful before + * launch; the SDK ignores it. + */ + notify( + title: string, + body: string | null, + severity: number, + fieldsJson: string | null, + urgent: boolean, + ): void; + + /** + * Starts a transaction and retains it. The snapshot's `handle` is the + * bridge id. `attributesJson` null is the two-argument SDK overload. + * Calls stay on one thread: the active span is thread-local on both SDKs. + */ + startTransaction( + name: string, + operation: string, + attributesJson: string | null, + ): UnsafeObject; + /** Starts a span under the active span on this thread and retains it. */ + startSpan(operation: string, description: string | null): UnsafeObject; + /** + * The active span on this thread, or a snapshot whose `handle` is `''` + * when there is none. A span this bridge already holds comes back with + * the same handle. + */ + getActiveSpan(): UnsafeObject; + /** + * `setName`, which sets the operation on both SDKs. + * + * Returns whether the live span accepted the value. The return is what + * keeps the call on the JS thread: codegen queues a `void` TurboModule + * method, and a setter then `spanFinish` in one turn would release the + * handle before the setter ran. Same for the three setters below. + */ + spanSetName(handle: string, name: string): boolean; + spanSetDescription(handle: string, description: string | null): boolean; + /** `valueJson` is one JSON value: a string, number or boolean. */ + spanSetAttribute(handle: string, key: string, valueJson: string): boolean; + spanSetStatus(handle: string, status: number): boolean; + spanStartChild( + handle: string, + operation: string, + description: string | null, + ): UnsafeObject; + /** + * Finishes the span and releases every retained span that is now + * finished, including children a parent finish cancelled. `statusSet` + * false is the no-arg `finish` (status OK). The returned handles are + * the ones dropped. Empty means the handle was already gone. The name + * is `statusSet` because `explicit` is a keyword in the ObjC++ spec. + */ + spanFinish(handle: string, status: number, statusSet: boolean): string[]; } export default TurboModuleRegistry.getEnforcing('Bugsee'); diff --git a/packages/react-native/src/__mocks__/native.ts b/packages/react-native/src/__mocks__/native.ts index 47f9737a..928d2208 100644 --- a/packages/react-native/src/__mocks__/native.ts +++ b/packages/react-native/src/__mocks__/native.ts @@ -342,6 +342,17 @@ export const native = { /** Resolves by default; tests that hang native replace the implementation. */ logUnhandledException: jest.fn, [string]>(), + notify: jest.fn(), + startTransaction: jest.fn, [string, string, string | null]>(), + startSpan: jest.fn, [string, string | null]>(), + getActiveSpan: jest.fn, []>(), + spanSetName: jest.fn(), + spanSetDescription: jest.fn(), + spanSetAttribute: jest.fn(), + spanSetStatus: jest.fn(), + spanStartChild: jest.fn, [string, string, string | null]>(), + spanFinish: jest.fn(), + /** * Resets every mock on this object, found rather than listed. * diff --git a/packages/react-native/src/__tests__/notify.test.ts b/packages/react-native/src/__tests__/notify.test.ts new file mode 100644 index 00000000..342e1216 --- /dev/null +++ b/packages/react-native/src/__tests__/notify.test.ts @@ -0,0 +1,103 @@ +// The package entry now exports the options model, which reads Platform.OS, +// so importing it loads `react-native` -- which jest cannot parse. Mocked to a +// known platform; this suite is about `notify`, not about which one. +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); +jest.mock('../NativeBugsee', () => require('../__mocks__/native').nativeMock); + +import Bugsee, { IssueSeverity } from '../index'; +import { notify as notifyDirect } from '../apm/notify'; +import { jsonOf, native } from '../__mocks__/native'; + +beforeEach(() => native.reset()); + +describe('notify', () => { + it('the full form crosses title, body, severity by value, fields and urgent', () => { + const fields = { nonce: 'abc', lane: 'relay' }; + Bugsee.notify('title', 'body', IssueSeverity.Critical, fields, true); + expect(native.notify).toHaveBeenCalledWith( + 'title', + 'body', + 4, + jsonOf({ nonce: 'abc', lane: 'relay' }), + true, + ); + fields.nonce = 'changed'; + const crossed = native.notify.mock.calls[0]?.[3]; + expect(JSON.parse(crossed as string)).toEqual({ nonce: 'abc', lane: 'relay' }); + }); + + it('title alone crosses null body, severity 0, null fields and urgent false', () => { + Bugsee.notify('title'); + expect(native.notify).toHaveBeenCalledWith('title', null, 0, null, false); + }); + + it('null body, null fields, an empty body and urgent false cross as such', () => { + Bugsee.notify('title', null, undefined, null, false); + expect(native.notify).toHaveBeenCalledWith('title', null, 0, null, false); + native.notify.mockClear(); + Bugsee.notify('title', '', undefined, {}); + expect(native.notify).toHaveBeenCalledWith('title', '', 0, jsonOf({}), false); + }); + + it('rejects an empty or non-string title before crossing', () => { + const message = 'Bugsee.notify requires a non-empty title'; + expect(() => Bugsee.notify('')).toThrow(message); + expect(() => Bugsee.notify(' ')).toThrow(message); + expect(() => Bugsee.notify(1 as unknown as string)).toThrow(message); + expect(native.notify).not.toHaveBeenCalled(); + }); + + it('rejects severity 0, 6 and a string before crossing', () => { + const message = 'Bugsee.notify severity must be an integer 1..5'; + expect(() => Bugsee.notify('t', 'b', 0 as unknown as IssueSeverity)).toThrow(RangeError); + expect(() => Bugsee.notify('t', 'b', 0 as unknown as IssueSeverity)).toThrow(message); + expect(() => Bugsee.notify('t', 'b', 6 as unknown as IssueSeverity)).toThrow(RangeError); + expect(() => Bugsee.notify('t', 'b', 'high' as unknown as IssueSeverity)).toThrow(TypeError); + expect(() => Bugsee.notify('t', 'b', 'high' as unknown as IssueSeverity)).toThrow(message); + expect(native.notify).not.toHaveBeenCalled(); + }); + + it('rejects a field that is not a string before crossing', () => { + const message = 'Bugsee.notify fields must be an object of strings'; + expect(() => + Bugsee.notify('t', 'b', undefined, { n: 1 } as unknown as Record), + ).toThrow(message); + expect(() => Bugsee.notify('t', null, undefined, [] as unknown as Record)).toThrow( + message, + ); + expect(() => Bugsee.notify('t', null, undefined, 'x' as unknown as Record)).toThrow( + message, + ); + expect(native.notify).not.toHaveBeenCalled(); + }); + + it('rejects a non-string body before crossing', () => { + expect(() => Bugsee.notify('t', 1 as unknown as string)).toThrow('Bugsee.notify body must be a string'); + expect(native.notify).not.toHaveBeenCalled(); + }); + + it('rejects a non-boolean urgent before crossing', () => { + expect(() => + Bugsee.notify('t', 'b', undefined, undefined, 'yes' as unknown as boolean), + ).toThrow('Bugsee.notify urgent must be a boolean'); + expect(native.notify).not.toHaveBeenCalled(); + }); + + it('a sixth argument throws TypeError and crosses nothing', () => { + const message = 'Bugsee.notify takes at most five arguments'; + expect(() => + (Bugsee.notify as (...args: unknown[]) => void)( + 't', + 'b', + IssueSeverity.High, + null, + false, + 'extra', + ), + ).toThrow(message); + expect(() => + (notifyDirect as (...args: unknown[]) => void)('t', 'b', undefined, null, false, 'extra'), + ).toThrow(message); + expect(native.notify).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native/src/__tests__/span.test.ts b/packages/react-native/src/__tests__/span.test.ts new file mode 100644 index 00000000..1e848a99 --- /dev/null +++ b/packages/react-native/src/__tests__/span.test.ts @@ -0,0 +1,529 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// The package entry now exports the options model, which reads Platform.OS, +// so importing it loads `react-native` -- which jest cannot parse. Mocked to a +// known platform; this suite is about spans, not about which one. +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); +jest.mock('../NativeBugsee', () => require('../__mocks__/native').nativeMock); + +import Bugsee, { BugseeSpanError, SpanErrorCode, SpanStatus } from '../index'; +import { jsonOf, native } from '../__mocks__/native'; + +beforeEach(() => native.reset()); + +function wire(overrides: Record = {}): Record { + return { + handle: 'sp-1', + spanId: 'span-1', + traceId: 'trace-1', + operation: 'user.flow', + description: null, + status: SpanStatus.OK, + finished: false, + attributesJson: '{}', + ...overrides, + }; +} + +let handleSeq = 0; + +function freshHandle(): string { + handleSeq += 1; + return `sp-m-${handleSeq}`; +} + +const DEAD = { + name: 'BugseeSpanError', + code: 'E_SPAN_HANDLE_DEAD', + message: 'Bugsee span handle is dead; finish already released it', +}; + +describe('spans', () => { + it('startTransaction crosses name, operation and null attributes', () => { + native.startTransaction.mockReturnValue( + wire({ name: 'checkout', sampled: true, operation: 'user.flow' }), + ); + const txn = Bugsee.startTransaction('checkout', 'user.flow'); + expect(native.startTransaction).toHaveBeenCalledWith('checkout', 'user.flow', null); + expect(txn.name).toBe('checkout'); + expect(txn.sampled).toBe(true); + expect(txn.spanId).toBe('span-1'); + expect(txn.operation).toBe('user.flow'); + expect(txn.finished).toBe(false); + }); + + it('startTransaction crosses attributes as JSON text', () => { + native.startTransaction.mockReturnValue(wire({ name: 'checkout', sampled: false })); + Bugsee.startTransaction('checkout', 'user.flow', { retries: 2, ok: true, lane: 'a' }); + expect(native.startTransaction).toHaveBeenCalledWith( + 'checkout', + 'user.flow', + jsonOf({ retries: 2, ok: true, lane: 'a' }), + ); + }); + + it('startSpan crosses operation and description', () => { + native.startSpan.mockReturnValue(wire({ handle: 'sp-2', operation: 'db.query', description: 'users' })); + const span = Bugsee.startSpan('db.query', 'users'); + expect(native.startSpan).toHaveBeenCalledWith('db.query', 'users'); + expect(span.operation).toBe('db.query'); + expect(span.description).toBe('users'); + expect('name' in span).toBe(false); + }); + + it('getActiveSpan is null when native has no handle', () => { + native.getActiveSpan.mockReturnValue(wire({ handle: '' })); + expect(Bugsee.getActiveSpan()).toBeNull(); + }); + + it('getActiveSpan returns the span already held for that handle', () => { + native.startTransaction.mockReturnValue(wire({ name: 'checkout', sampled: true })); + native.getActiveSpan.mockReturnValue(wire({ name: 'checkout', sampled: true })); + const txn = Bugsee.startTransaction('checkout', 'user.flow'); + expect(Bugsee.getActiveSpan()).toBe(txn); + }); + + it('finish releases the handle and a second finish does not cross', () => { + native.startSpan.mockReturnValue(wire()); + native.spanFinish.mockReturnValue(['sp-1']); + const span = Bugsee.startSpan('db.query'); + span.finish(); + expect(native.spanFinish).toHaveBeenCalledTimes(1); + expect(native.spanFinish).toHaveBeenCalledWith('sp-1', 0, false); + expect(span.finished).toBe(true); + expect(() => span.finish()).toThrow(expect.objectContaining({ code: 'E_SPAN_HANDLE_DEAD' })); + expect(native.spanFinish).toHaveBeenCalledTimes(1); + expect(() => span.setName('later')).toThrow( + expect.objectContaining({ code: 'E_SPAN_HANDLE_DEAD' }), + ); + expect(native.spanSetName).not.toHaveBeenCalled(); + }); + + it('finish(status) crosses the status by value', () => { + native.startSpan.mockReturnValue(wire()); + native.spanFinish.mockReturnValue(['sp-1']); + Bugsee.startSpan('db.query').finish(SpanStatus.Error); + expect(native.spanFinish).toHaveBeenCalledWith('sp-1', SpanStatus.Error, true); + }); + + it('setName, setDescription, setAttribute and setStatus cross, and setName is the operation', () => { + native.startSpan.mockReturnValue(wire()); + native.spanSetAttribute.mockReturnValue(true); + const span = Bugsee.startSpan('db.query', null); + span.setName('http.client').setDescription('GET').setAttribute('code', 200).setStatus(SpanStatus.OK); + expect(native.spanSetName).toHaveBeenCalledWith('sp-1', 'http.client'); + expect(native.spanSetDescription).toHaveBeenCalledWith('sp-1', 'GET'); + expect(native.spanSetAttribute).toHaveBeenCalledWith('sp-1', 'code', '200'); + expect(native.spanSetStatus).toHaveBeenCalledWith('sp-1', SpanStatus.OK); + expect(span.operation).toBe('http.client'); + expect(span.description).toBe('GET'); + expect(span.attributes).toEqual({ code: 200 }); + expect(span.status).toBe(SpanStatus.OK); + }); + + it('startChildSpan returns the child, and finishing the parent releases the child', () => { + native.startTransaction.mockReturnValue( + wire({ handle: 'sp-parent', name: 'checkout', sampled: true }), + ); + native.spanStartChild.mockReturnValue( + wire({ handle: 'sp-child', spanId: 'child', operation: 'db.query', description: 'users' }), + ); + native.spanFinish.mockReturnValue(['sp-parent', 'sp-child']); + const txn = Bugsee.startTransaction('checkout', 'user.flow'); + const child = txn.startChildSpan('db.query', 'users'); + expect(native.spanStartChild).toHaveBeenCalledWith('sp-parent', 'db.query', 'users'); + expect(child.spanId).toBe('child'); + txn.finish(); + expect(() => child.setAttribute('k', 'v')).toThrow( + expect.objectContaining({ code: 'E_SPAN_HANDLE_DEAD' }), + ); + expect(native.spanSetAttribute).not.toHaveBeenCalled(); + }); + + it('the first finish returns even when native omits the handle', () => { + const handle = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle, spanId: 'first' })); + native.spanFinish.mockReturnValue([]); + const span = Bugsee.startSpan('db.query'); + expect(() => span.finish()).not.toThrow(); + expect(span.finished).toBe(true); + expect(() => span.finish()).toThrow(expect.objectContaining({ code: 'E_SPAN_HANDLE_DEAD' })); + expect(native.spanFinish).toHaveBeenCalledTimes(1); + native.startSpan.mockReturnValue(wire({ handle, spanId: 'second' })); + const again = Bugsee.startSpan('db.query'); + expect(again).not.toBe(span); + expect(again.spanId).toBe('second'); + expect(again.finished).toBe(false); + }); + + it('a lone surrogate is well-formed, and a refused attribute is not cached', () => { + native.startSpan.mockReturnValue(wire({ handle: freshHandle() })); + const span = Bugsee.startSpan('db.query'); + native.spanSetAttribute.mockReturnValue(false); + span.setAttribute('k', '\uD800'); + const sent = native.spanSetAttribute.mock.calls[0]?.[2] as string; + expect(sent).not.toContain('\\ud800'); + expect(sent).toContain('\uFFFD'); + expect(span.attributes).toEqual({}); + native.spanSetAttribute.mockReturnValue(true); + span.setAttribute('k', '\uD800'); + expect(span.attributes).toEqual({ k: '\uFFFD' }); + }); + + it('the four setters return a value so codegen does not queue them', () => { + const source = readFileSync(join(__dirname, '..', 'NativeBugsee.ts'), 'utf8'); + for (const name of ['spanSetName', 'spanSetDescription', 'spanSetAttribute', 'spanSetStatus']) { + const declared = new RegExp(`\\b${name}\\s*\\([^)]*\\)\\s*:\\s*([^;]+);`).exec(source); + expect(declared).not.toBeNull(); + // `void` is queued. A Promise is queued too. Only a sync boolean + // runs on the JS thread, ahead of spanFinish in the same turn. + expect({ name, returns: declared![1]!.trim() }).toEqual({ name, returns: 'boolean' }); + } + }); + + it('rejects an empty operation before crossing', () => { + expect(() => Bugsee.startSpan('')).toThrow( + 'Bugsee.startSpan requires operation to be a non-empty string', + ); + expect(() => Bugsee.startTransaction('', 'user.flow')).toThrow( + 'Bugsee.startTransaction requires name to be a non-empty string', + ); + expect(native.startSpan).not.toHaveBeenCalled(); + expect(native.startTransaction).not.toHaveBeenCalled(); + }); + + it('names a dead-handle error and keeps the code', () => { + const error = new BugseeSpanError(SpanErrorCode.HandleDead, 'gone'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('BugseeSpanError'); + expect(error.message).toBe('gone'); + expect(error.code).toBe('E_SPAN_HANDLE_DEAD'); + }); + + it('reads every transaction field, including a false sample and a finished flag', () => { + const handle = freshHandle(); + native.startTransaction.mockReturnValue( + wire({ + handle, + name: 'checkout', + sampled: false, + spanId: 'sid', + traceId: 'tid', + operation: 'op', + description: 'desc', + status: SpanStatus.Timeout, + finished: true, + attributesJson: JSON.stringify({ + s: '', + n: 0, + neg: -2, + ok: false, + yes: true, + t: 'x', + }), + }), + ); + const txn = Bugsee.startTransaction('checkout', 'op'); + expect(txn.name).toBe('checkout'); + expect(txn.sampled).toBe(false); + expect(txn.spanId).toBe('sid'); + expect(txn.traceId).toBe('tid'); + expect(txn.operation).toBe('op'); + expect(txn.description).toBe('desc'); + expect(txn.status).toBe(SpanStatus.Timeout); + expect(txn.finished).toBe(true); + expect(txn.attributes).toEqual({ + s: '', + n: 0, + neg: -2, + ok: false, + yes: true, + t: 'x', + }); + expect(txn.setName('next')).toBe(txn); + expect(txn.operation).toBe('next'); + expect(txn.name).toBe('checkout'); + expect(txn.finished).toBe(true); + }); + + it('drops non-string wire fields and ignores a non-string transaction name', () => { + const handle = freshHandle(); + native.startSpan.mockReturnValue({ + handle, + spanId: 1, + traceId: false, + operation: 2, + description: 3, + status: 'nope', + finished: 1, + attributesJson: 4, + name: 5, + }); + const span = Bugsee.startSpan('db.query'); + expect(span.spanId).toBe(''); + expect(span.traceId).toBe(''); + expect(span.operation).toBe(''); + expect(span.description).toBeNull(); + expect(span.status).toBe(SpanStatus.OK); + expect(span.finished).toBe(false); + expect(span.attributes).toEqual({}); + expect('name' in span).toBe(false); + }); + + it('keeps an empty description and treats bad attribute JSON as an empty map', () => { + native.startSpan.mockReturnValue(wire({ handle: freshHandle(), description: '' })); + expect(Bugsee.startSpan('db.query').description).toBe(''); + for (const attributesJson of ['{', 'null', '[1,"a"]', '"x"', '1', '{"a":null,"b":{"c":1},"d":[1]}']) { + native.startSpan.mockReturnValue(wire({ handle: freshHandle(), attributesJson })); + expect(Bugsee.startSpan('db.query').attributes).toEqual({}); + } + }); + + it('rejects a blank or non-string name, operation, key or description before crossing', () => { + const handle = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle })); + const span = Bugsee.startSpan('db.query'); + expect(() => Bugsee.startSpan(' ')).toThrow( + 'Bugsee.startSpan requires operation to be a non-empty string', + ); + expect(() => Bugsee.startSpan(1 as unknown as string)).toThrow( + 'Bugsee.startSpan requires operation to be a non-empty string', + ); + expect(() => Bugsee.startTransaction('checkout', '')).toThrow( + 'Bugsee.startTransaction requires operation to be a non-empty string', + ); + expect(() => Bugsee.startTransaction(' ', 'user.flow')).toThrow( + 'Bugsee.startTransaction requires name to be a non-empty string', + ); + expect(() => Bugsee.startTransaction(1 as unknown as string, 'user.flow')).toThrow( + 'Bugsee.startTransaction requires name to be a non-empty string', + ); + expect(() => span.setName('')).toThrow('Bugsee.setName requires name to be a non-empty string'); + expect(() => span.setName(' ')).toThrow('Bugsee.setName requires name to be a non-empty string'); + expect(() => span.setName(1 as unknown as string)).toThrow( + 'Bugsee.setName requires name to be a non-empty string', + ); + expect(() => span.setAttribute('', true)).toThrow( + 'Bugsee.setAttribute requires key to be a non-empty string', + ); + expect(() => span.setAttribute(' ', true)).toThrow( + 'Bugsee.setAttribute requires key to be a non-empty string', + ); + expect(() => span.startChildSpan('')).toThrow( + 'Bugsee.startChildSpan requires operation to be a non-empty string', + ); + expect(() => Bugsee.startSpan('db.query', 1 as unknown as string)).toThrow( + 'Bugsee span description must be a string or null', + ); + expect(() => span.startChildSpan('db.query', 1 as unknown as string)).toThrow( + 'Bugsee span description must be a string or null', + ); + expect(() => span.setDescription(1 as unknown as string)).toThrow( + 'Bugsee.setDescription requires a string or null', + ); + expect(native.spanSetName).not.toHaveBeenCalled(); + expect(native.spanSetAttribute).not.toHaveBeenCalled(); + expect(native.spanSetDescription).not.toHaveBeenCalled(); + expect(native.spanStartChild).not.toHaveBeenCalled(); + expect(native.startSpan).toHaveBeenCalledTimes(1); + expect(native.startTransaction).not.toHaveBeenCalled(); + }); + + it('crosses null, omitted and empty descriptions, and returns each setter', () => { + const handle = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle, description: 'wire' })); + native.spanSetAttribute.mockReturnValue(true); + const span = Bugsee.startSpan('db.query'); + expect(native.startSpan).toHaveBeenCalledWith('db.query', null); + native.startSpan.mockClear(); + Bugsee.startSpan('db.query', null); + expect(native.startSpan).toHaveBeenCalledWith('db.query', null); + native.startSpan.mockClear(); + Bugsee.startSpan('db.query', ''); + expect(native.startSpan).toHaveBeenCalledWith('db.query', ''); + expect(span.setDescription(null)).toBe(span); + expect(span.description).toBeNull(); + expect(native.spanSetDescription).toHaveBeenCalledWith(handle, null); + expect(span.setDescription('')).toBe(span); + expect(span.description).toBe(''); + expect(span.setAttribute('b', false)).toBe(span); + expect(span.setAttribute('n', 0)).toBe(span); + expect(span.setAttribute('s', '')).toBe(span); + expect(span.attributes).toEqual({ b: false, n: 0, s: '' }); + expect(native.spanSetAttribute).toHaveBeenCalledWith(handle, 'b', 'false'); + expect(native.spanSetAttribute).toHaveBeenCalledWith(handle, 'n', '0'); + expect(native.spanSetAttribute).toHaveBeenCalledWith(handle, 's', '""'); + expect(span.setStatus(SpanStatus.Unknown)).toBe(span); + expect(span.status).toBe(SpanStatus.Unknown); + expect(native.spanSetStatus).toHaveBeenCalledWith(handle, SpanStatus.Unknown); + const childHandle = freshHandle(); + native.spanStartChild.mockReturnValue(wire({ handle: childHandle, description: null })); + const child = span.startChildSpan('db.query'); + expect(native.spanStartChild).toHaveBeenCalledWith(handle, 'db.query', null); + expect(child.setDescription(null)).toBe(child); + native.spanStartChild.mockClear(); + span.startChildSpan('db.query', null); + expect(native.spanStartChild).toHaveBeenCalledWith(handle, 'db.query', null); + span.startChildSpan('db.query', ''); + expect(native.spanStartChild).toHaveBeenCalledWith(handle, 'db.query', ''); + }); + + it('rejects attributes that are not a map of string, finite number or boolean', () => { + expect(() => Bugsee.startTransaction('checkout', 'user.flow', null as unknown as Record)).toThrow( + 'Bugsee.startTransaction attributes must be an object', + ); + expect(() => + Bugsee.startTransaction('checkout', 'user.flow', [] as unknown as Record), + ).toThrow('Bugsee.startTransaction attributes must be an object'); + expect(() => + Bugsee.startTransaction('checkout', 'user.flow', 1 as unknown as Record), + ).toThrow('Bugsee.startTransaction attributes must be an object'); + const bad = 'Bugsee span attribute must be a string, a finite number or a boolean'; + expect(() => Bugsee.startTransaction('checkout', 'user.flow', { a: null as unknown as string })).toThrow(bad); + expect(() => Bugsee.startTransaction('checkout', 'user.flow', { a: Number.NaN })).toThrow(bad); + expect(() => + Bugsee.startTransaction('checkout', 'user.flow', { a: Number.POSITIVE_INFINITY }), + ).toThrow(bad); + expect(() => Bugsee.startTransaction('checkout', 'user.flow', { a: {} as unknown as string })).toThrow(bad); + native.startTransaction.mockReturnValue(wire({ handle: freshHandle(), name: 'checkout', sampled: true })); + const txn = Bugsee.startTransaction('checkout', 'user.flow', {}); + expect(native.startTransaction).toHaveBeenCalledWith('checkout', 'user.flow', jsonOf({})); + expect(txn.attributes).toEqual({}); + const handle = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle })); + const span = Bugsee.startSpan('db.query'); + native.spanSetAttribute.mockReturnValue(true); + span.setAttribute('keep', 'yes'); + expect(() => span.setAttribute('k', null as unknown as string)).toThrow(bad); + expect(() => span.setAttribute('k', Number.NaN)).toThrow(bad); + expect(() => span.setAttribute('k', Number.POSITIVE_INFINITY)).toThrow(bad); + expect(span.attributes).toEqual({ keep: 'yes' }); + expect(native.spanSetAttribute).toHaveBeenCalledTimes(1); + }); + + it('rejects a status that is not an integer 0..5 and leaves the span open', () => { + const handle = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle, status: SpanStatus.OK })); + const span = Bugsee.startSpan('db.query'); + const message = 'Bugsee span status must be an integer 0..5'; + expect(() => span.setStatus('1' as unknown as SpanStatus)).toThrow(TypeError); + expect(() => span.setStatus('1' as unknown as SpanStatus)).toThrow(message); + expect(() => span.setStatus(undefined as unknown as SpanStatus)).toThrow(TypeError); + expect(() => span.setStatus(1.5 as unknown as SpanStatus)).toThrow(RangeError); + expect(() => span.setStatus(1.5 as unknown as SpanStatus)).toThrow(message); + expect(() => span.setStatus(-1 as unknown as SpanStatus)).toThrow(RangeError); + expect(() => span.setStatus(6 as unknown as SpanStatus)).toThrow(RangeError); + expect(() => span.setStatus(Number.NaN as unknown as SpanStatus)).toThrow(RangeError); + expect(() => span.setStatus(Number.POSITIVE_INFINITY as unknown as SpanStatus)).toThrow( + RangeError, + ); + expect(() => span.finish(1.5 as unknown as SpanStatus)).toThrow(RangeError); + expect(() => span.finish('1' as unknown as SpanStatus)).toThrow(TypeError); + expect(native.spanSetStatus).not.toHaveBeenCalled(); + expect(native.spanFinish).not.toHaveBeenCalled(); + expect(span.finished).toBe(false); + expect(span.status).toBe(SpanStatus.OK); + span.setStatus(SpanStatus.OK); + expect(native.spanSetStatus).toHaveBeenCalledWith(handle, 0); + native.spanFinish.mockReturnValue([handle]); + span.finish(SpanStatus.OK); + expect(native.spanFinish).toHaveBeenCalledWith(handle, 0, true); + expect(span.finished).toBe(true); + }); + + it('throws when native returns no span, and a missing child is a dead handle', () => { + native.startSpan.mockReturnValue(wire({ handle: '' })); + expect(() => Bugsee.startSpan('db.query')).toThrow('Bugsee.startSpan did not return a span'); + native.getActiveSpan.mockReturnValue(wire({ handle: 0 })); + expect(Bugsee.getActiveSpan()).toBeNull(); + native.startTransaction.mockReturnValue(wire({ handle: '' })); + expect(() => Bugsee.startTransaction('checkout', 'user.flow')).toThrow( + 'Bugsee.startTransaction did not return a transaction', + ); + native.startTransaction.mockReturnValue(wire({ handle: freshHandle() })); + expect(() => Bugsee.startTransaction('checkout', 'user.flow')).toThrow( + 'Bugsee.startTransaction did not return a transaction', + ); + const parent = freshHandle(); + native.startTransaction.mockReturnValue(wire({ handle: parent, name: 'checkout', sampled: true })); + const txn = Bugsee.startTransaction('checkout', 'user.flow'); + native.spanStartChild.mockReturnValue(wire({ handle: '' })); + expect(() => txn.startChildSpan('db.query')).toThrow(expect.objectContaining(DEAD)); + expect(native.spanStartChild).toHaveBeenCalledWith(parent, 'db.query', null); + }); + + it('every later call on a finished span throws without crossing', () => { + const handle = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle })); + native.spanFinish.mockReturnValue([handle]); + const span = Bugsee.startSpan('db.query'); + span.finish(); + native.spanSetName.mockClear(); + native.spanSetDescription.mockClear(); + native.spanSetAttribute.mockClear(); + native.spanSetStatus.mockClear(); + native.spanStartChild.mockClear(); + native.spanFinish.mockClear(); + expect(() => span.setName('x')).toThrow(expect.objectContaining(DEAD)); + expect(() => span.setDescription('x')).toThrow(expect.objectContaining(DEAD)); + expect(() => span.setAttribute('k', 'v')).toThrow(expect.objectContaining(DEAD)); + expect(() => span.setStatus(SpanStatus.Error)).toThrow(expect.objectContaining(DEAD)); + expect(() => span.startChildSpan('op')).toThrow(expect.objectContaining(DEAD)); + expect(() => span.finish()).toThrow(expect.objectContaining(DEAD)); + expect(native.spanSetName).not.toHaveBeenCalled(); + expect(native.spanSetDescription).not.toHaveBeenCalled(); + expect(native.spanSetAttribute).not.toHaveBeenCalled(); + expect(native.spanSetStatus).not.toHaveBeenCalled(); + expect(native.spanStartChild).not.toHaveBeenCalled(); + expect(native.spanFinish).not.toHaveBeenCalled(); + }); + + it('releases only the finished handle, and a later start with that handle is a new span', () => { + const kept = freshHandle(); + const done = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle: kept, spanId: 'kept' })); + const liveSpan = Bugsee.startSpan('db.query'); + native.startSpan.mockReturnValue(wire({ handle: done, spanId: 'done' })); + const finished = Bugsee.startSpan('db.query'); + native.spanFinish.mockReturnValue([done, 'sp-missing']); + expect(() => finished.finish()).not.toThrow(); + expect(finished.finished).toBe(true); + expect(liveSpan.finished).toBe(false); + liveSpan.setName('still'); + expect(native.spanSetName).toHaveBeenCalledWith(kept, 'still'); + native.startSpan.mockReturnValue(wire({ handle: done, spanId: 'again' })); + const again = Bugsee.startSpan('db.query'); + expect(again).not.toBe(finished); + expect(again.finished).toBe(false); + expect(again.spanId).toBe('again'); + }); + + it('a child handle can be started again after the parent finish drops it', () => { + const parent = freshHandle(); + const childHandle = freshHandle(); + native.startTransaction.mockReturnValue(wire({ handle: parent, name: 'checkout', sampled: true })); + native.spanStartChild.mockReturnValue( + wire({ handle: childHandle, spanId: 'child', operation: 'db.query' }), + ); + const txn = Bugsee.startTransaction('checkout', 'user.flow'); + const child = txn.startChildSpan('db.query', 'users'); + native.spanFinish.mockReturnValue([parent, childHandle]); + txn.finish(); + native.startSpan.mockReturnValue(wire({ handle: childHandle, spanId: 'reborn' })); + const reborn = Bugsee.startSpan('db.query'); + expect(reborn).not.toBe(child); + expect(reborn.spanId).toBe('reborn'); + expect(reborn.finished).toBe(false); + expect(() => child.setName('nope')).toThrow(expect.objectContaining(DEAD)); + }); + + it('getActiveSpan returns the same span object, twice', () => { + const handle = freshHandle(); + native.startSpan.mockReturnValue(wire({ handle })); + native.getActiveSpan.mockReturnValue(wire({ handle })); + const span = Bugsee.startSpan('db.query'); + expect(Bugsee.getActiveSpan()).toBe(span); + expect(Bugsee.getActiveSpan()).toBe(span); + }); +}); diff --git a/packages/react-native/src/apm/notify.ts b/packages/react-native/src/apm/notify.ts new file mode 100644 index 00000000..46e74b61 --- /dev/null +++ b/packages/react-native/src/apm/notify.ts @@ -0,0 +1,58 @@ +import NativeBugsee from '../NativeBugsee'; +import { encodeBridgeObject } from '../bridge/json'; +import { severityArgument } from '../report/fields'; + +/** + * Queues a notification for the app's messaging integrations. + * + * This does not create a bug report. Both SDKs persist it and upload it in + * a batch; `urgent` skip-ahead POSTs this item without draining older ones. + * Shorter calls are non-urgent. A call before `launch()` is ignored by the + * SDK. An empty title is rejected here: both SDKs drop it and only log. + */ +export function notify( + title: string, + body?: string | null, + severity?: Parameters[0], + fields?: Readonly> | null, + urgent?: boolean, +): void { + if (arguments.length > 5) { + throw new TypeError('Bugsee.notify takes at most five arguments'); + } + if (typeof title !== 'string' || title.trim().length === 0) { + throw new TypeError('Bugsee.notify requires a non-empty title'); + } + let message: string | null = null; + if (body !== undefined && body !== null) { + if (typeof body !== 'string') { + throw new TypeError('Bugsee.notify body must be a string'); + } + message = body; + } + let fieldsJson: string | null = null; + if (fields !== undefined && fields !== null) { + if (typeof fields !== 'object' || Array.isArray(fields)) { + throw new TypeError('Bugsee.notify fields must be an object of strings'); + } + const copy: Record = Object.create(null); + for (const key of Object.keys(fields)) { + const value = fields[key]; + if (typeof value !== 'string') { + throw new TypeError('Bugsee.notify fields must be an object of strings'); + } + copy[key] = value; + } + fieldsJson = encodeBridgeObject(copy); + } + if (urgent !== undefined && typeof urgent !== 'boolean') { + throw new TypeError('Bugsee.notify urgent must be a boolean'); + } + NativeBugsee.notify( + title, + message, + severityArgument(severity, 'notify'), + fieldsJson, + urgent === true, + ); +} diff --git a/packages/react-native/src/apm/span.ts b/packages/react-native/src/apm/span.ts new file mode 100644 index 00000000..f35df19f --- /dev/null +++ b/packages/react-native/src/apm/span.ts @@ -0,0 +1,403 @@ +import NativeBugsee from '../NativeBugsee'; +import { encodeBridgeJson, encodeBridgeObject } from '../bridge/json'; +import { + BugseeSpanError, + SpanErrorCode, + SpanStatus, + type SpanAttribute, + type SpanWire, +} from './types'; + +const live = new Map(); + +function dead(): never { + throw new BugseeSpanError( + SpanErrorCode.HandleDead, + 'Bugsee span handle is dead; finish already released it', + ); +} + +function assertText(value: unknown, method: string, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError(`Bugsee.${method} requires ${field} to be a non-empty string`); + } + return value; +} + +function assertStatus(status: unknown): SpanStatus { + if (typeof status !== 'number') { + throw new TypeError('Bugsee span status must be an integer 0..5'); + } + if (!Number.isInteger(status) || status < SpanStatus.OK || status > SpanStatus.Unknown) { + throw new RangeError('Bugsee span status must be an integer 0..5'); + } + return status as SpanStatus; +} + +function assertAttribute(value: unknown): SpanAttribute { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'boolean') { + return value; + } + // Number.isFinite is true only for finite numbers, so the typeof check is redundant. + if (Number.isFinite(value)) { + return value as number; + } + throw new TypeError('Bugsee span attribute must be a string, a finite number or a boolean'); +} + +function assertAttributes( + attributes: unknown, +): Record | null { + if (attributes === undefined) { + return null; + } + if (attributes === null || typeof attributes !== 'object' || Array.isArray(attributes)) { + throw new TypeError('Bugsee.startTransaction attributes must be an object'); + } + const copy: Record = Object.create(null); + for (const key of Object.keys(attributes as Record)) { + copy[key] = assertAttribute((attributes as Record)[key]); + } + return copy; +} + +function asWire(value: unknown): SpanWire { + const wire = value as SpanWire; + return { + handle: typeof wire.handle === 'string' ? wire.handle : '', + spanId: typeof wire.spanId === 'string' ? wire.spanId : '', + traceId: typeof wire.traceId === 'string' ? wire.traceId : '', + operation: typeof wire.operation === 'string' ? wire.operation : '', + description: typeof wire.description === 'string' ? wire.description : null, + status: typeof wire.status === 'number' ? wire.status : SpanStatus.OK, + finished: wire.finished === true, + attributesJson: wire.attributesJson, + ...(typeof wire.name === 'string' ? { name: wire.name, sampled: wire.sampled === true } : {}), + }; +} + +function adopt(value: unknown): SpanObject | null { + const wire = asWire(value); + if (wire.handle === '') { + return null; + } + const existing = live.get(wire.handle); + if (existing !== undefined) { + return existing; + } + const span = new SpanObject(wire); + live.set(wire.handle, span); + return span; +} + +function markReleased(handles: readonly string[]): void { + for (const handle of handles) { + const span = live.get(handle); + if (span !== undefined) { + span.markDead(); + } + live.delete(handle); + } +} + +/** + * A span the bridge is holding. `finish` tells native to drop it. A parent + * finish also drops children the SDK has finished; native reports every + * handle it released, and those objects die here without another call. + */ +class SpanObject { + private dead = false; + private operation: string; + private description: string | null; + private status: number; + private finishedFlag: boolean; + private attributes: Record; + readonly spanId: string; + readonly traceId: string; + /** Set only when the wire is a transaction. */ + readonly transactionName: string | undefined; + readonly transactionSampled: boolean; + + constructor(private readonly wire: SpanWire) { + this.spanId = wire.spanId; + this.traceId = wire.traceId; + this.operation = wire.operation; + this.description = wire.description; + this.status = wire.status; + this.finishedFlag = wire.finished; + this.attributes = parseAttributes(wire.attributesJson); + // `asWire` already kept `name` only when it is a string. + this.transactionName = wire.name; + this.transactionSampled = wire.sampled === true; + } + + /** Native `setName`, which sets the operation on both SDKs. */ + setName(name: string): this { + this.guard(); + const text = assertText(name, 'setName', 'name'); + NativeBugsee.spanSetName(this.wire.handle, text); + this.operation = text; + return this; + } + + setDescription(description: string | null): this { + this.guard(); + if (description !== null && typeof description !== 'string') { + throw new TypeError('Bugsee.setDescription requires a string or null'); + } + NativeBugsee.spanSetDescription(this.wire.handle, description); + this.description = description; + return this; + } + + setAttribute(key: string, value: SpanAttribute): this { + this.guard(); + const name = assertText(key, 'setAttribute', 'key'); + const attribute = assertAttribute(value); + const wire = encodeBridgeJson(attribute); + if (NativeBugsee.spanSetAttribute(this.wire.handle, name, wire)) { + const stored = JSON.parse(wire) as SpanAttribute; + this.attributes = { ...this.attributes, [name]: stored }; + } + return this; + } + + setStatus(status: SpanStatus): this { + this.guard(); + const wire = assertStatus(status); + NativeBugsee.spanSetStatus(this.wire.handle, wire); + this.status = wire; + return this; + } + + startChildSpan(operation: string, description?: string | null): SpanObject { + this.guard(); + const op = assertText(operation, 'startChildSpan', 'operation'); + const body = optionalDescription(description); + const child = adopt(NativeBugsee.spanStartChild(this.wire.handle, op, body)); + if (child === null) { + dead(); + } + return child; + } + + /** + * Finishes the span. No argument is the SDK's no-arg `finish` (status OK). + * The native handle is released, including any child this finish ended. + */ + finish(status?: SpanStatus): void { + this.guard(); + const explicit = status !== undefined; + const wire = explicit ? assertStatus(status) : 0; + const released = NativeBugsee.spanFinish(this.wire.handle, wire, explicit); + markReleased(released); + // Dead even when native omits this handle. A later call is what throws. + this.markDead(); + live.delete(this.wire.handle); + } + + get operationName(): string { + return this.operation; + } + + get descriptionText(): string | null { + return this.description; + } + + get statusValue(): number { + return this.status; + } + + get isFinished(): boolean { + return this.finishedFlag; + } + + get attributeMap(): Readonly> { + return this.attributes; + } + + markDead(): void { + this.dead = true; + this.finishedFlag = true; + } + + private guard(): void { + if (this.dead) { + dead(); + } + } +} + +function parseAttributes(json: string): Record { + try { + const parsed: unknown = JSON.parse(json); + // `null` is typeof "object"; Object.entries throws and the catch + // reports the same empty map a non-object would. + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + return Object.create(null); + } + const result: Record = Object.create(null); + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === 'string' || typeof value === 'boolean') { + result[key] = value; + } else if (typeof value === 'number') { + // JSON.parse only yields finite numbers, so no separate finite check. + result[key] = value; + } + } + return result; + } catch { + return Object.create(null); + } +} + +function optionalDescription(description: string | null | undefined): string | null { + if (description === undefined || description === null) { + return null; + } + if (typeof description !== 'string') { + throw new TypeError('Bugsee span description must be a string or null'); + } + return description; +} + +export interface BugseeSpan { + readonly spanId: string; + readonly traceId: string; + readonly operation: string; + readonly description: string | null; + readonly status: SpanStatus; + readonly finished: boolean; + readonly attributes: Readonly>; + setName(name: string): this; + setDescription(description: string | null): this; + setAttribute(key: string, value: SpanAttribute): this; + setStatus(status: SpanStatus): this; + startChildSpan(operation: string, description?: string | null): BugseeSpan; + finish(status?: SpanStatus): void; +} + +export interface BugseeTransaction extends BugseeSpan { + readonly name: string; + readonly sampled: boolean; +} + +class PublicSpan implements BugseeSpan { + constructor(private readonly inner: SpanObject) {} + + get spanId(): string { + return this.inner.spanId; + } + get traceId(): string { + return this.inner.traceId; + } + get operation(): string { + return this.inner.operationName; + } + get description(): string | null { + return this.inner.descriptionText; + } + get status(): SpanStatus { + return this.inner.statusValue as SpanStatus; + } + get finished(): boolean { + return this.inner.isFinished; + } + get attributes(): Readonly> { + return this.inner.attributeMap; + } + setName(name: string): this { + this.inner.setName(name); + return this; + } + setDescription(description: string | null): this { + this.inner.setDescription(description); + return this; + } + setAttribute(key: string, value: SpanAttribute): this { + this.inner.setAttribute(key, value); + return this; + } + setStatus(status: SpanStatus): this { + this.inner.setStatus(status); + return this; + } + startChildSpan(operation: string, description?: string | null): BugseeSpan { + return wrap(this.inner.startChildSpan(operation, description)); + } + finish(status?: SpanStatus): void { + this.inner.finish(status); + } +} + +class PublicTransaction extends PublicSpan implements BugseeTransaction { + readonly name: string; + readonly sampled: boolean; + + constructor(inner: SpanObject, name: string, sampled: boolean) { + super(inner); + this.name = name; + this.sampled = sampled; + } +} + +const wrapped = new WeakMap(); + +function wrap(inner: SpanObject): BugseeSpan { + const existing = wrapped.get(inner); + if (existing !== undefined) { + return existing; + } + const name = innerWireName(inner); + const span = + name === undefined + ? new PublicSpan(inner) + : new PublicTransaction(inner, name.name, name.sampled); + wrapped.set(inner, span); + return span; +} + +function innerWireName(inner: SpanObject): { name: string; sampled: boolean } | undefined { + if (inner.transactionName === undefined) { + return undefined; + } + return { name: inner.transactionName, sampled: inner.transactionSampled }; +} + +export function startTransaction( + name: string, + operation: string, + attributes?: Readonly>, +): BugseeTransaction { + const txnName = assertText(name, 'startTransaction', 'name'); + const op = assertText(operation, 'startTransaction', 'operation'); + const attrs = assertAttributes(attributes); + const span = adopt( + NativeBugsee.startTransaction( + txnName, + op, + attrs === null ? null : encodeBridgeObject(attrs), + ), + ); + if (span === null || !('name' in wrap(span))) { + throw new Error('Bugsee.startTransaction did not return a transaction'); + } + return wrap(span) as BugseeTransaction; +} + +export function startSpan(operation: string, description?: string | null): BugseeSpan { + const op = assertText(operation, 'startSpan', 'operation'); + const span = adopt(NativeBugsee.startSpan(op, optionalDescription(description))); + if (span === null) { + throw new Error('Bugsee.startSpan did not return a span'); + } + return wrap(span); +} + +export function getActiveSpan(): BugseeSpan | null { + const span = adopt(NativeBugsee.getActiveSpan()); + return span === null ? null : wrap(span); +} diff --git a/packages/react-native/src/apm/types.ts b/packages/react-native/src/apm/types.ts new file mode 100644 index 00000000..f16bdd9c --- /dev/null +++ b/packages/react-native/src/apm/types.ts @@ -0,0 +1,57 @@ +/** + * Terminal status of a span, the integer both SDKs use. + * + * Android `SpanStatus` is a plain enum whose ordinal is this value + * (`OK` is 0). iOS `BGSSpanStatus` declares the same integers. The bundle + * writes the name (`OK`, `ERROR`, `DEADLINE_EXCEEDED`); this is the value + * `setStatus` and `finish(status)` cross. + */ +export const SpanStatus = { + OK: 0, + Error: 1, + Timeout: 2, + Cancelled: 3, + DeadlineExceeded: 4, + Unknown: 5, +} as const; +export type SpanStatus = (typeof SpanStatus)[keyof typeof SpanStatus]; + +export const SpanErrorCode = { + /** + * `finish` already released this span, or a parent finish released it. + * Raised locally, without crossing, for every operation after that. + */ + HandleDead: 'E_SPAN_HANDLE_DEAD', +} as const; +export type SpanErrorCode = (typeof SpanErrorCode)[keyof typeof SpanErrorCode]; + +export class BugseeSpanError extends Error { + readonly code: SpanErrorCode; + + constructor(code: SpanErrorCode, message: string) { + super(message); + this.name = 'BugseeSpanError'; + this.code = code; + } +} + +/** A span attribute. Both SDKs take a string, number or boolean. */ +export type SpanAttribute = string | number | boolean; + +/** + * What `startTransaction` / `startSpan` / `getActiveSpan` / `spanStartChild` + * return. `handle` `''` means there is no span. `name` is present only for + * a transaction. + */ +export interface SpanWire { + handle: string; + spanId: string; + traceId: string; + operation: string; + description: string | null; + status: number; + finished: boolean; + attributesJson: string; + name?: string; + sampled?: boolean; +} diff --git a/packages/react-native/src/bridge/json.ts b/packages/react-native/src/bridge/json.ts index 4e8378cd..b7ac8737 100644 --- a/packages/react-native/src/bridge/json.ts +++ b/packages/react-native/src/bridge/json.ts @@ -99,3 +99,12 @@ function wellFormedCopy(value: unknown): unknown { export function encodeBridgeObject(value: Readonly>): string { return JSON.stringify(wellFormedCopy(value)); } + +/** + * One JSON value for the bridge, strings well-formed the same way as + * {@link encodeBridgeObject}. A lone surrogate becomes U+FFFD before + * `JSON.stringify`, so iOS `NSJSONSerialization` does not reject the text. + */ +export function encodeBridgeJson(value: unknown): string { + return JSON.stringify(wellFormedCopy(value)); +} diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 3b260088..05760112 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -53,6 +53,14 @@ import { reportHandled, reportUnhandled, } from './exceptions/report'; +import { notify as queueNotification } from './apm/notify'; +import { + getActiveSpan as readActiveSpan, + startSpan as openSpan, + startTransaction as openTransaction, +} from './apm/span'; +import type { BugseeSpan, BugseeTransaction } from './apm/span'; +import type { SpanAttribute } from './apm/types'; const DETECT_CRASH_OPTION = 'com.bugsee.option.detect.crash'; @@ -722,6 +730,66 @@ class Bugsee { : { fallbackStack: dropFirstStackFrame(new Error().stack) }; return reportUnhandled(error, extras); } + + /** + * Queues a notification for the app's messaging integrations. This does + * not create a bug report: no video, logs or events are attached. Both + * SDKs persist it and upload it in a batch. `urgent` skip-ahead POSTs + * this item without draining older ones; omitted, the call is not urgent. + * + * Omitted `severity` crosses as `0`, which both SDKs treat as unset. + * Omitted `fields` cross as null. A call before `launch()` is ignored by + * the SDK. An empty title is rejected here, because both SDKs would drop + * it and only log. A sixth argument throws `TypeError`. + */ + notify( + title: string, + body?: string | null, + severity?: IssueSeverity, + fields?: Readonly> | null, + urgent?: boolean, + ): void { + if (arguments.length > 5) { + throw new TypeError('Bugsee.notify takes at most five arguments'); + } + queueNotification(title, body, severity, fields, urgent); + } + + /** + * Starts a performance transaction and returns it. The bridge retains the + * native object until {@link BugseeSpan.finish} releases it, including any + * child that finish cancelled. + * + * `name` and `operation` are the SDK's two required strings. Omitted + * `attributes` is the two-argument overload. The transaction becomes the + * active span on the thread the bridge calls from, so a later + * {@link startSpan} is its child. Both SDKs return a no-op when + * performance monitoring is off; `finish` still releases that handle. + */ + startTransaction( + name: string, + operation: string, + attributes?: Readonly>, + ): BugseeTransaction { + return openTransaction(name, operation, attributes); + } + + /** + * Starts a span as a child of the active span on the bridge's thread. + * With no active transaction, or with performance monitoring off, the SDK + * returns a no-op. `finish` releases the handle either way. + */ + startSpan(operation: string, description?: string | null): BugseeSpan { + return openSpan(operation, description); + } + + /** + * The active span on the bridge's thread, or null when there is none. + * A span this process already holds is the same object. + */ + getActiveSpan(): BugseeSpan | null { + return readActiveSpan(); + } } /** @@ -823,6 +891,10 @@ export type { } from './breadcrumbs/types'; export type { ExceptionOptions } from './exceptions/options'; +export { SpanStatus } from './apm/types'; +export type { SpanAttribute } from './apm/types'; +export type { BugseeSpan, BugseeTransaction } from './apm/span'; +export { SpanErrorCode, BugseeSpanError } from './apm/types'; export { ErrorBoundary } from './exceptions/ErrorBoundary'; export type { ErrorBoundaryFallbackProps,