App metrics
See how your app really performs on users’ devices: how long it takes to start and become
interactive, how it handles network requests, when it crashes. @symbiote-native/app-metrics
wraps expo-app-metrics
(startup, frame rate, memory, network request, crash and session metrics). The functions and the
Session and NetworkRequestObserver classes are shared by every adapter. AppMetricsRoot ships
on all five adapters, and AppMetricsErrorBoundary on React, Vue, Solid and Svelte, each wrapping
that framework’s own error-catch primitive.
| OS platform | Support |
|---|---|
| iOS | live |
| Android | live |
| Framework adapter | Support |
|---|---|
| React | live |
| Vue | live |
| Angular | live (no AppMetricsErrorBoundary yet) |
| Svelte | live |
| Solid | live |
Installation
Section titled “Installation”npm install @symbiote-native/app-metricsScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --app-metrics (or
add --app-metrics in an existing app) installs and wires this for you - see
@symbiote-native/cli.
expo-app-metrics and expo-modules-core come along as regular dependencies, pinned to exact
versions. Never install either yourself, and never add the expo meta-package to your project (it
bundles its own Metro/Babel pipeline, which conflicts with this project’s own).
No permission string or manifest entry is needed.
Wrap your app in AppMetricsRoot. It marks the first render for the startup metric and, where the
adapter has one, catches render errors below it. Then call markInteractive when the app can be
used.
import { markInteractive } from '@symbiote-native/app-metrics';import { AppMetricsRoot } from '@symbiote-native/app-metrics/react';
export default function App() { return ( <AppMetricsRoot errorBoundaryFallback={null}> <Home onReady={() => markInteractive({ routeName: 'Home' })} /> </AppMetricsRoot> );}AppMetricsRoot.wrap(App) is a React-only helper that wraps a component for you.
<script setup lang="ts">import { markInteractive } from '@symbiote-native/app-metrics';import { AppMetricsRoot } from '@symbiote-native/app-metrics/vue';</script>
<template> <AppMetricsRoot :errorBoundaryFallback="null"> <Home @ready="markInteractive({ routeName: 'Home' })" /> </AppMetricsRoot></template>import { Component } from '@angular/core';import { markInteractive } from '@symbiote-native/app-metrics';import { AppMetricsRoot } from '@symbiote-native/app-metrics/angular';
@Component({ standalone: true, imports: [AppMetricsRoot], template: ` <app-metrics-root> <Home (ready)="onReady()" /> </app-metrics-root> `,})export class App { onReady(): void { markInteractive({ routeName: 'Home' }); }}Angular’s AppMetricsRoot is a plain wrapper that marks the first render. It takes no
errorBoundaryFallback, since there is no error boundary yet (see Notes).
<script lang="ts"> import { markInteractive } from '@symbiote-native/app-metrics'; import { AppMetricsRoot } from '@symbiote-native/app-metrics/svelte';</script>
<AppMetricsRoot errorBoundaryFallback={null}> <Home onReady={() => markInteractive({ routeName: 'Home' })} /></AppMetricsRoot>import { markInteractive } from '@symbiote-native/app-metrics';import { AppMetricsRoot } from '@symbiote-native/app-metrics/solid';
export default function App() { return ( <AppMetricsRoot errorBoundaryFallback={null}> <Home onReady={() => markInteractive({ routeName: 'Home' })} /> </AppMetricsRoot> );}Catch errors in a subtree
Section titled “Catch errors in a subtree”AppMetricsErrorBoundary reports a caught error and renders a fallback. The fallback receives the
error and a resetError function (IAppMetricsErrorBoundaryFallbackProps).
import { AppMetricsErrorBoundary } from '@symbiote-native/app-metrics/react';
<AppMetricsErrorBoundary fallback={({ error, resetError }) => ( <button title={`Retry (${String(error)})`} onPress={resetError} /> )}> <RiskyScreen /></AppMetricsErrorBoundary><script setup lang="ts">import { h } from 'vue';import { AppMetricsErrorBoundary } from '@symbiote-native/app-metrics/vue';import type { IAppMetricsErrorBoundaryFallbackProps } from '@symbiote-native/app-metrics/vue';
const renderFallback = ({ error, resetError }: IAppMetricsErrorBoundaryFallbackProps) => h('button', { title: `Retry (${String(error)})`, onPress: resetError });</script>
<template> <AppMetricsErrorBoundary :fallback="renderFallback"> <RiskyScreen /> </AppMetricsErrorBoundary></template>The fallback is a render function, so a component is mounted through h.
<script lang="ts"> import { AppMetricsErrorBoundary } from '@symbiote-native/app-metrics/svelte'; import type { IAppMetricsErrorBoundaryFallbackProps } from '@symbiote-native/app-metrics/svelte';</script>
<AppMetricsErrorBoundary> {#snippet fallback({ error, resetError }: IAppMetricsErrorBoundaryFallbackProps)} <button title={`Retry (${String(error)})`} onPress={resetError} /> {/snippet} <RiskyScreen /></AppMetricsErrorBoundary>import { AppMetricsErrorBoundary } from '@symbiote-native/app-metrics/solid';
<AppMetricsErrorBoundary fallback={({ error, resetError }) => ( <button title={`Retry (${String(error)})`} onPress={resetError} /> )}> <RiskyScreen /></AppMetricsErrorBoundary>Log events and watch the network
Section titled “Log events and watch the network”import { logEvent, reportError, setGlobalAttributes } from '@symbiote-native/app-metrics';
setGlobalAttributes({ plan: 'pro' }); // attached to everything logged from now onlogEvent('checkout_started', { severity: 'info', attributes: { items: 3 } });reportError({ source: 'reportedByUser', message: 'payment failed', isFatal: false,});useNetworkRequestObserver watches network requests for as long as the component lives
(injectNetworkRequestObserver on Angular). It takes a filter and onStarted and onCompleted
callbacks:
import { useNetworkRequestObserver } from '@symbiote-native/app-metrics/react';
useNetworkRequestObserver({ filter: { hosts: ['api.example.com'], methods: ['POST'] }, onStarted: event => console.log('started', event.url), onCompleted: event => console.log(event.statusCode, event.totalDuration),});On Vue, Solid and Svelte it takes a getter returning the same object. Outside a component, use the
NetworkRequestObserver class directly and call release() when done.
Functions
Section titled “Functions”| Signature | Description |
|---|---|
markFirstRender(): void |
Marks the first render for the startup metric. AppMetricsRoot calls it for you |
markInteractive(attributes?): void |
Marks the app as interactive, optionally with routeName and params |
logEvent(name, options?): void |
Logs a named event with optional displayName, body, attributes and severity |
setGlobalAttributes(attributes?): void |
Sets attributes attached to everything logged afterwards. null clears them |
reportError(error: IReportErrorInput): void |
Reports an error as a js.exception log event |
getMainSession(): Session |
The main session, which lasts for the app run |
getForegroundSession(): Promise<Session | null> |
The current foreground session, or null |
getInactiveSessions(): Promise<IDebugSession[]> |
Past sessions. Debug builds only |
getAllCrashReports(): Promise<ICrashReport[]> |
Android only, debug builds only. Throws UnavailabilityError elsewhere |
clearStoredEntries(): Promise<void> |
Clears the stored metric entries |
installErrorHandler(): void |
Wraps the global error handler so unhandled JS errors reach reportError. Runs automatically on import |
Classes
Section titled “Classes”| Class | Description |
|---|---|
Session |
A metrics session: id, type, startDate, isActive(), getEndDate(), getMetrics(), getLogs(), addMetric(metric) |
NetworkRequestObserver |
Observes requests. new NetworkRequestObserver(filter?), setFilter(filter), addListener, removeListener, release() |
Components and bindings
Section titled “Components and bindings”| Name | Adapters | Description |
|---|---|---|
AppMetricsRoot |
All five | Marks first render and, where supported, catches render errors |
AppMetricsErrorBoundary |
React, Vue, Solid, Svelte | Reports a caught error and renders the fallback |
useNetworkRequestObserver |
React, Vue, Solid, Svelte | Observes network requests for the component’s lifetime |
injectNetworkRequestObserver |
Angular | The same, with an inject* shape |
ILogEventOptions and INetworkRequestFilter
Section titled “ILogEventOptions and INetworkRequestFilter”| Field | Type | Description |
|---|---|---|
displayName |
string | null | undefined |
A human-readable name for the event |
body |
string | null | undefined |
Free-text detail |
attributes |
Record<string, ILogAttributeValue> | null | undefined |
Structured attributes: strings, numbers, booleans, arrays or objects |
severity |
'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' |
Defaults to 'info' |
hosts |
string[] | null | undefined |
INetworkRequestFilter: only requests to these hosts |
methods |
string[] | null | undefined |
INetworkRequestFilter: only these HTTP methods |
IReportErrorInput
Section titled “IReportErrorInput”| Field | Type | Description |
|---|---|---|
source |
'global' | 'errorBoundary' | 'reportedByUser' |
Where the error came from |
message |
string |
The error message |
isFatal |
boolean |
Whether the error crashed the app |
type |
string | undefined |
The error type or class name |
stacktrace |
string | undefined |
The JS stack trace |
componentStack |
string | undefined |
The React component stack. Error-boundary captures only |
The onCompleted event carries id, url, method, statusCode, networkProtocol,
requestBytesSent, responseBytesReceived, errorDescription, startedAt, completedAt,
totalDuration and redirects.
installErrorHandlerruns when the package is imported. It wraps React Native’s global error handler so an unhandled JS error reachesreportErrorbefore the previous handler runs.- A caught error is reported separately from an uncaught one.
AppMetricsErrorBoundaryreports through its own path and never reaches the engine’s uncaught-error reporting, by design. - Vue and Solid boundaries carry no component stack. Vue’s
onErrorCapturedgives only a lifecycle-phase string, so only React’s boundary forwards a realcomponentStack. - On Solid and Svelte, an uncaught error still rethrows.
AppMetricsRootwithouterrorBoundaryFallbackleaves that throw to reach the caller, the same as not wrapping the tree. - Angular has no
AppMetricsErrorBoundaryyet. Angular’s@boundaryand@errortemplate primitive is stable from@angular/core22.2, but this repo pins~22.0.8because the AOT linker in newer versions asserts Babel 8, which cannot run inside Metro’s Babel 7. It ships once the catalog can move past 22.0.8. getForegroundSessionworks on both platforms. Upstream’s comment says iOS only, but the native modules implement it on both.- Metrics can only be verified on a device. The headless tests fake the native module and the React layer is mounted through the repo’s Fabric-recording harness.
Common questions
Section titled “Common questions”- What does it collect? App startup (cold and warm launch, bundle load, time to first render), frame rate, memory and sessions, plus your own events on the same session timeline.
- Where do the numbers go? Expo’s EAS Observe service, or any OpenTelemetry-compatible backend.
- Why is time to interactive missing? It is only reported once you mark the app interactive yourself.
- Debug or release? Judge startup numbers on release builds only.
Sources: Expo docs: Introduction to EAS Observe, Set up EAS Observe, Introducing Observe.
How the wrapper works
Section titled “How the wrapper works”expo-app-metrics’s JS is hand-ported into this package’s core/. Session and
NetworkRequestObserver are native shared-object classes re-exported straight off the native
module, so there is no JS logic beyond the re-export. Each framework’s error boundary wraps that
framework’s own catch primitive and shares the report-building logic through
core/report-caught-error.ts. The native code is never vendored: expo-modules-autolinking
resolves it from node_modules (see the native setup guide).