Skip to content
Open
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
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,100 @@ is a confusing state to end up in).

Run `npx pod-install` after installing the npm package.

# Optional: Sentry integration

This module can forward its native-side and JS-side lifecycle events
into the host app's `@sentry/react-native`. Sentry is opt-in — if you
don't register the plugin and don't import the sub-export, no Sentry
code path is exercised and no DSN ends up in your APK/IPA. See
[`docs/ARCHITECTURE.md` §7](./docs/ARCHITECTURE.md) for the
architectural overview and
[`docs/sentry-integration-plan.md`](./docs/sentry-integration-plan.md)
for the design plan and per-phase status.

### 1. Install `@sentry/react-native` in your app

`@sentry/react-native` is an optional peer dep of this module. Install
it in the host app and run `Sentry.init(...)` once at startup as
documented at <https://docs.sentry.io/platforms/react-native/>. The
runtime classes shipped with `@sentry/react-native` also satisfy the
Android FGS-process bridge — no extra Android dependency to declare.

### 2. Register the Expo config plugin

In `app.config.js` (must be `.js`, not `app.json`, to read `process.env`):

```js
export default {
expo: {
plugins: [
["@comapeo/core-react-native", {
sentry: {
dsn: process.env.SENTRY_DSN,
environment: process.env.SENTRY_ENVIRONMENT ?? "production",
// Optional: opt internal/test builds into the §9 capture-application-data
// toggle by default. Production stays off-by-default.
captureApplicationDataDefault:
(process.env.SENTRY_ENVIRONMENT ?? "production") !== "production",
},
}],
],
},
};
```

The plugin runs at `expo prebuild` and bakes the DSN, environment, and other
options into AndroidManifest meta-data and Info.plist keys. Sourcing values
from `process.env` lets EAS build profiles produce different builds without
code changes — see
[`docs/sentry-integration-plan.md` §4.1](./docs/sentry-integration-plan.md)
for the matching `eas.json` example with per-profile env vars.

### 3. Hand off the host's Sentry SDK

```ts
import * as Sentry from "@sentry/react-native";
import { configureSentry } from "@comapeo/core-react-native/sentry";

Sentry.init({ /* options — DSN/environment/release auto-loaded from plist/manifest */ });

configureSentry({ sentry: Sentry });
```

After this call, the module's lifecycle ERROR transitions and `messageerror`
events are captured to your Sentry project tagged with the relevant phase
(`rootkey`, `starting-timeout`, `node-runtime-unexpected`, etc.). State
transitions show up as breadcrumbs that ride along on the next event.

### What gets captured automatically

Once the plugin is registered with a `dsn`, the module captures three
streams without any further setup:

- **JS-process events** (via the adapter you pass to `configureSentry`):
state-machine ERROR transitions and `messageerror` parse failures
tagged `proc:main`, `layer:rn`. State transitions emit breadcrumbs
on every cycle.
- **FGS-process events** (Android only — `:ComapeoCore` foreground
service): boot transaction (`comapeo.boot`) with phase spans
(`boot.rootkey-load`, `boot.init-frame`), state-transition
breadcrumbs, control-frame breadcrumbs, FGS-lifecycle breadcrumbs,
watchdog-timeout events (`timeout:startup`, `timeout:fgsStop`),
and rootkey-load `captureException` — all tagged `proc:fgs`,
`layer:native` so the dashboard can split FGS-originated events
from main-process events.
- **Backend-process events** (Phase 3, not yet shipped) — Node-side
RPC method spans and exceptions tagged `proc:backend`.

The FGS-process Sentry SDK is initialised automatically in
`ComapeoCoreService.onCreate` from the manifest meta-data your
config plugin wrote. There's no extra configuration required for
multi-process Android apps using this module — that's the
`SentryFgsBridge` doing the work behind the scenes. If
`@sentry/react-native` isn't installed (so `io.sentry.*` isn't on
the runtime classpath), the bridge stays inert and the module
continues to function unchanged.

