Session-Based Observability
Group a multi-turn chatbot's spans into one session you can filter and replay.
Tag every LLM span with using_user() and using_session() so a multi-turn conversation groups into one filterable session in the Future AGI Tracing dashboard, instead of appearing as unrelated spans.
| Time | Difficulty | Package |
|---|---|---|
| 15 min | Beginner | fi-instrumentation-otel + traceAI-openai |
- Future AGI account → app.futureagi.com
- API keys:
FI_API_KEYandFI_SECRET_KEY(see Get your API keys) - Python 3.11+
- OpenAI API key
Install
pip install fi-instrumentation-otel traceAI-openai openai
export FI_API_KEY="your-api-key"
export FI_SECRET_KEY="your-secret-key"
export OPENAI_API_KEY="your-openai-api-key"
Tutorial
Register the tracer and instrument OpenAI
register() creates a tracer provider connected to Future AGI. OpenAIInstrumentor patches the OpenAI client so every chat.completions.create call is captured automatically: model name, messages, token counts, and latency.
from fi_instrumentation import register
from fi_instrumentation.fi_types import ProjectType
from traceai_openai import OpenAIInstrumentor
from openai import OpenAI
trace_provider = register(
project_type=ProjectType.OBSERVE,
project_name="chatbot-session-demo",
)
OpenAIInstrumentor().instrument(tracer_provider=trace_provider)
client = OpenAI()You should see:
🔭 OpenTelemetry Tracing Details 🔭
| FI Project: chatbot-session-demo
| FI Project Type: observe
| Span Processor: BatchSpanProcessor
| Transport: HTTP Tag a single request with user and session context
An OpenAI call made without any context still gets traced (model, messages, tokens, latency), but the span carries no user.id or session.id. Run one first to see what an untagged span looks like:
# No user/session context: this span is traced but ungrouped
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello, what can you help me with?"}],
)
print(response.choices[0].message.content)You should see:
I can help you with a wide range of topics: answering questions, drafting text,
explaining concepts, writing code, and much more. What would you like to explore?Go to app.futureagi.com → LLM Tracing (left sidebar under OBSERVE). The span is there, but user.id and session.id are both empty in the attributes panel, and the request doesn’t show up under Sessions at all: there’s no session value to group it by.
Now wrap the same call with using_user() and using_session(). Every span created inside the block, including ones OpenAIInstrumentor generates, inherits the user.id and session.id attributes.
from fi_instrumentation import using_user, using_session
user_id = "user-7f3a2b"
session_id = "session-c91d4e"
with using_user(user_id), using_session(session_id):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello, what can you help me with?"}],
)
print(response.choices[0].message.content)You should see:
I can help you with a wide range of topics: answering questions, drafting text,
explaining concepts, writing code, and much more. What would you like to explore?Back in LLM Tracing, the span carries user.id = user-7f3a2b and session.id = session-c91d4e in the attributes panel.
Run a multi-turn conversation
All three turns run inside the same using_user and using_session block, so they share identical ID values and group together in the dashboard.
from fi_instrumentation import using_user, using_session
def run_conversation(user_id: str, session_id: str) -> None:
"""Run a 3-turn conversation. Every span shares the same user and session IDs."""
turns = [
"What is photosynthesis?",
"How does it differ from cellular respiration?",
"Give me a one-sentence summary of both processes.",
]
conversation_history = []
with using_user(user_id), using_session(session_id):
for turn_number, user_message in enumerate(turns, start=1):
conversation_history.append({"role": "user", "content": user_message})
# Each call is auto-traced with the same user.id and session.id
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=conversation_history,
)
assistant_message = response.choices[0].message.content
conversation_history.append({"role": "assistant", "content": assistant_message})
print(f"Turn {turn_number}: {assistant_message[:80]}...")
run_conversation(user_id="user-7f3a2b", session_id="session-a72f10")You should see:
Turn 1: Photosynthesis is the process by which plants, algae, and some bacteria...
Turn 2: While photosynthesis converts light energy into stored chemical energy...
Turn 3: Photosynthesis builds glucose from sunlight and CO2, while cellular...In LLM Tracing, click the Sessions tab. The session appears as a row with trace count, duration, and first/last messages. Click the row to view all three turns together in a conversation view.
Note
The Sessions tab shows an auto-generated UUID as the session identifier, not the string you passed to using_session(). Your string (e.g. session-a72f10) is stored as the session name and used for grouping: every trace that shares the same using_session() value within a project links to the same session.
Add per-turn metadata
using_metadata() attaches structured data to one span at a time: turn number, conversation stage, or any context you want to slice by later. Nest it inside the outer using_user and using_session block so it scopes to that turn’s span only.
from fi_instrumentation import using_user, using_session, using_metadata
def run_conversation_with_metadata(user_id: str, session_id: str) -> None:
"""Same conversation loop, with per-turn metadata attached to each span."""
turns = [
{"message": "What is photosynthesis?", "stage": "opening"},
{"message": "How does it differ from cellular respiration?", "stage": "deepening"},
{"message": "Give me a one-sentence summary of both processes.", "stage": "closing"},
]
conversation_history = []
with using_user(user_id), using_session(session_id):
for turn_number, turn in enumerate(turns, start=1):
conversation_history.append({"role": "user", "content": turn["message"]})
# Per-turn metadata is scoped to this span only
turn_metadata = {
"turn_number": turn_number,
"conversation_stage": turn["stage"],
"total_turns": len(turns),
}
with using_metadata(turn_metadata):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=conversation_history,
)
assistant_message = response.choices[0].message.content
conversation_history.append({"role": "assistant", "content": assistant_message})
print(f"Turn {turn_number} [{turn['stage']}]: {assistant_message[:80]}...")
run_conversation_with_metadata(user_id="user-7f3a2b", session_id="session-b58e33")You should see:
Turn 1 [opening]: Photosynthesis is the process by which plants, algae, and some b...
Turn 2 [deepening]: While photosynthesis converts light energy into stored chemical...
Turn 3 [closing]: Photosynthesis builds glucose from sunlight and CO2, while cellu...Each span in Tracing now carries a metadata attribute with turn_number, conversation_stage, and total_turns, visible in the span detail panel. Filter by userId in the LLM Tracing tab to see every span from one user across sessions.
Tip
Combine using_user(), using_session(), using_metadata(), and using_tags() into a single using_attributes() call. Import it from fi_instrumentation.
Flush and verify the grouped session
Short scripts can exit before the batch span processor sends its final spans. Call trace_provider.force_flush() at the end of any script to guarantee delivery before the process exits.
print("Flushed 3 turns to project 'chatbot-session-demo'.")
trace_provider.force_flush()You should see:
Flushed 3 turns to project 'chatbot-session-demo'.That print only confirms the script ran: the real check is the dashboard. After force_flush() returns, the final spans are in the session: see Step 3 for reading the Sessions tab row, and Step 4 for filtering by userId.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| No traces appear in the dashboard | The script exited before trace_provider.force_flush() ran, so the batch span processor never sent its buffer | Call trace_provider.force_flush() at the end of the script, as in Step 5 |
A span is missing user.id or session.id | The chat.completions.create() call ran outside the using_user/using_session with block | Move the call inside the context manager, or wrap the whole request handler |
| Every turn creates its own session instead of grouping | A new session_id was generated per turn instead of reusing one value for the whole conversation | Generate session_id once per conversation, outside the turn loop |
The Sessions tab shows a UUID, not the string passed to using_session() | This is expected: the platform assigns its own session ID and stores your string as the session name | Match on the session name field, not the displayed ID, when reconciling sessions with your own database |
metadata attribute is missing from a span | using_metadata() wrapped the wrong call, or was closed before the request ran | Confirm the chat.completions.create() call sits inside the using_metadata() block, as in Step 4 |
userId filter in LLM Tracing returns no results | Spans were traced before using_user() was added to the code, or the filter uses a different user ID string | Re-run the script with the current user_id and confirm it matches the value passed to using_user() |
ModuleNotFoundError: No module named 'traceai_openai' | traceAI-openai isn’t installed, or a different virtualenv is active | Run pip install fi-instrumentation-otel traceAI-openai openai in the environment you’re executing from |
Score a turn inside a grouped session with Inline Evals in Tracing.
Questions & Discussion