Skip to content
mcpd
no daemon

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:

FieldUse it forExample
session_idOne conversation. The same value on every turn of it.the chat's id in your database
thread_idA thread inside a session, when one conversation has several.a side thread on a ticket
user_idThe 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
  })
}

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'

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.

  1. support_agent 2.86s
  2. gpt-4.1-mini 910ms
  3. lookup_order 420ms
  4. orders.get 300ms
  5. gpt-4.1-mini 1.38s
Tokens2,184 → 96 Cost$0.00071 Duration2.86 s

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 seeWhy, 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.

mcpd is free and open source. Start free at mcpd.sh , or run the daemon yourself.