Sessions
Sessions group related agent runs under a single identifier. A common use case is tracking multi-turn conversations where each user message triggers a separate agent run, but you want to analyze the entire conversation as a unit.
What Gets Tracked
When runs share a session_id, Waxell Observe automatically aggregates:
| Metric | Description |
|---|---|
run_count | Number of runs in the session |
first_run | Timestamp of the earliest run |
last_activity | Timestamp of the most recent run |
total_duration | Combined execution time across all runs (seconds) |
total_cost | Sum of LLM costs across all runs (USD) |
total_tokens | Sum of tokens used across all runs |
agents | List of distinct agent names that participated |
Setting a Session ID
Recommended: @observe with Auto-Instrumentation
Call waxell.init() before importing your LLM SDK and pass session_id at call time. Every LLM call inside the decorated function is auto-captured and attributed to the session:
import waxell_observe as waxell
waxell.init() # BEFORE importing the LLM SDK
import openai
client = openai.OpenAI()
@waxell.observe(agent_name="chat-agent")
async def handle_message(user_message: str) -> str:
response = client.chat.completions.create( # auto-captured
model="gpt-4o",
messages=[{"role": "user", "content": user_message}],
)
return response.choices[0].message.content
# Pass session_id at call time -- groups every call with this id together
await handle_message("Hello!", session_id="session-abc123", user_id="user_456")
You can also bake session_id into the decorator if it's static for that agent: @waxell.observe(agent_name="chat-agent", session_id="session-abc123"). For real apps, call-time is more common since the session is per-conversation.
Dynamic Session IDs
Derive the session ID from your application's conversation or request context. Use generate_session_id() to mint one, or use your own identifier:
import waxell_observe as waxell
from waxell_observe import generate_session_id
# Generate a new session ID (format: sess_ + 16 hex chars)
session_id = generate_session_id()
# e.g. "sess_a1b2c3d4e5f60718"
# Or use your own identifier
session_id = f"conv-{conversation.id}"
# All runs with this session_id are grouped together
await handle_message("Hi", session_id=session_id, user_id=f"user-{user.id}")
await handle_message("Follow up", session_id=session_id, user_id=f"user-{user.id}")
The session_id is propagated to both the HTTP data path (stored on the AgentExecutionRun record) and the OTel tracing path (as a waxell.session_id span attribute). This means sessions are queryable from both the Waxell UI and Grafana TraceQL.
REST API
List Sessions
GET /api/v1/observability/sessions/
Authentication: Session (UI)
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
search | string | Filter by session_id (substring match) | |
agent | string | Filter by agent name | |
start | ISO8601 | Only sessions with runs after this time | |
end | ISO8601 | Only sessions with runs before this time | |
sort | string | -last_activity | Sort field. Options: last_activity, -last_activity, first_run, -first_run, run_count, -run_count |
limit | int | 25 | Page size (max 100) |
offset | int | 0 | Pagination offset |
Example:
curl -s "https://acme.waxell.dev/api/v1/observability/sessions/?limit=10&sort=-run_count" \
-H "Cookie: sessionid=..."
Response:
{
"results": [
{
"session_id": "sess_a1b2c3d4e5f6g7h8",
"run_count": 5,
"first_run": "2026-02-07T10:00:00Z",
"last_activity": "2026-02-07T10:05:32Z",
"total_duration": 12.45,
"total_cost": 0.0234,
"total_tokens": 4520,
"agents": ["chat-agent", "retrieval-agent"]
}
],
"count": 42,
"next": "?offset=10&limit=10",
"previous": null
}
Get Session Detail
GET /api/v1/observability/sessions/{session_id}/
Authentication: Session (UI)
Returns the session's aggregate metrics and a chronological list of all runs.
Example:
curl -s "https://acme.waxell.dev/api/v1/observability/sessions/sess_a1b2c3d4e5f6g7h8/" \
-H "Cookie: sessionid=..."
Response:
{
"session_id": "sess_a1b2c3d4e5f6g7h8",
"aggregates": {
"run_count": 5,
"total_duration": 12.45,
"total_cost": 0.0234,
"total_tokens": 4520,
"agents": ["chat-agent", "retrieval-agent"]
},
"runs": [
{
"id": 101,
"agent_name": "chat-agent",
"workflow_name": "default",
"started_at": "2026-02-07T10:00:00Z",
"completed_at": "2026-02-07T10:00:02Z",
"duration": 2.1,
"status": "success",
"cost": 0.0045,
"tokens": 890,
"trace_id": "abcdef1234567890abcdef1234567890"
}
]
}
UI Walkthrough
Sessions List
The sessions list view shows all tracked sessions with sortable columns:
- Session ID -- click to open session detail
- Runs -- number of agent runs in the session
- First Run / Last Activity -- time range of the session
- Duration -- total execution time
- Cost -- aggregated LLM spend
- Tokens -- total token usage
- Agents -- which agents participated
Use the search bar to filter by session ID, or the agent dropdown to see sessions for a specific agent.
Session Detail
The session detail page shows:
- Summary cards at the top with run count, total duration, total cost, and total tokens
- Vertical timeline of runs in chronological order, showing each run's agent, duration, cost, and status
- Click any run to navigate to its full trace detail
Multi-Agent Sessions
Sessions are especially useful for multi-agent workflows where several agents collaborate on a single request. Decorate each agent with @observe and pass the same session_id at call time:
@waxell.observe(agent_name="router")
async def classify(query: str) -> str:
...
@waxell.observe(agent_name="retrieval")
async def search(query: str) -> list:
...
@waxell.observe(agent_name="synthesizer")
async def synthesize(query: str, docs: list) -> str:
...
session_id = generate_session_id()
intent = await classify(query, session_id=session_id)
docs = await search(query, session_id=session_id)
answer = await synthesize(query, docs, session_id=session_id)
All three runs appear under the same session, giving you a complete picture of the multi-agent pipeline.
Advanced: Multiple Runs Per Function with WaxellContext
If a single function needs to spawn multiple distinct runs (e.g., a batch loop processing items, where each item should be its own run with the same session), use WaxellContext directly:
from waxell_observe import WaxellContext, generate_session_id
session_id = generate_session_id()
for item in batch:
async with WaxellContext(
agent_name="batch-processor",
session_id=session_id,
user_id="user_456",
) as ctx:
result = await process(item) # auto-captured LLM calls go here
ctx.set_result({"output": result})
For the common case of one function = one run, the decorator is simpler and recommended.
Next Steps
- User Tracking -- Attribute sessions and costs to individual users
- Scoring -- Attach quality scores to runs within a session
- LLM Call Tracking -- Understand token and cost breakdown per run