diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f20e3b..a66fdf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.19] - 2026-09-19 + +### Changed + +- **Android low-memory kills are no longer crashes.** `ApplicationExitInfo` + `REASON_LOW_MEMORY` (the OS reclaiming a cached background process) was + written as a `native_crash` report with `crash.type: low_memory`. Play + Console and Crashlytics don't count it, and on aggressive OEMs it outnumbers + real crashes several times over, dragging crash-free rates far below the + store's. It is now an `app_exit` span (`exit.reason: low_memory`, + `exit.description`, `exit.importance`, `exit.pss_kb`, …) that never counts + as a crash. Crash counts will drop; re-baseline any alert on `native_crash`. + Parity with scout-flutter 0.3.0. +- **First launch with no exit-info watermark reports nothing.** Fresh installs + used to drain the OS's exit history (up to 50 records, days old) into the + session that had just started. The watermark is now recorded and the backlog + skipped. + +### Added + +- `SPAN.APP_EXIT` (`app_exit`) and the `scout.span` marker the Android + collector puts on a pending report to route it there. + +## [0.1.18] - 2026-09-18 + +### Added + +- **`app_startup.duration_ms`** on every `app_startup` span (native cold and + warm, web cold and bfcache warm), next to the seconds-valued + `app_startup.duration`. The unsuffixed key gave no unit and produced the same + 1000x display bug `anr.duration` did (fixed in 0.1.17 by renaming). Unlike + ANR, the seconds key is *kept*: RUM plugins before 0.1.34 read only + `app_startup.duration`, so dropping it would blank their startup panels. + Backends coalesce `app_startup.duration_ms` over `app_startup.duration * 1000`. + +### Fixed + +- `docs/configuration.md` claimed `enableStartupTracking` measured a "hot" start + and the 0.1.8 changelog entry named attribute keys that were never emitted. + ## [0.1.17] - 2026-09-07 Eight data-quality defects found validating browser RUM against live data. Four @@ -634,8 +674,10 @@ slows trace/log export from 5s to 30s. All of it is opt-in-able — see below. the cold-start duration is measured from the OS process start, not from `Scout.initialize`. On background-to-active transitions, a `warm` `app_startup` span is emitted with duration measured to the next animation - frame. Both carry `app.startup.type` (`cold` | `warm`) and - `app.startup.duration_seconds`. + frame. Both carry `app_startup.type` (`cold` | `warm`) and + `app_startup.duration` (seconds). *(Corrected 2026-09-18: this entry + originally named the keys `app.startup.type` / `app.startup.duration_seconds`, + which were never what the SDK emitted.)* ## [0.1.6] - 2026-05-22 diff --git a/README.md b/README.md index f2e7e88..b0dda0a 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ On Android USB devices, the OTLP endpoint runs on your dev machine — point it | ANR | `anr` | Web: worker watchdog. RN: timer-drift watchdog. | | HTTP (fetch + XHR) | `http.request` | Method, URL, status, duration, content-length | | Crash (OOM / force-kill) | `app_crash` on next launch | Persistent session marker (localStorage on web, AsyncStorage on RN) — survives unclean termination | -| Native crash (RN) | `native_crash` on next launch | iOS: **KSCrash 2.5+** (mach exceptions, POSIX signals, C++, NSException, main-thread deadlock) + **MetricKit** (`MXCrashDiagnostic`, `MXHangDiagnostic`) on iOS 14+. Android: uncaught Java/Kotlin (`Thread.setDefaultUncaughtExceptionHandler`) + **NDK signal handler** for native crashes + **ApplicationExitInfo** (API 30+) for OS-recorded process deaths including OOM and ANR. Reports persisted to disk and emitted on next launch with full register / stack / binary-image dumps, prior breadcrumbs, and `crash.type` / `crash.reason` / `crash.stack_trace` | +| Native crash (RN) | `native_crash` on next launch | iOS: **KSCrash 2.5+** (mach exceptions, POSIX signals, C++, NSException, main-thread deadlock) + **MetricKit** (`MXCrashDiagnostic`, `MXHangDiagnostic`) on iOS 14+. Android: uncaught Java/Kotlin (`Thread.setDefaultUncaughtExceptionHandler`) + **NDK signal handler** for native crashes + **ApplicationExitInfo** (API 30+) for OS-recorded process deaths including ANR (low-memory kills are emitted as `app_exit`, not as crashes). Reports persisted to disk and emitted on next launch with full register / stack / binary-image dumps, prior breadcrumbs, and `crash.type` / `crash.reason` / `crash.stack_trace` | | Logs | OTLP logs | `Scout.logDebug/Info/Warning/Error` and (opt-in) `console.*` capture | ### Web only diff --git a/android/src/main/java/io/base14/scoutreact/ScoutCrashModule.kt b/android/src/main/java/io/base14/scoutreact/ScoutCrashModule.kt index 87d15d6..e866088 100644 --- a/android/src/main/java/io/base14/scoutreact/ScoutCrashModule.kt +++ b/android/src/main/java/io/base14/scoutreact/ScoutCrashModule.kt @@ -446,16 +446,30 @@ private object ScoutExitInfoCollector { } catch (_: Throwable) { return } + // First launch with no watermark (fresh install, or an upgrade from an + // SDK that kept none): the OS history predates this SDK. Nothing in it + // can be attributed to a session we know about, and dumping up to 50 old + // deaths into the session that just started made it look crashed. Record + // the watermark and report nothing. + if (lastTs == 0L) { + val newestSeen = infos.maxOfOrNull { it.timestamp } ?: 0L + if (newestSeen > 0L) { + prefs.edit().putLong(KEY_LAST_TIMESTAMP, newestSeen).apply() + } + return + } var newest = lastTs for (info in infos) { if (info.timestamp <= lastTs) continue // The watermark advances over every record, benign ones included, so a // dropped exit is never re-examined on the next launch. if (info.timestamp > newest) newest = info.timestamp - val crashType = ScoutExitInfoClassifier.crashTypeFor(reasonName(info.reason)) - ?: continue + val reason = reasonName(info.reason) + val crashType = ScoutExitInfoClassifier.crashTypeFor(reason) + val exitReason = if (crashType == null) ScoutExitInfoClassifier.exitReasonFor(reason) else null + if (crashType == null && exitReason == null) continue try { - writeReport(dir, info, crashType) + writeReport(dir, info, crashType ?: exitReason!!, isExit = crashType == null) } catch (_: Throwable) { } @@ -465,8 +479,16 @@ private object ScoutExitInfoCollector { } } - private fun writeReport(dir: File, info: ApplicationExitInfo, crashType: String) { + private fun writeReport( + dir: File, + info: ApplicationExitInfo, + crashType: String, + isExit: Boolean = false, + ) { val obj = JSONObject().apply { + // Same `crash.*` shape for both; the JS side renames to `exit.*` and + // emits `app_exit` when the span marker says so. + if (isExit) put(ScoutExitInfoClassifier.SPAN_KEY, ScoutExitInfoClassifier.APP_EXIT) put("crash.type", crashType) put("crash.source", ScoutExitInfoClassifier.SOURCE) put("crash.os_reason_code", info.reason) diff --git a/android/src/main/java/io/base14/scoutreact/ScoutExitInfoClassifier.kt b/android/src/main/java/io/base14/scoutreact/ScoutExitInfoClassifier.kt index a987b7b..e38f656 100644 --- a/android/src/main/java/io/base14/scoutreact/ScoutExitInfoClassifier.kt +++ b/android/src/main/java/io/base14/scoutreact/ScoutExitInfoClassifier.kt @@ -27,23 +27,43 @@ internal object ScoutTimeFormat { * (see `android/unit-tests`). The caller maps the platform's `reason` int to * a name via [ScoutExitInfoCollector.reasonName]; this object owns the policy. * - * Only {anr, jvm_crash, native_crash, low_memory} are crash-class. Everything - * else is a normal way for a process to stop — swiping the app from recents, - * Force Stop, a self-exit — and reporting those inflates crash counts with - * user actions. Matches scout-flutter's `isCrashClassExitInfo`. + * Only {anr, jvm_crash, native_crash} are crash-class. Everything else is a + * normal way for a process to stop — swiping the app from recents, Force + * Stop, a self-exit — and reporting those inflates crash counts with user + * actions. `low_memory` (the OS reclaiming a cached background process) is + * not a crash either: Play Console and Crashlytics don't count it, and on + * aggressive OEMs it outnumbers real crashes several times over. It is still + * worth seeing, so it goes out as an `app_exit` span via [exitReasonFor]. + * Matches scout-flutter's `isCrashClassExitInfo` / `isReportedExitInfo`. */ internal object ScoutExitInfoClassifier { /** `crash.source` for records that came from the exit-info path. */ const val SOURCE = "exit_info" + /** + * Key in a pending report naming the span the JS side must emit. Absent + * (the default) means `native_crash`; [APP_EXIT] means an `app_exit` span + * whose `crash.*` keys are renamed to `exit.*`. + */ + const val SPAN_KEY = "scout.span" + const val APP_EXIT = "app_exit" + /** * The `crash.type` to report for an OS exit reason name, or null when the - * exit was benign and must not be emitted at all. + * exit was not a crash. */ fun crashTypeFor(reasonName: String): String? = when (reasonName) { "crash" -> "jvm_crash" "crash_native" -> "native_crash" "anr" -> "anr" + else -> null + } + + /** + * The `exit.reason` to report as an `app_exit` span for a non-crash exit, + * or null when the exit is neither a crash nor worth a diagnostic span. + */ + fun exitReasonFor(reasonName: String): String? = when (reasonName) { "low_memory" -> "low_memory" else -> null } diff --git a/android/unit-tests/src/test/kotlin/io/base14/scoutreact/ExitInfoFilterTest.kt b/android/unit-tests/src/test/kotlin/io/base14/scoutreact/ExitInfoFilterTest.kt index 3943cc0..d7eac5f 100644 --- a/android/unit-tests/src/test/kotlin/io/base14/scoutreact/ExitInfoFilterTest.kt +++ b/android/unit-tests/src/test/kotlin/io/base14/scoutreact/ExitInfoFilterTest.kt @@ -22,11 +22,11 @@ class ExitInfoFilterTest { "crash" to "jvm_crash", "crash_native" to "native_crash", "anr" to "anr", - "low_memory" to "low_memory", ) - /** Every other exit reason the OS can report. None may be emitted. */ + /** Every other exit reason the OS can report. None may become a crash. */ private val benign = listOf( + "low_memory", // OS reclaimed a cached process -- app_exit, not a crash "user_requested", "user_stopped", "exit_self", @@ -66,16 +66,29 @@ class ExitInfoFilterTest { } @Test - fun `the crash-class set is exactly these four reasons`() { + fun `the crash-class set is exactly these three reasons`() { val classified = (crashClass.keys + benign).filter { ScoutExitInfoClassifier.isCrashClass(it) } assertEquals( - setOf("crash", "crash_native", "anr", "low_memory"), + setOf("crash", "crash_native", "anr"), classified.toSet(), ) } + @Test + fun `only low_memory is reported as an app_exit span`() { + assertEquals("low_memory", ScoutExitInfoClassifier.exitReasonFor("low_memory")) + for (reasonName in crashClass.keys + benign - "low_memory") { + assertNull( + "'$reasonName' must not produce an app_exit record", + ScoutExitInfoClassifier.exitReasonFor(reasonName), + ) + } + assertEquals("app_exit", ScoutExitInfoClassifier.APP_EXIT) + assertEquals("scout.span", ScoutExitInfoClassifier.SPAN_KEY) + } + @Test fun `unknown future reason names are treated as benign`() { // reasonName() falls back to "reason_" for codes added by later diff --git a/docs/configuration.md b/docs/configuration.md index b0342ee..9bac251 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,9 +169,9 @@ Every auto-instrumentation can be turned off independently. All default to `true |---|---|---| | `enableAutoTapTracking` | `true` | Web: the DOM events listed under `interactionEvents`. RN: `onPress` on Pressable/Touchable* (via babel plugin). Emits `user_interaction` spans. | | `interactionEvents` | `['click','change','submit','input']` | Web only. Which DOM events auto-tap tracking listens to; the value lands on the span as `user_interaction.type`. See below. | -| `enableErrorTracking` | `true` | `window.onerror`, `unhandledrejection`, native crashes via KSCrash + NDK signal handler + MetricKit + ApplicationExitInfo. Emits `error`, `app_crash`, `native_crash` spans. | +| `enableErrorTracking` | `true` | `window.onerror`, `unhandledrejection`, native crashes via KSCrash + NDK signal handler + MetricKit + ApplicationExitInfo. Emits `error`, `app_crash`, `native_crash` spans. Android low-memory kills (`REASON_LOW_MEMORY`, the OS reclaiming a cached process) are emitted as `app_exit` (`exit.reason: low_memory`) and never count as a crash; the first launch with no exit-info watermark records one and reports nothing. | | `enableLifecycleTracking` | `true` | App `foreground`/`background`/`paused`/`resumed`. Emits `app_paused` / `app_resumed` spans + `view.in_foreground_periods_json` on screen_view. | -| `enableStartupTracking` | `true` | Cold/warm/hot start timing. Emits `app_startup` span. | +| `enableStartupTracking` | `true` | Cold/warm start timing. Emits `app_startup` spans with `app_startup.type` (`cold` \| `warm`), `app_startup.duration` (seconds) and `app_startup.duration_ms` (milliseconds). Native cold start is measured from the OS process start; web cold start from navigation start to `loadEventEnd`. | | `enableConnectivityTracking` | `true` | Network type changes (`wifi` → `cellular`), connection quality. | | `enablePerformanceMetrics` | `true` | Memory and CPU samples. | | `enableLongTaskDetection` | `true` | JS long tasks > `longTaskThresholdMs`. Emits `long_task` span. | diff --git a/package-lock.json b/package-lock.json index e3be9d5..267f22d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@base-14/scout-react", - "version": "0.1.17", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@base-14/scout-react", - "version": "0.1.17", + "version": "0.1.19", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", diff --git a/package.json b/package.json index ae3b04f..9d07cb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@base-14/scout-react", - "version": "0.1.17", + "version": "0.1.19", "description": "Zero-config OpenTelemetry RUM for React and React Native. Auto-captures clicks, navigation, errors, lifecycle, network, performance, and web vitals.", "license": "MIT", "author": "base-14", diff --git a/src/core/attributes.test.ts b/src/core/attributes.test.ts index d836254..cc27172 100644 --- a/src/core/attributes.test.ts +++ b/src/core/attributes.test.ts @@ -21,6 +21,7 @@ describe('attribute / span / metric name contract', () => { expect(ATTR.USER_INTERACTION_TARGET).toBe('user_interaction.target'); expect(ATTR.APP_STARTUP_TYPE).toBe('app_startup.type'); expect(ATTR.APP_STARTUP_DURATION).toBe('app_startup.duration'); + expect(ATTR.APP_STARTUP_DURATION_MS).toBe('app_startup.duration_ms'); expect(ATTR.LONG_TASK_DURATION).toBe('long_task.duration'); expect(ATTR.ANR_DURATION_MS).toBe('anr.duration_ms'); expect(ATTR.ANR_THRESHOLD_MS).toBe('anr.threshold_ms'); diff --git a/src/core/attributes.ts b/src/core/attributes.ts index 7ca3b35..3345220 100644 --- a/src/core/attributes.ts +++ b/src/core/attributes.ts @@ -82,6 +82,10 @@ export const ATTR = { DISPLAY_SCROLL_MAX_SCROLL_HEIGHT_TIME_MS: 'display.scroll.max_scroll_height_time_ms', APP_STARTUP_TYPE: 'app_startup.type', APP_STARTUP_DURATION: 'app_startup.duration', + /** Milliseconds. Pairs with the seconds-valued `app_startup.duration` + * the way `anr.duration_ms` replaced `anr.duration`; readers coalesce + * this over the legacy key while both exist. */ + APP_STARTUP_DURATION_MS: 'app_startup.duration_ms', APP_STARTUP_METRIC: 'app_startup.metric', APP_STARTUP_IS_PREWARMED: 'app_startup.is_prewarmed', APP_STARTUP_HAS_SAVED_INSTANCE_STATE_BUNDLE: diff --git a/src/core/scope.ts b/src/core/scope.ts index fbf40d3..366237c 100644 --- a/src/core/scope.ts +++ b/src/core/scope.ts @@ -1,2 +1,2 @@ export const SCOPE_NAME = 'base14.scout.react'; -export const SCOPE_VERSION = '0.1.17'; +export const SCOPE_VERSION = '0.1.19'; diff --git a/src/core/spans.ts b/src/core/spans.ts index 34ada7d..60320b4 100644 --- a/src/core/spans.ts +++ b/src/core/spans.ts @@ -18,6 +18,11 @@ export const SPAN = { * crash and must not depress crash-free rate. */ APP_UNCLEAN_EXIT: 'app_unclean_exit', NATIVE_CRASH: 'native_crash', + /** A process death that is NOT a crash but worth seeing: Android + * ApplicationExitInfo REASON_LOW_MEMORY (the OS reclaiming a cached + * background process). Play Console and Crashlytics don't count it, and + * neither does any crash-free rate; it only shows in session timelines. */ + APP_EXIT: 'app_exit', ERROR: 'error', LONG_TASK: 'long_task', FROZEN_FRAME: 'frozen_frame', diff --git a/src/native/index.ts b/src/native/index.ts index 7594f2f..9cb7e2c 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -314,12 +314,13 @@ export const Scout = { nativeStartMs !== null ? (Date.now() - nativeStartMs) / 1000 : core.timeSinceAppStartMs() / 1000; + const fbcMs = Math.round(coldDurationSec * 1000); core.emitSpan(SPAN.APP_STARTUP, { [ATTR.APP_STARTUP_TYPE]: 'cold', [ATTR.APP_STARTUP_DURATION]: coldDurationSec, + [ATTR.APP_STARTUP_DURATION_MS]: fbcMs, ...core.commonAttributes(), }); - const fbcMs = Math.round(coldDurationSec * 1000); core.emitSpan(SPAN.APP_VITAL, { [ATTR.VITAL_NAME]: 'fbc', [ATTR.VITAL_TYPE]: 'startup', diff --git a/src/native/instrumentations/lifecycle.ts b/src/native/instrumentations/lifecycle.ts index fe73ffd..204c18d 100644 --- a/src/native/instrumentations/lifecycle.ts +++ b/src/native/instrumentations/lifecycle.ts @@ -55,6 +55,7 @@ export function installNativeLifecycleTracker( scout.emitSpan(SPAN.APP_STARTUP, { [ATTR.APP_STARTUP_TYPE]: 'warm', [ATTR.APP_STARTUP_DURATION]: durationMs / 1000, + [ATTR.APP_STARTUP_DURATION_MS]: Math.round(durationMs), ...scout.commonAttributes(), }); } catch {} diff --git a/src/native/instrumentations/native-crash.test.ts b/src/native/instrumentations/native-crash.test.ts new file mode 100644 index 0000000..8505234 --- /dev/null +++ b/src/native/instrumentations/native-crash.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { EXIT_SPAN_KEY, toAppExitAttributes } from './native-crash'; +import { SPAN } from '../../core/spans'; + +describe('toAppExitAttributes', () => { + it('renames crash.* to exit.* with reason/description for the OS name and text', () => { + const out = toAppExitAttributes({ + 'crash.type': 'low_memory', + 'crash.reason': 'low memory', + 'crash.source': 'exit_info', + 'crash.importance': 400, + 'crash.pid': 15538, + 'crash.timestamp': '2026-09-19T13:54:14.244Z', + 'error.stack_trace': '', + breadcrumbs: '[]', + }); + expect(out).toEqual({ + 'exit.reason': 'low_memory', + 'exit.description': 'low memory', + 'exit.source': 'exit_info', + 'exit.importance': 400, + 'exit.pid': 15538, + 'exit.timestamp': '2026-09-19T13:54:14.244Z', + breadcrumbs: '[]', + }); + expect(Object.keys(out).some((k) => k.startsWith('crash.'))).toBe(false); + }); + + it('span marker matches the Kotlin collector contract', () => { + expect(EXIT_SPAN_KEY).toBe('scout.span'); + expect(SPAN.APP_EXIT).toBe('app_exit'); + }); +}); diff --git a/src/native/instrumentations/native-crash.ts b/src/native/instrumentations/native-crash.ts index e835626..aef7325 100644 --- a/src/native/instrumentations/native-crash.ts +++ b/src/native/instrumentations/native-crash.ts @@ -2,6 +2,35 @@ import { ATTR } from '../../core/attributes'; import { SPAN } from '../../core/spans'; import type { Scout } from '../../core/scout'; import { withSuppression } from '../soft-load'; + +/** + * Key the Android exit-info collector sets on a pending report that must be + * emitted as `app_exit` (a low-memory reclaim) instead of `native_crash`. + * Mirrors `ScoutExitInfoClassifier.SPAN_KEY`. + */ +export const EXIT_SPAN_KEY = 'scout.span'; + +/** + * `crash.*` → `exit.*` for the `app_exit` span shape: the OS reason name + * becomes `exit.reason`, its description `exit.description`, every other + * `crash.` keeps its key under the `exit.` prefix. Non-`crash.` keys pass + * through; an empty `error.stack_trace` (there never is one for an exit) is + * dropped. + */ +export function toAppExitAttributes( + attrs: Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(attrs)) { + if (k === ATTR.ERROR_STACK_TRACE && v === '') continue; + if (k === ATTR.CRASH_TYPE) out['exit.reason'] = v; + else if (k === ATTR.CRASH_REASON) out['exit.description'] = v; + else if (k.startsWith('crash.')) out[`exit.${k.slice('crash.'.length)}`] = v; + else out[k] = v; + } + return out; +} + export async function installNativeCrashReader(scout: Scout): Promise { let ScoutCrash: ScoutCrashApi | null = null; try { @@ -74,7 +103,11 @@ export async function installNativeCrashReader(scout: Scout): Promise { if (crashedSessionStart) { common[ATTR.SESSION_START_TIME] = crashedSessionStart; } - scout.emitSpan(SPAN.NATIVE_CRASH, { ...attrs, ...common }); + if (report[EXIT_SPAN_KEY] === SPAN.APP_EXIT) { + scout.emitSpan(SPAN.APP_EXIT, { ...toAppExitAttributes(attrs), ...common }); + } else { + scout.emitSpan(SPAN.NATIVE_CRASH, { ...attrs, ...common }); + } } catch {} } await ScoutCrash.clearPendingCrashes(); diff --git a/src/web/instrumentations/startup.ts b/src/web/instrumentations/startup.ts index 9d82f83..f395305 100644 --- a/src/web/instrumentations/startup.ts +++ b/src/web/instrumentations/startup.ts @@ -29,6 +29,7 @@ export function installStartupTracker(scout: Scout): () => void { scout.emitSpan(SPAN.APP_STARTUP, { [ATTR.APP_STARTUP_TYPE]: 'cold', [ATTR.APP_STARTUP_DURATION]: duration, + [ATTR.APP_STARTUP_DURATION_MS]: Math.round(duration * 1000), [ATTR.BROWSER_NAV_DOM_COMPLETE_MS]: nav.domComplete, [ATTR.BROWSER_NAV_DOM_CONTENT_LOADED_MS]: nav.domContentLoadedEventEnd, [ATTR.BROWSER_NAV_DOM_INTERACTIVE_MS]: nav.domInteractive, @@ -52,6 +53,7 @@ export function installStartupTracker(scout: Scout): () => void { scout.emitSpan(SPAN.APP_STARTUP, { [ATTR.APP_STARTUP_TYPE]: 'warm', [ATTR.APP_STARTUP_DURATION]: 0, + [ATTR.APP_STARTUP_DURATION_MS]: 0, ...scout.commonAttributes(), }); scout.addBreadcrumb(BREADCRUMB_TYPE.STARTUP, 'warm start (bfcache)');