Browse resources

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.

Why not the browser script?
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:

NeedsBuilt in fromOn older versions
TextEncoderReact Native 0.74 (Hermes)npm install fast-text-encoding and import it first.
URL hostname and originReact Native 0.80npm 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.getRandomValuesNot neededOpenTelemetry 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.

tracing.ts
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.

app/_layout.tsx
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

AttributeWhy
service.nameWhat the dashboard groups by. Give the app its own name, separate from your backend services.
service.versionThe release, so a slowdown or error spike can be tied to a build.
deployment.environment.nameKeeps development builds apart from production if they share a project.
os.name, os.version, device.model.nameFilter and group by platform and device. Every attribute is searchable.
sampling.probabilityThe 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.

No CORS setup needed
The option's name comes from the browser, but a native app doesn't send preflight requests, so your API doesn't need to allow the header. Still leave third-party hosts out: they can't use the header, and a strict API may reject a request that carries one.

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();
  }
});
Requests after an await lose their parent
The context manager that works in React Native can't follow 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 AppState listener 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
tracing.ts
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.ts is imported first, after any polyfills.
  • The exporter URL ends in /v1/traces, and the same path is in ignoreUrls.
  • 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/api to see export errors in Metro, and see Troubleshooting.

The OpenTelemetry project keeps a React Native example app built on the same packages.