@e-volv/logs-react-native
v0.1.0
Published
e-volv Observer SDK for React Native — logs, traces, JS crash capture and traceparent propagation. A wrapper with no transport of its own: finished events cross the native module into the Android (io.e-volv:logs-android) and iOS (e-volv-logs-swift) client
Maintainers
Readme
@e-volv/logs-react-native
e-volv Observer SDK for React Native — logs, traces, JS crash capture and
traceparent propagation against the e-volv ingest endpoint
(POST /api/public/v1/logs). A wrapper with no transport of its own: the
JS layer builds events with the browser core's model and redaction
(packages/logs-js/src/types.ts, vendored into src/types.ts) and hands
finished events over a TurboModule to the Android client
(io.e-volv:logs-android) or the iOS client (e-volv-logs-swift), which own
the disk queue, the retry schedule, the lifecycle flush and crash capture.
Companions: @e-volv/logs (Node/browser), e-volv-logs (Python),
e-volv-logs-go (Go). All speak one wire contract (docs/OBSERVER-SDK.md).
React Native 0.74+, New or Old Architecture. react and react-native are
peer dependencies.
Quick start
npm install @e-volv/logs-react-native
# iOS:
cd ios && pod install # New Architecture: codegen runs from package.json's codegenConfigimport EvolveLogs from '@e-volv/logs-react-native';
EvolveLogs.init({
key: 'evk_pub_…', // public project key — the ONLY kind allowed in an app
appId: 'com.acme.shop', // Android application id / iOS bundle id
service: 'shop-app',
environment: 'production',
release: '1.4.2', // match the --release you upload source maps for
});
EvolveLogs.info('order created', { orderId: 'o_1', total: 42.5 });
try {
await charge();
} catch (err) {
EvolveLogs.exception(err, { orderId: 'o_1' }); // exception.type/message/stack
}
await EvolveLogs.span('db.query', () => db.query('…'), { table: 'orders' });
// an exception inside the callback ends the span as failed and rethrows
// Queue consumers / deep links: continue an inbound traceparent.
EvolveLogs.runWithTraceparent(job.traceparent, () => {
EvolveLogs.info('job received', { jobId: job.id });
EvolveLogs.span('queue.consume', () => handle(job));
});If key or appId is missing the client is a no-op and warns once. A
server key (evk_…) is refused — it has no app-id allowlist and no
per-install quota, so it must never ship in a binary; mobile SDKs only accept
evk_pub_… public keys. The JS layer holds no key material beyond the
configure() call into native.
Launch flags
EvolveLogs.init({ key: 'evk_pub_…', appId: 'com.acme.shop' });
// Reads are synchronous — a snapshot the native client holds, never a bridge
// round trip. Before the first snapshot they return your default.
const checkout = EvolveLogs.flags.bool('checkout.new', false);
// Login/logout: re-fetch values for the new context.
await EvolveLogs.flags.identify({ targetingKey: user.id, plan: user.plan });
await EvolveLogs.flags.ready(); // first network confirmation
EvolveLogs.flags.onChange((keys) => {
// native pushed a new snapshot
if (keys.includes('checkout.new')) render();
});
await EvolveLogs.flags.verify(); // the install check (GET /ping)With the native module linked, the native flags client is the single
fetcher — it owns fetching, the on-device cache (§6), polling and lifecycle
refresh, and pushes a JSON snapshot over the bridge on every change. Without
one (Expo Go, nativeTransport: false) the shared @e-volv/flags-client
core fetches with fetch, caches in AsyncStorage when the optional
@react-native-async-storage/async-storage peer is installed, and refreshes
on AppState active. The two are never active at once.
Options: flags: false disables flags; otherwise flags takes the client
flags options of contract §4 (context, pollIntervalSeconds, cache,
privateAttributes, exposures.*). Exposures are recorded on the JS side,
where reads happen, and drained to native in batches — a read is recorded
exactly once.
What runs where
| Concern | Owner |
| ----------------------------------------------------------------------------------------------------- | ----------------- |
| Event model, redaction, span/trace ids, JS crash capture, fetch/XHR traceparent | JS layer (src/) |
| Disk queue, batching, gzip, retry, lifecycle flush, x-evolve-app-id / x-evolve-install-id headers | Native clients |
| Native crashes (Kotlin exceptions, ANRs, Obj-C/Swift exceptions, signals) | Native clients |
JS crashes are captured by wrapping ErrorUtils.getGlobalHandler /
setGlobalHandler, chaining the previous handler so the red box and the
fatal-crash behaviour are unchanged. Because JS and native events carry the
same key, app id and release, both crash streams land in one project.
Options (init)
key (public evk_pub_…), appId, service, environment, release,
redactKeys (extra keys merged into the standard
password|secret|token|authorization|cookie|set-cookie|api[-_]?key
backstop), sampleRate (0–1), nativeTransport (default true),
networkInstrumentation (default true), captureJsErrors (default
true), url + fetchImpl (only used when nativeTransport: false),
flags (Launch flags options or false, see above).
nativeTransport: false makes the JS layer ship events itself with
fetch (identity encoding, the mobile headers sent from JS). It exists for
development, tests and the conformance runner — it has no disk queue, so it
is not a production configuration.
Network instrumentation
With networkInstrumentation: true (default), fetch and XMLHttpRequest
made inside a span/trace get a W3C traceparent header — an existing header
is never overwritten — and traceparent is read off responses
(EvolveLogs.getLastResponseTraceparent()), so a later JS crash can be
attached to the server trace that caused it.
Architecture
New Architecture: src/spec/NativeEvolveLogs.ts is the codegen spec
(codegenConfig in package.json); the JSI TurboModule
(NativeEvolveLogs) serves all four methods with no bridge serialization.
Old Architecture: the same Kotlin class / ObjC++ module is served through
the bridge (hand-written spec on Android, NativeModules fallback on the JS
side). Autolinking picks the package up on both platforms; no manual
linking step.
TurboModule method map
| Method | Direction | Purpose |
| ---------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
| configure(config) | JS → native, once | Init the native client (key, appId, service, environment, release, redactKeys, sampleRate) |
| emitEvent(eventJson) | JS → native, per event | Hand a finished, redacted event (JSON) to the native queue |
| flush() | JS → native, promise | Resolve when the native queue is empty |
| getInstallId() | native → JS, promise | The SDK install id (also sent as x-evolve-install-id) |
Native interfaces (S5 clients, wired in S6)
The bridges call the real native clients — no assumed facades:
- Android (
packages/logs-jvm/android,io.e-volv:logs-android):EvolveLogsAndroid.init(context, EvolveAndroidOptions)(built withEvolveAndroidOptions.builder(); a server key is refused withIllegalArgumentExceptionand surfaced once),EvolveLogsAndroid.clientOrNull()?.emitJson(eventJson),client.flush()(the bridge moves it off the JS thread),client.installId(). - iOS (
packages/logs-swift, podEvolveLogs):EvolveLogs.initialize(EvolveLogsOptions(key:url:service:environment:release:redactKeys:sampleRate:appID:)),client.emit(json:),client.flush()(on a utility queue),client.installID.
The compile gate for both is ci.yml's react-native-build job, which builds
the generated example/ app (both architectures on Android) against the
published native artifacts.
Source maps (Hermes)
Release builds ship minified Hermes bytecode, so JS stacks arrive as bytecode frames. Upload the source map per release — make it a release-pipeline step, one per platform:
# Android (index.android.bundle.map) and iOS (main.jsbundle.map):
evolve-logs upload-artifacts --key evk_… --url https://api.e-volv.io \
--release 1.4.2 --platform react-native --type sourcemap \
index.android.bundle.map(evolve-logs is the CLI in tools/logs-cli, or npx @e-volv/logs.)
Without the map the error group still forms and counts, but frames show
minified names and no file:line.
Conformance
The JS layer conforms in Node through the pure-JS transport:
npx tsx packages/logs-conformance/run.ts packages/logs-conformance/fixture.json -- \
npx tsx packages/logs-react-native/runner/conformance-runner.tsLaunch flags conform through the same pure-JS path (the Expo Go fallback — the native path is S5's own conformance rows):
npx tsx packages/flags-conformance/run.ts client -- \
npx tsx packages/logs-react-native/runner/flags-conformance-runner.tsUnder npx react-native run-android / run-ios, the same runner conforms
through the native path with nativeTransport: true (the stub URL is
reachable from the device/emulator via adb reverse / the dev-server host).
Limitations
- The Kotlin/Swift in
android/andios/is compile-gated by ci.yml'sreact-native-buildjob (the generatedexample/app on both Android architectures and the iOS simulator). The underlying native clients are unit-tested in their own packages (Robolectric / XCTest). - In the default native mode the JS buffer is a hand-off, not a queue: if the native module is missing, events are dropped with a startup warning.
- XHR interception wraps
send/setRequestHeaderon the prototype; a request that setstraceparentitself is left untouched by contract, but exotic XHR subclasses that bypass the prototype are not covered. nativeTransport: falsekeeps the buffer in memory and sends identity (ungzipped) bodies — development and conformance only.
