Send data
React Native
Trace network requests, errors and key user actions in an iOS or Android app, joined to your backend traces.
A React Native app sends traces with the OpenTelemetry JavaScript SDK: the same packages a web app uses, which export OTLP/JSON that knotel takes as is. You get a span for every network request, a traceparent header that joins those requests to your backend's traces, and custom spans for the things users wait on.
t.js is for web pages. It reads its settings from its own <script> tag and times page loads from the browser's navigation entries, and a React Native app has neither, so it would do nothing. The OpenTelemetry SDK below is the supported path. If you'd rather not add dependencies, see without OpenTelemetry.Install
npm install @opentelemetry/api @opentelemetry/core \ @opentelemetry/sdk-trace-base @opentelemetry/sdk-trace-web \ @opentelemetry/resources @opentelemetry/exporter-trace-otlp-http \ @opentelemetry/instrumentation @opentelemetry/instrumentation-xml-http-request
For the app version and device model on the resource:
# Expo npx expo install expo-application expo-device # Bare React Native npm install react-native-device-info && npx pod-install
The packages are plain JavaScript, so they work in Expo Go, development builds and bare apps alike. Metro picks their browser builds on its own; no Metro config is needed. What your React Native version decides is whether you need a polyfill:
| Needs | Built in from | On older versions |
|---|---|---|
TextEncoder | React Native 0.74 (Hermes) | npm install fast-text-encoding and import it first. |
URL hostname and origin | React Native 0.80 | npm install react-native-url-polyfill and import react-native-url-polyfill/auto first. Without it, the instrumentation throws URL.hostname is not implemented and your requests fail with it. |
crypto.getRandomValues | Not needed | OpenTelemetry generates ids without it. |
Set up tracing
Create the ingest key on the project's Send data page. Shipping it in the app is fine: ingest keys can only write spans, never read them. A phone has no domain and no fixed address, so give this key the ranges 0.0.0.0/0 and ::/0, which accept every address, and don't reuse it anywhere else.
import { AppState, Platform } from "react-native";
import * as Application from "expo-application";
import * as Device from "expo-device";
import { W3CTraceContextPropagator } from "@opentelemetry/core";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { XMLHttpRequestInstrumentation } from "@opentelemetry/instrumentation-xml-http-request";
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
BatchSpanProcessor,
ParentBasedSampler,
TraceIdRatioBasedSampler,
} from "@opentelemetry/sdk-trace-base";
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
const SAMPLE_RATIO = 1; // 0.1 keeps one trace in ten
export const provider = new WebTracerProvider({
resource: resourceFromAttributes({
"service.name": "mobile-app",
"service.version": Application.nativeApplicationVersion ?? undefined,
"deployment.environment.name": __DEV__ ? "development" : "production",
"os.name": Platform.OS,
"os.version": Device.osVersion ?? undefined,
"device.model.name": Device.modelName ?? undefined,
"sampling.probability": SAMPLE_RATIO,
}),
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(SAMPLE_RATIO),
}),
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: "https://YOUR-INSTANCE/v1/traces",
headers: { "x-knotel-key": "kn_YOUR_KEY" },
}),
{ scheduledDelayMillis: 2000 },
),
],
});
provider.register({ propagator: new W3CTraceContextPropagator() });
registerInstrumentations({
instrumentations: [
new XMLHttpRequestInstrumentation({
// Never trace the exporter's own requests.
ignoreUrls: [/\/v1\/traces$/],
// Your own APIs, which get a traceparent header.
propagateTraceHeaderCorsUrls: [/^https:\/\/api\.example\.com\//],
}),
],
});
// Send what's queued when the app leaves the foreground.
AppState.addEventListener("change", (state) => {
if (state !== "active") void provider.forceFlush();
});Import it before anything that makes requests, as the first line of your entry file: index.js in a bare app, or the root app/_layout.tsx with Expo Router. Polyfills, if you need them, go above it.
import "../tracing";
Bare React Native
Swap the two Expo imports for react-native-device-info:
import DeviceInfo from "react-native-device-info"; "service.version": DeviceInfo.getVersion(), "os.version": DeviceInfo.getSystemVersion(), "device.model.name": DeviceInfo.getModel(),
Resource attributes
| Attribute | Why |
|---|---|
service.name | What the dashboard groups by. Give the app its own name, separate from your backend services. |
service.version | The release, so a slowdown or error spike can be tied to a build. |
deployment.environment.name | Keeps development builds apart from production if they share a project. |
os.name, os.version, device.model.name | Filter and group by platform and device. Every attribute is searchable. |
sampling.probability | The ratio you sample at, so counts are scaled back up. See sampling. |
Avoid attributes that identify a person, such as an email or a persistent device id, unless you mean to store them: every attribute is kept for the retention period.
Network requests
In React Native, fetch() is built on XMLHttpRequest, and so are axios and most other clients. Instrumenting XMLHttpRequest alone therefore records every request exactly once. Adding @opentelemetry/instrumentation-fetch as well gives each fetch() two spans.
Each request becomes a client span named after its method, with http.request.method, url.full, server.address and http.response.status_code. knotel shows where the time went by host, so GET spans to different APIs stay apart.
Joining backend traces
A web page sends traceparent to its own origin automatically. An app has no origin, so no request gets the header until its URL matches propagateTraceHeaderCorsUrls. List your own APIs there. Your backend's OpenTelemetry SDK or the Workers SDK reads the header, and the request's server span lands in the app's trace.
Custom spans
Wrap what users wait on, such as a checkout or a screen's first load:
import { SpanStatusCode, trace } from "@opentelemetry/api";
const tracer = trace.getTracer("mobile-app");
await tracer.startActiveSpan("checkout", async (span) => {
span.setAttribute("cart.items", items.length);
try {
await api.post("/orders", { items });
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});await. A request started before the first await in the callback is a child of the span; one started after it begins a trace of its own. Restore the span around later requests:import { context } from "@opentelemetry/api";
const withSpan = trace.setSpan(context.active(), span);
await context.with(withSpan, () => api.post("/payments", payment));Uncaught errors
Nothing records JavaScript errors automatically. Hook React Native's global handler, and keep the previous handler so the red box and crash reporters still work:
const previous = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
const span = tracer.startSpan(`error: ${error.message}`.slice(0, 80), {
attributes: { "exception.fatal": Boolean(isFatal) },
});
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
void provider.forceFlush();
previous(error, isFatal);
});A fatal error can end the app before the flush finishes, so a crash report may not arrive. Native crashes never reach JavaScript at all; keep a crash reporter for those.
Delivery
- Spans are sent in batches every 2 seconds (the SDK's default is 5), up to 512 per request, with up to 2,048 waiting in memory. Beyond that, new spans are dropped.
- A failed send is retried up to five times with backoff, then dropped. Nothing is written to disk, so spans queued while offline are lost if the app is closed.
- The
AppStatelistener flushes when the app goes to the background. iOS and Android give a backgrounded app only a few seconds, which is usually enough for one batch but isn't guaranteed.
Sampling
Mobile traffic can be large. TraceIdRatioBasedSampler keeps a fraction of traces, decided once at the root, and the resource's sampling.probability tells knotel that fraction so request counts are scaled back up. Keep both from the same SAMPLE_RATIO constant, as the setup above does.
A trace that isn't kept still sends traceparent, marked as not sampled, and a backend that honours its parent's decision drops its spans too. Those backend spans then stand for more requests than their own declared rate says. See Sampling for how rates are read.
Without OpenTelemetry
The browser script is built on sdk/core.ts, a dependency-free span and exporter that does run in React Native. It has no instrumentation, so you create every span yourself, but it adds only one small file to your app. Copy it from the knotel repository and add a random-values polyfill, which it needs for ids:
npm install react-native-get-random-values
import "react-native-get-random-values";
import { AppState, Platform } from "react-native";
import { Exporter, formatTraceparent, Span, SpanKind } from "./knotel/core";
export const exporter = new Exporter({
endpoint: "https://YOUR-INSTANCE",
key: "kn_YOUR_KEY",
resource: { "service.name": "mobile-app", "os.name": Platform.OS },
});
setInterval(() => void exporter.flush(), 5000);
AppState.addEventListener("change", (state) => {
if (state !== "active") void exporter.flush();
});
export async function tracedFetch(url: string, init: RequestInit = {}) {
const method = init.method ?? "GET";
const span = new Span(exporter, `${method} ${url}`, {
kind: SpanKind.CLIENT,
attributes: { "http.request.method": method, "url.full": url },
});
const headers = new Headers(init.headers);
headers.set("traceparent", formatTraceparent(span));
try {
const res = await fetch(url, { ...init, headers });
span.setAttribute("http.response.status_code", res.status);
return res;
} catch (err) {
span.recordException(err);
throw err;
} finally {
span.end();
}
}Pass { parent: span } in a span's options to nest it. Query strings often carry tokens, so strip them from url.full if your URLs have them.
Checklist
tracing.tsis imported first, after any polyfills.- The exporter URL ends in
/v1/traces, and the same path is inignoreUrls. - Your API hosts are in
propagateTraceHeaderCorsUrls. - Spans appear a few seconds after a request. If not, set
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN)from@opentelemetry/apito see export errors in Metro, and see Troubleshooting.
The OpenTelemetry project keeps a React Native example app built on the same packages.