Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 26 additions & 4 deletions android/src/main/java/io/base14/scoutreact/ScoutCrashModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

}
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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_<n>" for codes added by later
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ 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 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. |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@base-14/scout-react",
"version": "0.1.18",
"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",
Expand Down
2 changes: 1 addition & 1 deletion src/core/scope.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export const SCOPE_NAME = 'base14.scout.react';
export const SCOPE_VERSION = '0.1.18';
export const SCOPE_VERSION = '0.1.19';
5 changes: 5 additions & 0 deletions src/core/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
33 changes: 33 additions & 0 deletions src/native/instrumentations/native-crash.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
35 changes: 34 additions & 1 deletion src/native/instrumentations/native-crash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<k>` 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<string, string | number | boolean>,
): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {};
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<void> {
let ScoutCrash: ScoutCrashApi | null = null;
try {
Expand Down Expand Up @@ -74,7 +103,11 @@ export async function installNativeCrashReader(scout: Scout): Promise<void> {
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();
Expand Down