Session-Based Observability

Group a multi-turn chatbot's spans into one session you can filter and replay.

📝
TL;DR

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.

Open in ColabGitHub
TimeDifficultyPackage
15 minBeginnerfi-instrumentation-otel + traceAI-openai
Prerequisites

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.comLLM 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

SymptomCauseFix
No traces appear in the dashboardThe script exited before trace_provider.force_flush() ran, so the batch span processor never sent its bufferCall trace_provider.force_flush() at the end of the script, as in Step 5
A span is missing user.id or session.idThe chat.completions.create() call ran outside the using_user/using_session with blockMove the call inside the context manager, or wrap the whole request handler
Every turn creates its own session instead of groupingA new session_id was generated per turn instead of reusing one value for the whole conversationGenerate 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 nameMatch on the session name field, not the displayed ID, when reconciling sessions with your own database
metadata attribute is missing from a spanusing_metadata() wrapped the wrong call, or was closed before the request ranConfirm the chat.completions.create() call sits inside the using_metadata() block, as in Step 4
userId filter in LLM Tracing returns no resultsSpans were traced before using_user() was added to the code, or the filter uses a different user ID stringRe-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 activeRun 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.

Was this page helpful?

Questions & Discussion