React Native and Cactus¶
On-device models in a React Native app, traced with the same GenAI semantic conventions as
server-side calls. Verified on a phone (Nothing Phone 2a, React Native 0.81, Hermes,
cactus-react-native 1.13) in the LiquidChat
app: spans for model download, load, completion and embedding reached an OTLP/HTTP collector
with token counts, timings and the model in use.
Install¶
npm install @traceverse/genai-otel-instrument-node @opentelemetry/api
Import from the react-native entry: it has no Node APIs (async_hooks, fs, http) and
exports over fetch.
import { instrument } from '@traceverse/genai-otel-instrument-node/react-native';
instrument({
serviceName: 'my-app',
// OTLP/HTTP base URL; /v1/traces and /v1/metrics are added.
endpoint: 'https://otel.example.com:4318',
headers: { Authorization: 'Bearer …' },
// Prompts and replies stay off the wire unless you opt in.
enableContentCapture: false,
});
That is the only call (from 0.1.0-alpha.7). cactus-react-native is instrumented when the app
has it installed, and React Native's AppState is used to send what is queued when the app
leaves the foreground; both are optional requires, so nothing breaks without them. A trace is
also sent as soon as it finishes, since the batch timer stops while the app is in the
background. Passing appState, or calling instrumentCactus({ CactusLM, CactusSTT })
yourself, still works.
There is no default endpoint. Telemetry leaves the phone only for a collector you name, which makes export something the user turns on (LiquidChat keeps it off until it is enabled in Settings).
Spans from Cactus¶
| Span | When | Main attributes |
|---|---|---|
cactus.download |
model weights fetched | gen_ai.request.model, cactus.model_file |
cactus.init |
model loaded into memory (not when it already is: the engine calls init() before every completion) |
load time, model file |
cactus.complete |
a completion | input and output tokens, time to first token, decode tokens/s, memory, tool calls |
cactus.embed |
an embedding | dimensions, input count |
cactus.stt.download, cactus.stt.init, cactus.stt.transcribe |
speech to text | model, timing |
Each of these spans carries openinference.span.kind: LLM for completions and
transcriptions, EMBEDDING for embeddings, CHAIN for downloads and loads (see
Backends).
Your own spans¶
An app's own components (its agent loop, tool dispatcher, search, pipelines) are not a library, so no instrumentor can find them. Wrap each in one call; the conventions come with it:
import {
traceAgent,
traceRetrieval,
traceTool,
traceWorkflow,
} from '@traceverse/genai-otel-instrument-node/react-native';
const reply = await traceAgent(
'assistant',
{ id: runId, input: text, output: (r) => r.reply, failure: (r) => (r.ok ? undefined : r.code) },
() => runAgent(text),
);
await traceTool(
'get_weather',
{ callId, description: 'Gets the weather for a city.', arguments: args, mcpServer: undefined },
() => tools.call('get_weather', args),
);
const hits = await traceRetrieval(
'notes',
{
query,
system: 'sqlite',
topK: 5,
documents: (rows) => rows.map((r) => ({ id: r.id, score: r.score, content: r.text, metadata: { source: r.file } })),
},
() => search(query, 5),
);
await traceWorkflow('ingest', { resultAttributes: (r) => ({ 'app.pages': r.pages }) }, () => ingest(file));
| Helper | Span | Carries |
|---|---|---|
traceAgent |
invoke_agent {name} (AGENT) |
agent name and id, conversation id, input and output (content) |
traceTool |
execute_tool {name} (TOOL) |
call id, description, type; mcp.server.name and mcp.method.name for an MCP tool; arguments and result (content) |
traceRetrieval |
retrieve {collection} (RETRIEVER) |
store, top-k, rows returned, rag.result.score_*, the documents in rank order (ids and scores always; text, metadata and query with content) |
traceWorkflow |
{name} (CHAIN) |
input and output (content) |
Every helper nests what runs inside it, across await; records content only with
enableContentCapture; records an error's type always and its message only with content
capture; and takes attributes (at start), resultAttributes (from the result, not content)
and parent. They are in every entry, Node included.
The SDK registers the global tracer provider, so the rest of the app can also add spans with
@opentelemetry/api and they go to the same collector.
React Native has no async context of its own, so the SDK registers a context manager that
keeps a context active until the promise passed to context.with() settles. Run your work
inside your span's context, and every Cactus call it awaits becomes a child of that span:
one trace per agent run or pipeline, not one per model call.
import { context, trace } from '@opentelemetry/api';
const tracer = trace.getTracer('my-app');
const run = tracer.startSpan('invoke_agent my-agent', {
attributes: { 'gen_ai.operation.name': 'invoke_agent', 'gen_ai.agent.name': 'my-agent' },
});
await context.with(trace.setSpan(context.active(), run), async () => {
const plan = await lm.complete({ messages }); // cactus.complete: a child of the run
await runTools(plan); // spans started here are children too
});
run.end();
Only your own context.with() scopes become active. The SDK's model-call spans are
leaves and never do (from 0.1.0-alpha.5): a call running in the background, such as an
embedding for a search index, does not become the parent of whatever the app starts
meanwhile. Use 0.1.0-alpha.5 or later; in alpha.4 such a call could become the root of
an unrelated trace.
The active context is the most recently entered of your scopes that has not finished. When two of your pipelines run at the same time (a meeting being transcribed while the user chats), a span started in the older one can attach to the newer one; where that matters, pass the parent explicitly instead:
import { context, trace } from '@opentelemetry/api';
const tracer = trace.getTracer('my-app');
const run = tracer.startSpan('invoke_agent my-agent', {
attributes: { 'gen_ai.operation.name': 'invoke_agent', 'gen_ai.agent.name': 'my-agent' },
});
const parent = trace.setSpan(context.active(), run);
const tool = tracer.startSpan(
'execute_tool search',
{ attributes: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'search' } },
parent,
);
// … run the tool …
tool.end();
run.end();
LiquidChat uses both for agent runs (invoke_agent), tool and MCP calls (execute_tool, with
mcp.server.name), retrieval (retrieve), its vector store (query <store> with
rag.result.score_*) and its meeting pipeline.
For a model call the SDK cannot see (a remote API called with fetch), wrap it with
traceGenAiCall to get the same span, token and cost attributes:
import { traceGenAiCall } from '@traceverse/genai-otel-instrument-node/react-native';
const reply = await traceGenAiCall(
{ name: 'chat my-model', system: 'my-provider', model: 'my-model' },
async () => {
const r = await callMyApi();
return { ...r, usage: { prompt_tokens: r.inputTokens, completion_tokens: r.outputTokens } };
},
);
Background work¶
The active context here is the most recent scope still open, so a model call made by
background work (indexing documents, say) while a long workflow runs would be filed under
that workflow. Wrap such calls in detached (from 0.1.0-alpha.10): each becomes its own
trace, and no scope is opened that could take over the workflow's spans.
import { detached } from '@traceverse/genai-otel-instrument-node/react-native';
const { embedding } = await detached(() => model.embed({ text: chunk }));
Only spans started while fn runs synchronously are covered, so wrap each call rather than
a whole async task.
Backends¶
Export is plain OTLP/HTTP, so any OpenTelemetry backend works: point endpoint at it and
pass whatever headers it requires. There is no vendor package and nothing
backend-specific to install.
Spans follow the OpenTelemetry GenAI semantic conventions. Two attributes from the OpenInference conventions are added as well, because backends built on them (Arize AX, Phoenix and others) need them and ignore the rest:
| Attribute | Where | Why |
|---|---|---|
openinference.span.kind |
every span | how those backends classify a span: LLM, EMBEDDING, RETRIEVER, TOOL, AGENT, CHAIN. Instrumented spans set it themselves; for your own spans it comes from gen_ai.operation.name or the span name (invoke_agent ... is AGENT, execute_tool ... TOOL, retrieve ... RETRIEVER, other internal spans CHAIN). One you set yourself is kept. Without it, an operation outside the GenAI registry (speech to text, a model load, a pipeline step) shows as "unknown" |
graph.node.id, graph.node.parent_id, graph.node.name, graph.node.display_name, agent.name |
every span that names an agent | the agent graph (from 0.1.0-alpha.8, as the Python library's 1.44.0): each agent is a node, its parent the nearest enclosing agent, so an agent started by another (a skill run by an assistant) is its child. From 0.1.0-alpha.9 the switch is parsed as the Python library parses it: on unless GENAI_ENABLE_AGENT_GRAPH_ATTRIBUTES is set to anything other than true |
session.id, user.id |
spans under a span that has them | from 0.1.0-alpha.9 (Python 1.45.0): a span without a session takes its parent span's, so every span of a traced run carries the run's session, not only the root. GENAI_ENABLE_SESSION_PROPAGATION: 'false' keeps only explicit sessions |
openinference.project.name |
the resource | the project the traces are filed under: the service name, or OPENINFERENCE_PROJECT_NAME passed to instrument(). Arize AX rejects a trace without one |
Content the span already carries (from 0.1.0-alpha.6) is also exported under the names
those backends display: input.value and output.value from the GenAI messages, tool
arguments and results, or the retrieval query; tool.name, tool.description and
tool.parameters from the tool attributes. It is a copy of what you captured: with content
capture off there is nothing to copy.
Other backends store them like any other attribute. To leave all of this out, pass
openInferenceSpanKinds: false.
Retrieved documents¶
Record what a search returned, in rank order, with addRetrievalAttributes (every entry;
a port of the Python library's add_retrieval_attributes, same names and limits):
import { addRetrievalAttributes } from '@traceverse/genai-otel-instrument-node/react-native';
const span = tracer.startSpan('retrieve memory', { attributes: { 'gen_ai.operation.name': 'retrieve' } });
const hits = await search(question);
addRetrievalAttributes(
span,
hits.map((h) => ({ id: h.id, score: h.score, content: h.text, metadata: { source: h.file } })),
{ query: question },
);
span.end();
| Attribute | Value |
|---|---|
retrieval.query |
the query, first 500 characters |
retrieval.documents.{i}.document.id / .score / .content |
the first five documents in rank order; content truncated to 500 characters |
retrieval.documents.{i}.document.metadata.<key> |
flattened metadata (keys 50, values 200 characters) |
retrieval.document_count |
how many documents came back |
The query and document text are your users' data: pass them only when you capture content. Ids and scores alone still show the ranking (LiquidChat does exactly that).
For example, Arize AX takes OTLP directly:
instrument({
serviceName: 'my-app', // also the project name
endpoint: 'https://otlp.arize.com',
headers: { space_id: '<space id>', api_key: '<api key>' },
});
To send to more than one backend at once (your own collector and a hosted one), list the
others in additionalDestinations (from 0.1.0-alpha.7). Each gets the same telemetry with its
own headers; the main endpoint's headers are never sent to them, and a destination that fails
is logged as a warning:
instrument({
serviceName: 'my-app',
endpoint: 'https://otel.example.com',
headers: { Authorization: 'Bearer …' },
additionalDestinations: [
{ endpoint: 'https://otlp.arize.com', headers: { space_id: '<space id>', api_key: '<api key>' } },
],
});
A failed export is logged as a warning, not at debug level: a backend that rejects every request (a missing header, for example) would otherwise look the same as one that accepts them.
Testing against a local collector¶
From a phone on USB, adb reverse tcp:4318 tcp:4318 makes http://localhost:4318 on the
phone reach a collector on the development machine. From the Android emulator, the host is
10.0.2.2.