How to group LLM traces into conversations
A chat is many requests. Record each turn as its own run and give every turn of one conversation the same session id. mcpd then lists, measures and filters the conversation as one thing.
Last updated
When you finish, you can open every turn of one conversation in order, see what the whole conversation cost, and find the conversations where something failed. You need an agent that already sends runs to mcpd, through an mcpd SDK or OpenTelemetry.
1. Choose the ids
Make one trace per turn: one user message and everything the agent did to answer it. Then give each trace the ids that join it to the others:
| Field | Use it for | Example |
|---|---|---|
session_id | One conversation. The same value on every turn of it. | the chat's id in your database |
thread_id | A thread inside a session, when one conversation has several. | a side thread on a ticket |
user_id | The person. mcpd hashes it before it stores it. | your customer id |
Use an id your application already has. A random id made per request gives every turn its own session, and nothing joins them.
2. Set the session id on every turn
Set the ids on the root span of each turn. With an SDK, pass them when you
open the run. With OpenTelemetry, set the attribute session.id;
mcpd also reads gen_ai.conversation.id and
mcpd.session_id, and user.id or
enduser.id for the user.
one turn of a chat
import { Mcpd } from '@mcpd/sdk'
const mcpd = new Mcpd({ project: 'support' })
// One call per turn. conversationId is the same for every turn of one chat.
async function chatTurn(conversationId: string, userId: string, message: string): Promise<string> {
return await mcpd.withSpan('chat_turn', async (run) => {
run.setCorrelation({ sessionId: conversationId, userId })
run.setInput({ message })
const reply = await callYourModel(message) // your model call, recorded as usual
run.setOutput({ reply })
return reply
})
} from mcpd import Mcpd
mcpd = Mcpd(project="support")
# One call per turn. conversation_id is the same for every turn of one chat.
def chat_turn(conversation_id: str, user_id: str, message: str) -> str:
with mcpd.trace("chat_turn", session_id=conversation_id, user_id=user_id) as run:
run.set_input({"message": message})
reply = call_your_model(message) # your model call, recorded as usual
run.set_output({"reply": reply})
return reply With the OpenTelemetry SDK and no mcpd SDK, the same turn:
one turn, with OpenTelemetry
from opentelemetry import trace
tracer = trace.get_tracer("support-agent")
def chat_turn(conversation_id: str, user_id: str, message: str) -> str:
# Set both on the root span of the turn.
with tracer.start_as_current_span("chat_turn") as span:
span.set_attribute("session.id", conversation_id)
span.set_attribute("user.id", user_id)
return call_your_model(message)
Frameworks that follow the OpenTelemetry GenAI conventions may set
gen_ai.conversation.id for you. Pydantic AI does, so check
whether the value it picks is the one you want before you set your own.
3. Read one conversation
In the interface, open Runs and type the id into the
session filter. From the API, filter the trace list with
session_id and sort it oldest first:
one conversation
# Every turn of one conversation, oldest first.
curl -s --get http://localhost:4317/v1/api/traces \
--data-urlencode 'project=support' \
--data-urlencode 'session_id=chat-42' \
--data-urlencode 'order=asc' \
| jq -r '.traces[] | [.start_time, .name, .status, .cost.total_cost] | @tsv' const res = await fetch(
'http://localhost:4317/v1/api/traces?' +
new URLSearchParams({ project: 'support', session_id: 'chat-42', order: 'asc' }),
)
const { traces } = await res.json()
for (const t of traces) console.log(t.start_time, t.name, t.status, t.cost?.total_cost) import requests
res = requests.get(
"http://localhost:4317/v1/api/traces",
params={"project": "support", "session_id": "chat-42", "order": "asc"},
)
for t in res.json()["traces"]:
print(t["start_time"], t["name"], t["status"], (t.get("cost") or {}).get("total_cost"))
The match is exact, and it composes with every other filter on the list:
add status=error to see only the turns that failed, or
q to search the text of this conversation alone.
Finding a run lists them all.
- support_agent 2.86s
- gpt-4.1-mini 910ms
- lookup_order 420ms
- orders.get 300ms
- gpt-4.1-mini 1.38s
- claude-sonnet-4 612k tok · $2.31
- gpt-4.1-mini 1.9M tok · $0.84
- text-embedding-3-small 2.0M tok · $0.04
- 14:02:07.118 INFO request received path=/api/chat
- 14:02:08.004 INFO lookup_order order_id=4471
- 14:02:08.211 WARN carrier api slow, retrying attempt=2
- 14:02:08.392 INFO order status=shipped eta=Thu
- 14:02:09.977 INFO answered tokens=1,310 cost=$0.00071
Each line links to the span that wrote it.
The Conversation screen shows a run as the messages that
were said rather than as spans. Runs that carry a thread_id are grouped on the
Threads screen, where the previous and next controls walk the
thread turn by turn.
4. Measure every conversation
The analytics summary groups by session_id: one row per
conversation with its number of runs, failures, latency, tokens and cost.
per conversation
# Runs, failures, latency, tokens and cost for each conversation in the last day.
curl -s --get http://localhost:4317/v1/api/analytics/summary \
--data-urlencode 'project=support' \
--data-urlencode 'from=-24h' \
--data-urlencode 'group_by=session_id' \
| jq -c '.groups[] | {session: .key, turns: .count, errors: .error_count, cost: .total_cost}' GET /v1/api/analytics/cost takes group_by=session_id
too, when you only need the money.
Troubleshooting
| What you see | Why, and what to do |
|---|---|
| A conversation is missing turns | Those turns were sent without the session id, or with a different one. Set it on every turn, at the root span. |
| Every turn is its own session | The id is made per request. Use the conversation's own id from your database. |
Filtering by user_id finds nothing, but the runs have one | Pass the plain id you sent. mcpd hashes the filter value the same way it hashed the stored one. The cost per user guide explains the hash. |
Related guides
- How to log OpenAI API calls with tokens and cost
- How to track LLM cost per user
- How to collect thumbs-up and thumbs-down feedback on LLM answers
mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.