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
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,42 @@ jobs:
- name: audit (production deps only)
run: npm audit --omit=dev --audit-level=high

# `make ci` runs plain `vitest run`, which writes no coverage/ — so the
# report has to be produced explicitly for the upload below to have
# anything to collect.
- name: coverage
if: matrix.node == '22'
run: make test-coverage

- name: upload coverage
if: matrix.node == '22'
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
if-no-files-found: ignore
if-no-files-found: error

android-unit-tests:
name: android unit tests (kotlin)
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

# The Android library itself needs a host app's Gradle build (it depends
# on :expo-modules-core), so this project compiles only the Android-free
# decision cores — exit-info classification and ANR hang detection.
# Gradle's version comes from the checked-in wrapper, so CI and
# `make test-android` run the identical toolchain.
- uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true

- name: kotlin unit tests
run: ./gradlew test --console=plain
working-directory: android/unit-tests
23 changes: 20 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,26 @@ jobs:
node-version: '24'
registry-url: 'https://registry.npmjs.org'

- run: npm ci
- run: npm run build --if-present
- run: npm test
- run: npm ci --no-fund --no-audit

# npm publishes whatever package.json says, not what the tag says, so an
# unsynced tag silently republishes the previous version under a new
# release. The scope.contract test (run by `make ci` below) pins
# SCOPE_VERSION to package.json, so this completes the chain:
# git tag -> package.json -> the version reported on every span.
- name: tag matches package.json version
run: |
tag="${GITHUB_REF_NAME#v}"
pkg="$(node -p "require('./package.json').version")"
if [ "$tag" != "$pkg" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match package.json version $pkg"
exit 1
fi

# Full pipeline, not just tests — a tag can be pushed from any commit,
# so publish cannot assume the CI workflow ever ran on it.
- run: make ci

- run: npm publish --provenance --access public

- run: gh release create "$GITHUB_REF_NAME" --generate-notes
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ android/build/
android/.cxx/
android/.gradle/
android/local.properties
android/unit-tests/build/
android/unit-tests/.gradle/
android/unit-tests/.kotlin/
ios/build/
ios/Pods/
ios/*.xcworkspace/
Expand Down
107 changes: 107 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.12] - 2026-08-03

Brings scout-react to parity with scout-flutter 0.1.23's production-hardening
work. **This release changes defaults.** Upgrading without action silently
turns off periodic vitals metrics, export retries and offline buffering, and
slows trace/log export from 5s to 30s. All of it is opt-in-able — see below.

### Changed — behavior (action required)

- **Vitals metrics are now opt-in.** `enableFrameMetrics`, `enableMemoryMetrics`
and `enableCpuMetrics` default to `false` (were `true`). These are the
highest-volume signals the SDK produces. A default `Scout.initialize()` now
emits **no periodic metrics at all**. Restore with
`{ enableFrameMetrics: true, enableMemoryMetrics: true, enableCpuMetrics: true }`.
- **Delivery is at-most-once.** `exportRetry.maxRetries` defaults to `0` (was
`3`) and `offlineBuffer.enabled` to `false` (was `true`, with 5000/2000/5000
item caps now `0`). Retrying an ambiguous failure — a timeout the collector
may already have ingested — re-delivers identical span IDs; no duplicates is
worth more than lossless delivery for RUM data. Restore with
`{ exportRetry: { maxRetries: 3 }, offlineBuffer: { enabled: true, maxItems: {...} } }`.
- **Export cadence is 30s for all signals** (traces and logs were 5s; metrics
unchanged at 30s). Tune with `exportIntervalSeconds`.
- **Vitals sampling is 60s** (memory/CPU/frame were 10s). Tune with
`vitalsCollectionIntervalSeconds`.
- **Android exit-info records are no longer all crashes.** Only `REASON_CRASH`,
`REASON_CRASH_NATIVE`, `REASON_ANR` and `REASON_LOW_MEMORY` are reported.
Swipe-from-recents, Force Stop, self-exit, `REASON_SIGNALED`,
`REASON_EXCESSIVE_RESOURCE_USAGE` and `REASON_INITIALIZATION_FAILURE` are
normal process exits and are dropped — they were inflating crash counts with
ordinary user actions.
- **`crash.type` on exit-info records now carries the real reason**
(`jvm_crash`, `native_crash`, `anr`, `low_memory`) instead of the constant
`"exit_info"`. **Dashboards filtering `crash.type = 'exit_info'` must move to
the new `crash.source = 'exit_info'` attribute.**
- **Breadcrumbs are session-scoped.** A relaunched session no longer inherits
the previous session's breadcrumb trail; those crumbs are attached to crash
reports drained from the session that died instead.
- **`app_crash` is attributed to the crashed session.** `session.id` and
`session.start_time` on the span are now the dead session's, and
`crash.timestamp` is when the app was last known alive rather than when the
crash was noticed on relaunch.

### Added

- `exportIntervalSeconds` (default 30, min 1) — one cadence for traces, logs
and metrics. Per-signal `traceExportIntervalMs` / `logExportScheduledDelayMs`
/ `metricExportIntervalMs` still win when set explicitly.
- `metricExportIntervalSeconds` — metrics-only override.
- `maxExportBatchSize` (512) and `maxQueueSize` (2048) — applied to the trace
and log processors.
- `vitalsCollectionIntervalSeconds` (default 60, min 1) — sampling cadence for
memory, CPU, frame and battery vitals.
- `scout.react.version` resource attribute on every span, metric and log, on
both web and native. Pinned to `package.json` by a CI contract test and not
overridable via `resourceAttributes`.
- `crash.source` — which detection path produced a crash record.
- `crash.drain_app_state`, `crash.drain_process_start_time` and
`crash.drain_uptime_secs` on drained native crash reports.
- Kotlin unit tests (`android/unit-tests`, `make test-android`) covering the
exit-info classification and ANR hang-detection rules, plus a CI job.
- `make check-exports` (`publint` + `arethetypeswrong`), wired into `make ci`.
Both pack the real tarball, so the exports map and the type-resolution matrix
are checked as consumers see them.

### Fixed

- **Duplicate exports.** The stock `@opentelemetry/exporter-*-otlp-http`
exporters wrap their transport in `RetryingTransport`, which re-sends up to
five more times on 429/502/503/504 and on network errors — stacked under the
SDK's own retry wrapper, one batch could reach the collector ~20 times.
Replaced with a fetch-based OTLP/JSON exporter where one export is exactly
one request; retry policy now lives in one place and is off by default.
- **Offline buffering was silently dead at zero retries.** The retry wrapper
returned the exporter untouched when `maxRetries <= 0`, so the hook that
feeds the offline buffer never ran. It now wraps whenever a buffer or debug
logging is wired up.
- **Console-capture feedback loop.** With `captureConsole` + `debug`, the SDK's
own `[scout]` diagnostics were captured as logs, which produced more exports,
which logged again. `[scout]`-prefixed lines are no longer captured.
- **Web `crash.started_at` was the marker's write time**, not the session's
start time.
- ANR detection latency: the watchdog polls every 100ms instead of
`threshold/10` (500ms at the default threshold), so a hang is reported at
threshold + ~0.1s.
- **Subpath types were unresolvable under classic `moduleResolution: "node"`.**
`@base-14/scout-react/native`, `/react` and `/babel-plugin` all failed to
resolve types — which covers most React Native tsconfigs. Added
`typesVersions` mappings.
- **CJS consumers got ESM types.** The single top-level `types` condition was
reused for `require`, so a CJS `import` saw ESM declarations ("masquerading
as ESM"). `import` and `require` now carry their own `types` pointing at
`.d.ts` / `.d.cts` respectively.
- **`./native` was unloadable from Node.** `tsconfig.native.json` emits
CommonJS, but the root package is `"type": "module"`, so Node read
`dist/native/**` and `dist/core/**` as ESM and every `require` threw. Both
directories now carry a `{"type":"commonjs"}` marker. Metro was unaffected
either way; this fixes Jest, SSR and other Node-based consumers.
- **`./babel-plugin` shipped no type declarations at all.** Added
`babel-plugin/index.d.cts`, including the `components` / `handlers` options.

### Removed

- Dependencies on `@opentelemetry/exporter-trace-otlp-http`,
`@opentelemetry/exporter-metrics-otlp-http` and
`@opentelemetry/exporter-logs-otlp-http`, replaced by a direct dependency on
`@opentelemetry/otlp-transformer`.

## [0.1.11] - 2026-06-30

### Added
Expand Down
42 changes: 36 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.PHONY: help install build typecheck test test-watch test-coverage lint lint-fix \
fmt fmt-check clean audit ci all
.PHONY: help install build typecheck test test-watch test-coverage test-android \
lint lint-fix fmt fmt-check check-exports clean audit ci all

NODE_BIN := node_modules/.bin

Expand All @@ -10,8 +10,9 @@ install: ## Install dependencies
npm ci --no-fund --no-audit

build: ## Build the package — tsup bundles web; tsc emits native unbundled so Metro sees literal require()s
$(NODE_BIN)/tsup
$(NODE_BIN)/tsc -p tsconfig.native.json
# Delegates to the npm script so this and `prepare` (which runs on
# `npm ci` and at publish time) can never drift apart.
npm run build

typecheck: ## TypeScript check without emit
$(NODE_BIN)/tsc --noEmit
Expand All @@ -25,6 +26,9 @@ test-watch: ## Run unit tests in watch mode
test-coverage: ## Run unit tests with coverage report
$(NODE_BIN)/vitest run --coverage

test-android: ## Run the Kotlin unit tests (needs only a JDK 17+; the wrapper fetches Gradle)
cd android/unit-tests && ./gradlew test --console=plain

lint: ## Run eslint
$(NODE_BIN)/eslint src

Expand All @@ -37,15 +41,41 @@ fmt: ## Format src with prettier
fmt-check: ## Verify src formatting
$(NODE_BIN)/prettier --check src

# Both tools pack the real tarball (which re-runs `prepare`), so they check what
# consumers actually install rather than the working tree.
#
# `cjs-only-exports-default` is ignored deliberately: dist/native is tsc's
# CommonJS output, so its `export default Scout` compiles to
# `exports.default` + `__esModule` without `module.exports`. Metro's babel
# interop honours that, but a Node ESM consumer's `import Scout from
# '.../native'` gets the namespace object instead of the class. Fixing it means
# either dropping the documented default export or patching tsc's output, so
# it stays a known caveat rather than a silent CI failure.
#
# TODO: decide on publint's `"sideEffects": false` suggestion. It would let
# bundlers tree-shake unused instrumentation out of web apps, but it is only
# safe if no module in the graph does real work at import time — and several
# do: 17 modules call `withSuppression(() => require(...))` for optional peers
# at module scope, most of them under src/native/instrumentations/. Those
# requires are exactly the import-time work a `sideEffects: false` claim
# asserts does not happen. Getting it wrong drops telemetry code from
# consumers' production builds silently, with no error to trace it back to.
# Needs a per-module audit of top-level statements plus a bundled-app smoke
# test before flipping — not a one-line change.
check-exports: ## Lint the published exports map + type resolution (publint + attw)
$(NODE_BIN)/publint
$(NODE_BIN)/attw --pack . --ignore-rules cjs-only-exports-default

audit: ## Run npm audit (prod + all)
@echo "--- production ---"
@npm audit --omit=dev || true
@echo "--- all ---"
@npm audit || true

clean: ## Remove build outputs
clean: ## Remove build outputs (JS + Kotlin)
rm -rf dist coverage
rm -rf android/unit-tests/build android/unit-tests/.gradle android/unit-tests/.kotlin

ci: fmt-check lint typecheck test build ## Mirror the CI pipeline locally
ci: fmt-check lint typecheck test build check-exports ## Mirror the CI pipeline locally

all: install audit ci ## Install + audit + ci
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Scout.registerRootComponent(App);

`registerRootComponent` installs a root-level error + touch boundary via RN's `AppRegistry.setWrapperComponentProvider`. Every tap becomes a `user_interaction` span, every render error becomes an `error` span (with `error.component_stack`), automatically.

> **Running the native entry under plain Node** (Jest without a React Native preset, SSR, scripts) — use the named export, `import { Scout } from '@base-14/scout-react/native'`. `dist/native` is CommonJS so Metro can see literal `require()`s; Node's ESM interop does not honour its `__esModule` marker, so the default import resolves to the module namespace rather than the class. Metro and Babel handle the default import correctly, so app code is unaffected.

Then in your `App.tsx`:

```tsx
Expand Down
52 changes: 29 additions & 23 deletions android/src/main/java/io/base14/scoutreact/ScoutAnrWatchdog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ class ScoutAnrWatchdog(
val now = SystemClock.uptimeMillis()
lastMainHeartbeatMs = now
lastJsHeartbeatMs = now
val pollMs = (thresholdMs / 10).coerceAtLeast(200)
// Fixed 100ms cadence: detection lands at threshold + ~0.1s instead of
// threshold + up to a full poll (500ms at the default 5s threshold).
val pollMs = POLL_INTERVAL_MS.coerceAtMost(thresholdMs)
Log.i(TAG, "start threshold=${thresholdMs}ms poll=${pollMs}ms")
watchdogThread = Thread({
var inHangMain = false
var inHangJs = false
val mainDetector = ScoutHangDetector(thresholdMs, MAIN_RECOVERY_MS)
val jsDetector = ScoutHangDetector(thresholdMs, JS_RECOVERY_MS)
var pendingMainHeartbeat = false
var cycle = 0L
while (running) {
Expand All @@ -44,31 +46,25 @@ class ScoutAnrWatchdog(
val elapsedMain = nowMs - lastMainHeartbeatMs
val elapsedJs = nowMs - lastJsHeartbeatMs
cycle++
if (cycle % 10 == 0L || elapsedMain > pollMs * 2 || elapsedJs > pollMs * 2) {
// 100ms polling makes a per-cycle log far too chatty; log once a
// second, or whenever a thread is visibly lagging.
if (cycle % LOG_EVERY_N_CYCLES == 0L ||
elapsedMain > MAIN_RECOVERY_MS ||
elapsedJs > JS_RECOVERY_MS
) {
Log.i(
TAG,
"cycle=$cycle main=${elapsedMain}ms js=${elapsedJs}ms inHang(m/j)=$inHangMain/$inHangJs",
"cycle=$cycle main=${elapsedMain}ms js=${elapsedJs}ms " +
"inHang(m/j)=${mainDetector.inHang}/${jsDetector.inHang}",
)
}
if (elapsedMain >= thresholdMs) {
if (!inHangMain) {
inHangMain = true
Log.w(TAG, "MAIN THREAD ANR elapsed=${elapsedMain}ms")
fireSafe(elapsedMain, "main")
}
} else if (elapsedMain < pollMs) {
if (inHangMain) Log.i(TAG, "main hang ended")
inHangMain = false
if (mainDetector.onSample(elapsedMain)) {
Log.w(TAG, "MAIN THREAD ANR elapsed=${elapsedMain}ms")
fireSafe(elapsedMain, "main")
}
if (elapsedJs >= thresholdMs) {
if (!inHangJs) {
inHangJs = true
Log.w(TAG, "JS THREAD ANR elapsed=${elapsedJs}ms")
fireSafe(elapsedJs, "js")
}
} else if (elapsedJs < pollMs * 4) {
if (inHangJs) Log.i(TAG, "js hang ended")
inHangJs = false
if (jsDetector.onSample(elapsedJs)) {
Log.w(TAG, "JS THREAD ANR elapsed=${elapsedJs}ms")
fireSafe(elapsedJs, "js")
}
}
Log.i(TAG, "watchdog loop exited")
Expand Down Expand Up @@ -97,5 +93,15 @@ class ScoutAnrWatchdog(

companion object {
private const val TAG = "ScoutAnrWatchdog"
private const val POLL_INTERVAL_MS = 100L
/** Elapsed-since-ping below which the main thread counts as recovered. */
private const val MAIN_RECOVERY_MS = 500L
/**
* The JS thread heartbeats far less often than the main looper, so it
* needs a slacker recovery window to avoid flapping in and out of hang.
*/
private const val JS_RECOVERY_MS = 2000L
/** 50 cycles × 100ms = one heartbeat log every 5s, as before. */
private const val LOG_EVERY_N_CYCLES = 50L
}
}
Loading
Loading