Agentic AI applications typically involve multiple back-and-forth exchanges within a single user session. Each exchange is a separate HTTP request with its own trace ID, which makes it difficult to answer questions like: "What did this user's session look like end-to-end?" or "Which LLM calls are related to the same conversation?"
This tutorial shows how to connect Real User Monitoring (RUM) with backend AI agent spans using a gen_ai.conversation.id attribute. Then, you can use a single DQL query to follow a user's click all the way through the LLM response and across every follow-up question in the same session.
traceparent headers so that browser user-action spans are the parent of backend LLM spans.gen_ai.conversation.id that groups all AI interactions across a session into a single queryable identifier.gen_ai.conversation.id aligns with dt.rum.session.id to enable the frontend link in distributed traces.AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT)openTelemetryTrace.ingest, metrics.ingestThe code for this tutorial is available in the rum/opentelemetry directory of dynatrace-oss/dynatrace-ai-agent-instrumentation-examples.
The application is a music history chatbot that routes requests across AWS Bedrock and Azure OpenAI, instrumented with pydantic-ai native OpenTelemetry support.
Follow these steps to set up conversation and session tracking in Dynatrace.
In
Experience Vitals, select New frontend > Web and provide a frontend name.
In the Select instrumentation method step, select Agentless and then select Create.
In the Setup step, under Select capability and settings, verify that RUM is enabled. If it is not enabled, select Override and turn it on.
Select Next to copy the JavaScript tag URL. This is the value for DT_RUM_SCRIPT in your .env file.
Create an .env file in rum/opentelemetry/ according to the following snippet.
DT_ENDPOINT=https://<your-env-id>.live.dynatrace.comDT_API_TOKEN=dt0c01.<your-token>DT_RUM_SCRIPT=https://js-cdn.dynatrace.com/jstag/<your-tag>.js# At least one of the following provider groups is required:AWS_ACCESS_KEY_ID=<aws-access-key-id>AWS_SECRET_ACCESS_KEY=<aws-secret-access-key>AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/AZURE_OPENAI_API_KEY=<your-key>AZURE_OPENAI_DEPLOYMENT=<deployment-name>
There are two ways to run the sample application: HTML or Next.js.
Run the following commands in a terminal.
cd rum/opentelemetrymake installmake run
Open http://localhost:8000. The FastAPI server serves the HTML page directly.
First, start the FastAPI backend. Run the following commands in a terminal.
cd rum/opentelemetrymake installmake run # listens on port 8000
In a second terminal, start Next.js.
cd rum/opentelemetry/nextjs-frontendnpm installnpm run dev # listens on port 3000
Open http://localhost:3000. Next.js proxies all /api/* requests to the FastAPI backend.
Once you've configured conversation and session tracking, here's what will happen when a user submits a question.
fetch() call.traceparent header and injects it into the request.traceparent and starts a backend span as a child of the browser user-action span. Both share the same traceId.gen_ai.conversation.id directly on the root span and propagates it to all child LLM spans via a span processor and exporter wrapper that override whatever the AI framework sets.trace.id and dt.rum.session.id.Because every question in a session carries the same gen_ai.conversation.id, you can reconstruct the full agent trajectory across multiple traces with a single DQL query.
Propagating a conversation ID through a Pydantic AI application requires three components: a frontend that generates and stores the UUID, a backend context variable that carries it through each request, and a two-stage span processor and exporter that write the correct value to gen_ai.conversation.id before spans are exported.
The frontend generates a UUID once per browser session and stores it in sessionStorage. Every request body includes this value as conversation_id.
const CONV_ID = sessionStorage.getItem('conversationId') || crypto.randomUUID();sessionStorage.setItem('conversationId', CONV_ID);
A ContextVar carries the conversation ID for the duration of each request.
_current_conversation_id: ContextVar[str | None] = ContextVar("current_conversation_id", default=None)
A ConversationIdSpanProcessor stages the value on every span as it starts, using a private attribute name that the AI framework won't overwrite.
_STAGING_ATTR = "_rum_session_id"class ConversationIdSpanProcessor(SpanProcessor):def on_start(self, span: Span, parent_context=None) -> None:conversation_id = _current_conversation_id.get()if conversation_id:span.set_attribute(_STAGING_ATTR, conversation_id)
A SessionIdExporter wrapper copies the staged value to gen_ai.conversation.id immediately before spans leave the process, overriding whatever the framework set.
class SessionIdExporter(SpanExporter):def export(self, spans: list[ReadableSpan]) -> SpanExportResult:for span in spans:attrs = getattr(span, "_attributes", None)if not attrs:continuesession_id = attrs.get(_STAGING_ATTR)if session_id:attrs["gen_ai.conversation.id"] = session_iddel attrs[_STAGING_ATTR]return self._inner.export(spans)
Pydantic AI sets gen_ai.conversation.id on its own spans after on_start runs, so staging in a private attribute and overriding at export time ensures the correct value is always written.
Once RUM JavaScript is initialized, the frontend replaces the UUID with the real RUM session ID. This makes gen_ai.conversation.id equal to dt.rum.session.id, so both the
AI Observability session filter and the
Experience Vitals session share the same identifier.
const rumPoll = setInterval(() => {if (typeof window.dtrum === 'undefined') return;clearInterval(rumPoll);const rumId = window.dtrum.getSessionId?.();if (rumId) {CONV_ID = rumId;sessionStorage.setItem('conversationId', rumId);}window.dtrum.sendSessionProperties?.(undefined, undefined, { conversationId: CONV_ID });}, 200);
Independently of getSessionId(), Dynatrace always propagates dt.rum.session.id from the RUM JavaScript tracestate header to every backend span. When Dynatrace sees this attribute on a span, it creates a frontend link from the distributed trace view directly to the corresponding RUM session in
Experience Vitals.
| Attribute | Set by | Meaning |
|---|---|---|
| Sent from frontend UUID, to request body, to backend span processor | Groups all LLM spans from a single browser session |
| Dynatrace RUM JavaScript via | Powers the frontend link in |
|
| Alias for |
| Signal | Source | DQL attribute |
|---|---|---|
Browser user actions | RUM JavaScript |
|
End-to-end trace link | W3C | Shared |
Session grouping |
|
|
LLM provider and model |
|
|
Token usage |
|
|
User feedback |
|
|
Now that you've got your application running, you can explore session data, traces, and AI signals in Dynatrace.
In
AI Observability, select the Explorer tab and then select the rum/opentelemetry service to see LLM request counts, token usage, and latency for the chatbot. Drill into Prompts and traces to inspect individual exchanges.
To narrow down to a single user session, select the Prompts tab and filter by Session (which maps to gen_ai.conversation.id). All LLM interactions that belong to the same browser session are listed together, even though they span multiple trace IDs.
Selecting any prompt opens the full agentic trace with the system prompt, input, model output, token usage, and the Agents topology view. This shows the call chain from the root span, through the Pydantic AI agent, to the LLM provider.
For more information about the views, see AI Observability app.
Because the RUM JavaScript injects a W3C traceparent header into every fetch() call, Dynatrace links the browser user-action span to the backend spans by shared traceId. In
Experience Vitals, open the page load waterfall for a session and select a request to the backend. When a distributed trace is available, the details include a View trace option that opens the corresponding span in Distributed Tracing.
This gives you the full picture in one click: user action duration, active sessions, and click events in
Experience Vitals, with a direct path into the AI agent trace on the backend.
For more information about frontend-to-backend linking, see Analyze performance from frontend to backend.
To reconstruct the full agent trajectory across all requests in a session, run the following query:
fetch spans| filter gen_ai.conversation.id == "<paste-from-UI>"| fields timestamp, span.name, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, feedback.rating| sort timestamp asc
You can find the Conversation ID in
AI Observability as the gen_ai.conversation.id attribute.
The Copy for DQL button in the sample application copies this query to your clipboard, pre-filled with the current session's Conversation ID.