# Contributing

Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
Expand Down
17 changes: 17 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,23 @@ dependencies {
implementation "androidx.core:core-ktx:1.16.0"
compileOnly "com.facebook.fbjni:fbjni:0.3.0"

// Sentry Android SDK — `compileOnly` so this module doesn't pull
// sentry-android into consumers who never use Sentry. The runtime
// classes are expected to come transitively from the consumer's
// `@sentry/react-native@^6` dep (see plan §5.1 / package.json's
// optional peer dep). When the consumer hasn't installed Sentry,
// `SentryFgsBridge` short-circuits via `Class.forName` so the
// missing classpath never reaches a verifier crash.
//
// Pin matches the API surface @sentry/react-native@6.x exposes
// (sentry-android 7.20.x). Bumping should be done in lock-step
// with the @sentry/react-native peer-dep range.
compileOnly "io.sentry:sentry-android-core:7.20.1"
// Test classpath needs the runtime classes so JVM unit tests
// can exercise the Impl path. Robolectric isn't on the test
// classpath, so tests that need a real Context use a fake.
testImplementation "io.sentry:sentry-android-core:7.20.1"

// JVM unit test dependencies
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
Expand Down
46 changes: 46 additions & 0 deletions android/src/main/java/com/comapeo/core/ComapeoCoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,44 @@ class ComapeoCoreService : Service() {
override fun onCreate() {
super.onCreate()
activeInstanceCount++

// Phase 2b: initialise the FGS-process Sentry SDK before
// anything that might emit a breadcrumb / capture an
// exception. The host's `@sentry/react-native` runs its
// init in `MainApplication.onCreate`, which only fires in
// the *main* process — the FGS gets a fresh Application
// instance and an empty Sentry hub. Reading the manifest
// returns `null` when the consumer didn't register the
// Expo plugin with a `sentry: { ... }` argument, in which
// case the bridge stays inert.
SentryConfig.loadFromManifest(applicationContext)?.let { cfg ->
SentryFgsBridge.init(applicationContext, cfg)
SentryFgsBridge.addBreadcrumb(
category = "comapeo.fgs",
message = "ComapeoCoreService.onCreate",
level = "info",
)
}

nodeJSService = NodeJSService(applicationContext)
log("The service has been created".uppercase())
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
log("onStartCommand startId: $startId action: ${intent?.action}")

// FGS-lifecycle breadcrumb (§7.4.6). Captures the action
// routing decision — useful for debugging "why did the FGS
// start" questions in production.
SentryFgsBridge.addBreadcrumb(
category = "comapeo.fgs",
message = "onStartCommand",
data = mapOf(
"startId" to startId,
"action" to (intent?.action ?: "(restart)"),
),
)

when (intent?.action) {
Actions.USER_FOREGROUND.name -> {
startService()
Expand Down Expand Up @@ -107,6 +138,11 @@ class ComapeoCoreService : Service() {
log("onDestroy")
isServiceStarted = false
activeInstanceCount--
SentryFgsBridge.addBreadcrumb(
category = "comapeo.fgs",
message = "ComapeoCoreService.onDestroy",
level = "info",
)
serviceScope.launch {
try {
withTimeout(10_000) {
Expand All @@ -115,6 +151,16 @@ class ComapeoCoreService : Service() {
log("NodeJS service stopped")
} catch (e: Exception) {
log("Error stopping NodeJS service: ${e.message}")
// Plan §7.4.4: stop-timeout is a "we have to kill the
// FGS via Process.killProcess, observability is gone
// shortly" signal. Capture before the kill so the
// event has time to flush. Keep level at error — a
// 10s shutdown timeout is always actionable.
SentryFgsBridge.captureMessage(
"comapeo: FGS stop timeout fired",
level = "error",
tags = mapOf("timeout" to "fgsStop"),
)
}
log("The service has been destroyed".uppercase())
// Only kill the process if no new service instance has started.
Expand Down
Loading
Loading