flow.* attributes on it. From each trace Observe builds the conversation, stores both messages and grades the reply.
Any OpenTelemetry SDK works. You need a trace key (otk_test_...), made in the dashboard (see Get access), in FLOW_OBSERVE_TRACE_KEY.
1. Set up the exporter
Once, when your process starts (the other examples import this file astracing):
"module": "nodenext", write the local imports on this page with the extension: ./tracing.js.
Prefer environment variables? OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://flow.engineer/observe/v1/traces and OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=Bearer%20<key> (the space written as %20) configure an exporter created with no arguments.
2. Wrap each turn in a span
Start a span when a person’s message reaches your code, run your agent inside it, and report what you sent. Replacerun_agent, split_for_sms and send_sms with your own code:
flow.agent_reply on the agent.turn span itself; the send spans are for a reply sent as several messages.
In a short-lived process (a script, a serverless function), flush before it ends: provider.force_flush() in Python, await provider.forceFlush() in TypeScript. Otherwise the batch still in memory is lost.
The attributes
Put them on your turn span as in step 2, exceptflow.agent_reply, which goes on the turn span or on a span inside it. Observe also reads them from any other span of the trace or from the resource, and applies them to the whole trace, but the turn span is the safe place for the ids: it ends last, so when it arrives, the whole turn has.
- Ids are stored only as keyed hashes and never shown. A phone number is a fine conversation or contact id. Never put message text, names or email addresses in an id.
- Text is stored encrypted, like every message in Observe, and deleted when your account’s retention period ends.
- Times come from the spans. The person’s message is dated at the start of the span that carries
flow.user_message, and each reply at the end of the span that carries itsflow.agent_reply. The difference is the reply time Observe shows. So start the turn span when the message arrives, not after a queue, and end eachsendspan when the send returns. - Conversations follow a 48-hour rule. After 48 hours with no messages, the next message starts a new conversation, even with the same
flow.conversation_id. Billing counts conversations the same way. - A turn may have one side only. A reminder your agent starts has
flow.agent_replyand noflow.user_message; a message your agent does not answer hasflow.user_messageand noflow.agent_reply. Both are stored as they are. - Without
flow.user_messageorflow.agent_reply, Observe reads the text from your trace’s agent span (the first span withgen_ai.operation.nameinvoke_agent, else your root span): the last user message of its input and the text of its output, when your framework records them. The model’s output is often not what was sent, so set both yourself. - The same names also work with the prefixes
ai.settings.context.,ai.telemetry.metadata.andmastra.metadata.(for exampleai.telemetry.metadata.flow.conversation_id), which some frameworks add to metadata you pass them. No other names are read:gen_ai.conversation.id,enduser.idand the session ids frameworks set themselves are ignored.
3. Check it worked
Send one synthetic turn by hand. This needs nothing butcurl and your key, and sends one span in OTLP/JSON:
Then run a turn through your own code and look in the dashboard: your agent, then Conversations. The conversation appears within seconds with both messages and the reply time; open it to see the turn’s spans under the reply. The reply’s verdict appears shortly after. OpenTelemetry exporters report failures in your process’s log, not as exceptions, so check the log if nothing appears.
Common mistakes
- Ids on only some traces of a turn. Some frameworks and workflow engines run one turn as several traces (each step, retry or background job starts its own). A trace without
flow.conversation_idis kept but belongs to no conversation, and on a test-only account its content is deleted after 30 minutes. Keep the turn in one trace: start your framework’s work inside theagent.turnspan, and pass the trace context into queued jobs. Do not copy the ids onto the other traces instead: Observe reads every trace with aflow.conversation_idas a turn of its own, so the person’s message or the reply would be stored twice. - Reporting the model’s output instead of what was sent. Post-processing, guardrails, a fallback message, truncation or splitting change the text. Set
flow.agent_replyto the text your channel accepted, after all of that. A turn that sent nothing has noflow.agent_reply. - Text or personal data in ids or span names. Ids are hashed, but span names, tool names and service names are stored as plain text (long digit runs and anything with
@are masked, as a safety net). Name spans after what they do (agent.turn,send,lookup_order), never after what was said. - A new conversation id every turn. Each turn then becomes its own conversation. Use the thread’s id (for SMS, the person’s number, or the pair of your number and theirs).
- Reusing a message id in tests. A second turn with the same
flow.message_idis treated as a retry: its message from the person is not stored again, and its replies are added to the first turn. Make a new id per test turn. - A turn span that never reaches Observe. Observe stores the person’s message when its span arrives, and your replies once that message or your trace’s root span has arrived, so each reply gets its reply time even when your exporter sends it first. If your turn span’s parent lives in a service that does not export to Observe, the root never arrives: then a trace with no
flow.user_messagekeeps no replies. Start the turn span in the service that receives the message, and keep the ids and both texts on it or inside it. - Not flushing. A script or serverless function that exits before the batch is exported sends nothing.
- A gRPC exporter, or a URL without
/v1/traces. Use the HTTP exporter, and the full URL when you pass it in code. - The trace key in a browser or mobile app. Keep it on your server.
- Real people’s messages before your account is enabled for production. Send synthetic conversations only until the Flow team enables it.
Limits
- At most 8 MiB per request as sent (32 MiB after gzip), 10,000 spans per request, and 256 KiB of content per span (longer content is cut).
- Per key: 10 requests a second, bursts of 40, at most 8 at once. Above that,
429withRetry-After. A batching exporter sends about one request every few seconds, far below this. - A span dated more than 5 minutes ahead of Observe’s clock is refused.
- A span is stored once per trace id and span id, so an exporter’s retry changes nothing.
What Observe keeps from your spans
- Kept as metadata, and shown in the dashboard: span names, kinds (model, tool, retrieval, agent), timings, models and token counts, tool names, errors and retries, following the OpenTelemetry GenAI conventions and the common framework spellings.
- Stored encrypted, not shown today: prompts, model outputs, tool inputs and results, and the other content your framework records, plus every span event (exception messages included). If you do not want your framework’s recorded prompts and outputs sent, turn recording off in the framework;
flow.user_messageandflow.agent_replyare enough for grading. - Dropped: every other attribute, including your own custom ones.