Instrument and Verify: Add Tracing to an Existing App and Prove It Worked

Add tracing to an app that has none, then prove it worked with ten machine-checked gates that either pass or name exactly what is missing.

📝
TL;DR

Adding tracing is the easy half. Knowing it worked is the half that gets skipped, because a trace that arrives looking fine can still be missing cost, sessions, users, or half its spans. This guide instruments an app that has none, then runs fi_verify.py, which checks ten gates against the spans your app really produced and exits 0 or names the gate that failed.

Open in ColabGitHub
TimeDifficultyPackage
20 minIntermediatefi-instrumentation-otel
Prerequisites
  • Future AGI account → app.futureagi.com
  • API keys: FI_API_KEY and FI_SECRET_KEY (see Get your API keys)
  • An app that makes at least one real LLM call, with an entry point you can run once
  • Python 3.11+ to run fi_verify.py. The listings below are Python, but the app you are tracing can be in any language: see If your app is not Python

Note

Handing this to a coding agent? Point it at this page and say: follow this end to end to GREEN LIGHT, and tell me the one thing you need from me. Step 1 downloads the checker, and Step 5 decides the result, so the agent never has to claim success on your behalf.

Install

Everything new lands in one directory. Existing files get a dependency, some configuration, one call at the entry point, and one around the model call.

your-repo/
├── observability/futureagi/
│   ├── setup.py               # provider, exporter, instrumentor      G1 G2
│   ├── fi_verify.py           # the checker, downloaded below         all ten
│   ├── futureagi_spans.py     # the attribute helpers                 G4 G5 G10
│   └── futureagi_rollup.py    # the model on a stream, your own rates G8
├── app/main.py                # + one scope at the entry point        G3 G6 G7
├── requirements.txt           # + fi-instrumentation-otel
└── .gitignore                 # + .fi_verify/

If your repository ships as a package, put observability/futureagi/ under your own package root instead of the top level.

Every step below offers two tracks, the Future AGI SDK and plain OpenTelemetry. Pick one and stay on it from here to the end. The two tracks write files of the same name that are not interchangeable, so every listing names its own track on the first line.

# Future AGI SDK track
pip install fi-instrumentation-otel      # not fi-instrumentation. Python 3.11+
pip install traceai-openai               # one per framework in use: -openai-agents, -anthropic,
                                         # -langchain, -llamaindex, -crewai, -litellm, -bedrock, ...
# OpenTelemetry track
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http

Then the checker. One file, no dependency beyond the OpenTelemetry SDK, and Step 1 runs it.

mkdir -p observability/futureagi
curl -fsSL https://docs.futureagi.com/fi_verify.py -o observability/futureagi/fi_verify.py
shasum -a 256 observability/futureagi/fi_verify.py
# 75ce8d9cb5bafa1cbeea42affe8b464433ab97e438a53410f27678855d484de7
export FI_API_KEY="your-api-key"
export FI_SECRET_KEY="your-secret-key"
export FI_PROJECT_NAME="my-app"

Note

Self-hosted? Both listings below hardcode https://api.futureagi.com. Point the SDK at your own deployment with FI_BASE_URL, the plain OpenTelemetry exporter with its own endpoint=, and the checker with FI_ENDPOINT, which is the full path including /tracer/v1/traces.

What a verified integration means

These failures all produce a trace list that looks populated, and reading the dashboard cannot tell them apart from a correct integration:

  • Cost and tokens are missing everywhere, because the LLM spans carry neither a model nor a token count and nothing can price them
  • One request appears as three traces, and each piece looks valid on its own
  • Sessions and users are blank, so conversations do not group and per-customer spend cannot be read
  • Evals report nothing, which looks identical to an eval that found no problems
  • Nothing arrives at all, and the client stays healthy because the collector refused the batch quietly

So the result is decided by a checker rather than by looking. Ten gates, each tied to the step that closes it:

GateHolds whenStep
G1the keys and route are accepted, and the collector took the real batch1
G2project_name and project_type are on the resource2
G3one request is one trace, one root, no orphans2
G6one session.id, identical across the trace3
G7user.id is present3
G4every span is typed, and at least one is an LLM span4
G5prompt and completion on every LLM span4
G8the model name is on every LLM span4
G9every LLM span is priceable, or carries a cost you sent4
G10no credential in any span attribute4

Nine gates read a local capture of your spans. G1 reads two receipts written during the run, because a capture says nothing about whether anything arrived.

Warning

Nothing substitutes for fi_verify.py. A checker that skips G1 reports success on keys the collector refused, because every other gate reads a local capture that a broken integration still writes perfectly. If the download is blocked, ask for the file rather than writing your own.

Tutorial

Prove the keys before writing code that depends on them

Three values: FI_API_KEY and FI_SECRET_KEY from the console, and FI_PROJECT_NAME, the name this app appears under. Tracing never needs your model provider key.

python observability/futureagi/fi_verify.py preflight

preflight sends one real span, then three deliberately broken variants. If any broken variant is accepted, nothing is proven and it fails.

AnswerMeans
200keys, route and payload all valid. Go to Step 2
401 authentication failedthe keys arrived and were refused: wrong keys, or keys from another environment
401 missing credentialsthe X-Api-Key and X-Secret-Key headers never arrived: unset, or a proxy strips them
400 no project_namekeys fine, payload not. It belongs on the resource, not the span
404wrong path. It ends /tracer/v1/traces, with no trailing slash

Note

You can start before the keys arrive. They usually sit with whoever owns the account rather than the engineer integrating, so ask for FI_API_KEY and FI_SECRET_KEY by name and say they belong in the environment. Every other step is built meanwhile, and only this gate waits.

Make one request arrive as one tree

Tokens, cost and latency roll up to the root, and both the trace list and every trace-scoped eval read the root. A request that arrives in pieces makes all three wrong, so get the shape right before anything else.

Three things break a trace into pieces, and nothing else does:

A thread hand-off, a pool, a background task. Copy the context in the caller and run the work inside it: c = contextvars.copy_context(), then c.run(fn, ...) in the worker. Captured on the far side it captures nothing, and attach alone restores the parent while dropping the Step 3 scope. Nothing crosses a message broker, so inject the W3C traceparent into the message and extract it in the task, which Distributed Tracing covers in full.

A stream or generator. The first chunk nests and the rest do not, so open the model span inside the generator and close it in a finally.

No entry point at all: a job, a consumer, a CLI, a frozen runtime like Lambda. The most common of the three. Open a CHAIN root by hand and flush before returning, because a batch sends on a timer and the sandbox freezes first.

# observability/futureagi/setup.py     Future AGI SDK track
import os, sys
from opentelemetry import trace
from fi_instrumentation import FITracer, register
from fi_instrumentation.fi_types import ProjectType
from traceai_openai import OpenAIInstrumentor

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))   # for fi_verify

# Import once at process start, AFTER whatever loads your .env and before any model call.
# Imported earlier the keys are not there yet, and only G1 says so.
tracer_provider = None
tracer = trace.get_tracer(__name__)   # a no-op tracer, so no key means no spans, not a crash

if os.getenv("FI_API_KEY") and os.getenv("FI_SECRET_KEY"):
    tracer_provider = register(
        project_name=os.getenv("FI_PROJECT_NAME", "my-app"),
        project_type=ProjectType.OBSERVE,
        set_global_tracer_provider=True,
    )
    OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
    # FITracer, not get_tracer(): a plain Tracer drops session.id and user.id, failing G6 and G7
    tracer = FITracer(tracer_provider.get_tracer(__name__))
    if os.getenv("FI_VERIFY") == "1":
        import fi_verify; fi_verify.attach(tracer_provider)

register() puts project_name and project_type on the resource for you, which is G2.

# observability/futureagi/setup.py     OpenTelemetry track
import os, sys, contextvars
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider, SpanProcessor
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))   # for fi_verify

# Import this AFTER whatever loads your .env, or the keys are not there yet and only G1 says so.
# On the RESOURCE: without it the collector answers 400 and the client still looks healthy
resource = Resource.create({"project_name": os.getenv("FI_PROJECT_NAME", "my-app"),
                            "project_type": "observe"})
provider = TracerProvider(resource=resource)

if os.getenv("FI_API_KEY") and os.getenv("FI_SECRET_KEY"):
    provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(
        endpoint="https://api.futureagi.com/tracer/v1/traces",
        headers={"X-Api-Key": os.getenv("FI_API_KEY"),
                 "X-Secret-Key": os.getenv("FI_SECRET_KEY")})))

# Step 3 sets this once at the edge; every span picks it up here, so no call site remembers it
_scope = contextvars.ContextVar("fi_scope", default={})

class FiScope(SpanProcessor):     # the base class no-ops the other three methods
    def on_start(self, span, parent_context=None):
        for k, v in _scope.get().items(): span.set_attribute(k, v)

provider.add_span_processor(FiScope())
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)

if os.getenv("FI_VERIFY") == "1":
    import fi_verify; fi_verify.attach(provider)

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_HEADERS and OTEL_RESOURCE_ATTRIBUTES are an alternative to the module above.

Carry session and user from one scope at the edge

A session groups traces into a conversation you can read in order, and a user id lets you read cost and quality per customer. One scope, opened where the request enters, so every span inside inherits it.

# your entry point: the scope outside, the root inside     Future AGI SDK track
from fi_instrumentation import using_attributes
from observability.futureagi.setup import tracer

# The scope goes OUTSIDE the root. Opened within it, it reaches the children and misses
# the root itself, and G6 and G7 read every span in the trace.
def handle(message):                # an HTTP route, a consumer, a job, a CLI run
    with using_attributes(session_id=conversation_id, user_id=account_id,
                          tags=["prod"], metadata={"tenant": tenant}):
        with tracer.start_as_current_span("orders.reprice") as root:
            root.set_attribute("gen_ai.span.kind", "CHAIN")
            root.set_attribute("input.value", message.body)
            answer = run_the_work(message)      # every model call nests under this
            root.set_attribute("output.value", answer)
            return answer
# your entry point: the scope outside, the root inside     OpenTelemetry track
from observability.futureagi.setup import tracer, _scope

# Set here and nowhere else. FiScope copies it onto every span in the request, root included.
def handle(message):                # an HTTP route, a consumer, a job, a CLI run
    token = _scope.set({"session.id": conversation_id, "user.id": account_id})
    try:
        with tracer.start_as_current_span("orders.reprice") as root:
            root.set_attribute("gen_ai.span.kind", "CHAIN")
            root.set_attribute("input.value", message.body)
            answer = run_the_work(message)      # every model call nests under this
            root.set_attribute("output.value", answer)
            return answer
    finally:
        _scope.reset(token)

Note

Three mistakes leave both fields empty. A new id on every span is worse than no session at all, because it looks correct. A scope opened inside the root reaches the children and misses the root, and both gates read every span. A scope lost at a thread or stream boundary is empty on exactly the spans that used to be orphans, which means Step 2 is not finished. Use an internal account id, never an email.

Write the attributes behind each field, cost included

A field is empty in the product because the attribute behind it was never sent. Cost is the one exception, and where that line falls is worth knowing exactly. Five of the ten gates depend on this step.

Field that stays emptyAttribute that fills itWritten by
the span typed in the treegen_ai.span.kindyou, every span
prompt and completion, every eval bindinginput.value, output.valueinstrumentor or you
tokens on an LLM spangen_ai.usage.input_tokens, .output_tokensinstrumentor, except on a stream
cost on an LLM spanpriced from those two and the modelus, unless you send gen_ai.cost.total
model and provider filtersgen_ai.request.model, gen_ai.provider.nameinstrumentor, except on a stream
session grouping, user analyticssession.id, user.idyou, at the edge

The instrumentor writes every LLM key for the calls it wraps. The root and a local TOOL or RETRIEVER span fall outside it. A hand-rolled client has no instrumentor at all, so its LLM span is written the same way, by hand, with the model, the messages and the counts taken off the response object. The kind is the bare name in capitals: CHAIN for the root, LLM for one model call, then ten more listed in the instrumentor reference.

Warning

A streamed call is missing two of these. Pass stream_options={"include_usage": True} and the token counts arrive on the final chunk, which is what G9 reads and what we price from; without it the provider sends none at all. The model name never arrives either, and the instrumentor’s span is never current in your code, so it has to be set as the span opens, which is G8. Off a stream both arrive on their own.

Cost is priced for you, on the spans we can price. Any span carrying gen_ai.request.model and a non-zero token count is priced on arrival, from the vendored LiteLLM rate table first and then from your organisation’s own custom model rates. A number you send yourself always wins, including an explicit 0.

Three surfaces read that number differently, and the difference is the whole of what follows:

SurfaceReads
the Cost column in the trace listthe root span’s own cost
a trace’s total, in the trace detailevery span in the trace, summed
a session’s totalevery span in the session, summed

A CHAIN root carries neither a model nor tokens, so nothing prices it. The trace list column stays empty while the trace total and the session total are already right, and that end state costs you no code at all.

Send cost yourself in two cases. Your rates differ from ours, or the model is not one we price: the table is keyed exactly, so gpt-4o-mini matches and a bare llama-3.3-70b-versatile does not, because the entry is groq/llama-3.3-70b-versatile. Put your number on the LLM span, where we would have priced it, and your value replaces ours with both totals still right.

export FI_MODEL_RATES='{"llama-3.3-70b-versatile": [0.59, 0.79]}'   # {"<model>": [in, out]} per 1M

Warning

Summing the total onto the root as well double counts. The trace total and the session total add every span, and the root is one of them, so both read close to twice the real spend. Roll up onto the root only when the trace list’s Cost column is the one you are optimising for, and take that trade knowingly.

# observability/futureagi/futureagi_spans.py     Future AGI SDK track
from contextlib import contextmanager
from observability.futureagi.futureagi_rollup import MODEL

@contextmanager               # wrap the client call. The instrumentor writes the rest
def llm_call(model):
    t = MODEL.set(model)
    try: yield
    finally: MODEL.reset(t)

# a local TOOL or RETRIEVER span, and the root: three lines each
with tracer.start_as_current_span("lookup_price") as span:
    span.set_attribute("gen_ai.span.kind", "TOOL")
    span.set_attribute("gen_ai.tool.name", "lookup_price")
    span.set_attribute("output.value", json.dumps(catalogue.price(sku)))
# observability/futureagi/futureagi_rollup.py     Future AGI SDK track
import json, os, contextvars
from opentelemetry import context as otel_context
from opentelemetry.sdk.trace import SpanProcessor
from observability.futureagi.setup import tracer_provider as P   # None until the keys are set

RATES = json.loads(os.getenv("FI_MODEL_RATES", "{}"))   # {"<model>": [in, out]} per 1M
MODEL = contextvars.ContextVar("fi_model", default=None)   # what llm_call is about to call
RUN = otel_context.create_key("fi_run")     # rides the OpenTelemetry context, so a copied
M = "gen_ai.request.model"                  # context carries it over a thread hand-off too.
T = ["gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens",
     "gen_ai.usage.total_tokens", "gen_ai.cost.total"]

class RollUp(SpanProcessor):
    def on_start(self, span, parent_context=None):   # the instrumentor never leaves its LLM
        if MODEL.get(): span.set_attribute(M, MODEL.get())   # span current in your code, so
    def on_end(self, span):                          # the name goes on as the span opens
        run, a = otel_context.get_value(RUN), span.attributes or {}
        if run is not None:                    # None outside a request: nothing to sum onto
            if T[0] in a:                      # tokens land on LLM spans, which end first
                i, o = RATES.get(a.get(M), [0, 0])          # your rate, for a model
                a = dict(a, **{T[3]: (a[T[0]] * i + a.get(T[1], 0) * o) / 1e6})   # we do not price
            for k in T: run[k] = run.get(k, 0) + a.get(k, 0)

# register() leaves _default_processor set, and the first add_span_processor call on that
# provider discards the exporter it installed. Clearing it first is what keeps delivery.
if P: P._default_processor = False; P.add_span_processor(RollUp())
# observability/futureagi/futureagi_spans.py     OpenTelemetry track
# Every key comes from here. One module, so a name can be misspelled only once.
import json
from contextlib import contextmanager

def _set(span, attrs):              # None is never written: an empty attribute
    for k, v in attrs.items():      # reads as a missing one
        if v is not None: span.set_attribute(k, v if isinstance(v, (str, bool, int, float))
                                             else json.dumps(v, default=str))

@contextmanager
def span_of(t, name, kind, opening, alias=None):    # one shape for all twelve kinds
    with t.start_as_current_span(name) as span:
        _set(span, {"gen_ai.span.kind": kind, **opening})
        yield lambda out=None, extra=None: _set(span, {"output.value": out, **(extra or {}),
                                                       **({alias: out} if alias else {})})
# observability/futureagi/futureagi_rollup.py     OpenTelemetry track
import json, os
from opentelemetry import context as otel_context
from opentelemetry.sdk.trace import SpanProcessor
from observability.futureagi.setup import provider as P   # the provider Step 2 built

RATES = json.loads(os.getenv("FI_MODEL_RATES", "{}"))   # {"<model>": [in, out]} per 1M
RUN = otel_context.create_key("fi_run")     # rides the OpenTelemetry context, so a copied
M = "gen_ai.request.model"                  # context carries it over a thread hand-off too.
T = ["gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens",
     "gen_ai.usage.total_tokens", "gen_ai.cost.total"]

class RollUp(SpanProcessor):
    def on_end(self, span):        # the base class no-ops the other three methods
        run, a = otel_context.get_value(RUN), span.attributes or {}
        if run is not None and T[0] in a:      # tokens land on LLM spans, which end first
            i, o = RATES.get(a.get(M), [0, 0])            # your rate, for a model
            a = dict(a, **{T[3]: (a[T[0]] * i + a.get(T[1], 0) * o) / 1e6})   # we do not price
            for k in T: run[k] = run.get(k, 0) + a.get(k, 0)

P.add_span_processor(RollUp())

The roll-up needs a per-request accumulator, opened at the entry point rather than inside the module above:

# your entry point, around the root from Step 3
run = {k: 0 for k in T}          # mutated in place, so a copied context shares it
token = otel_context.attach(otel_context.set_value(RUN, run))
try:
    with tracer.start_as_current_span("orders.reprice") as root:
        answer = plan_and_run(order_id)
        run[T[2]] = run.get(T[2]) or run.get(T[0], 0) + run.get(T[1], 0)   # only if the
        for k in T: root.set_attribute(k, run.get(k, 0))              # provider sent none
finally:
    otel_context.detach(token)

A reasoning model bills for tokens in neither bucket, so carry the total the provider sent rather than adding the two up. Clip input.value at 8000 characters and redact it for G10: a serialised config object and a prompt that already carries a key are the two ways a credential reaches a span.

Run the ten gates

Three commands. It exits 0, or it names the gate that failed and why. Run it again after every change.

printf '\n.fi_verify/\n' >> .gitignore     # echo would join the last line
export FI_VERIFY=1

python observability/futureagi/fi_verify.py preflight   # keys, route, three broken controls
python -m your_app                                      # one real request, your entry point
python observability/futureagi/fi_verify.py check       # ten gates: receipts, then spans

A passing run:

  PASS  G1   preflight ok, delivery ok
  PASS  G2   project_name='my-app' project_type='observe'
  PASS  G3   7 spans, 1 trace(s), 1 root(s), 0 orphan(s)
  PASS  G4   3 LLM span(s), 0 untyped
  PASS  G5   prompt and completion on every LLM span
  PASS  G6   session.id=['c-4182']
  PASS  G7   user.id=['acct-993']
  PASS  G8   model on every LLM span
  PASS  G9   3 LLM span(s): 3 priceable from the model and tokens, 0 carrying your own cost
  PASS  G10  no credential in any span attribute

  Future AGI integrated
  GREEN LIGHT achieved

Each FAIL row names one gate and one cause. If the same gate fails twice for the same reason, the problem is outside your codebase.

ai-evaluation is not installed, please install it to trace protect on stderr is expected and affects no gate. It is the optional Protect package, which tracing does not need.

Bind evals to attributes the trace now carries

A correct trace is what lets you answer quality questions. An eval can only grade what it is bound to, and you bind every variable yourself. One with nothing bound reports nothing, which looks the same as one that found no problems.

Evals are configured in the console, never in application code. Binding is manual per eval task: a scope, a template, then each variable pointed at an attribute path in your own data.

  • Anything reading the request binds at Traces scope against the root, where input.value is the real question. On an instrumented LLM span it holds only the first message, and the full turn is under gen_ai.input.messages.*.
  • Output-only evals bind at Spans scope to output.value, which G5 already proved is there.

Read your own attributes out of .fi_verify/spans.jsonl first, so every variable you bind points at a path that is provably there. Then pick from the eval catalogue and attach them with Setup evals. The five bindings in The evals bound to it below are a worked set to copy the shape from.

The same six steps on a real repository

Everything above was run against openai/openai-agents-python, on its examples/customer_service airline support agent. Nothing in that repository was written for this guide, which is the point: it is a normal app with normal problems.

It is a fair target because it has all four of them at once. It emits no trace the platform can read. A single message fans out across a triage agent, a handoff, a specialist agent and a local tool, so Step 2 has real work to do. main.py is an interactive REPL, so there is no entry point that runs once, which is Step 2’s third case. And it carries a conversation id and a passenger context already, so Step 3 has a real session and a real user to attach rather than invented ones.

git clone --depth 1 https://github.com/openai/openai-agents-python
cd openai-agents-python
pip install openai-agents fi-instrumentation-otel traceai-openai-agents

observability/futureagi/ is the four files from Step 2 and Step 4 on the Future AGI SDK track, with traceai-openai-agents as the instrumentor. The one file added outside it is the entry point the repository does not have. No business logic was edited, and main.py was imported, not modified.

# examples/customer_service/run_traced.py
"""One customer message through the airline support agent, traced end to end.

main.py is an interactive REPL, so the repository has no entry point that runs once. This
is that entry point: the roll-up and the scope outside, the CHAIN root inside, a flush
before returning. Run it twice with the same FI_SESSION_ID to see a session of two traces.
"""
import asyncio, os, sys, uuid

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))

from observability.futureagi.setup import tracer, tracer_provider
from observability.futureagi import futureagi_rollup as R
from fi_instrumentation import using_attributes
from opentelemetry import context as otel_context

from agents import Runner, RunConfig, set_default_openai_api
from examples.customer_service.main import AirlineAgentContext, triage_agent

if os.getenv("OPENAI_BASE_URL"):      # an OpenAI-compatible endpoint that is not OpenAI
    set_default_openai_api("chat_completions")   # only OpenAI serves the Responses API

MODEL = os.getenv("AGENT_MODEL", "gpt-4o-mini")


async def handle(question, session_id, user_id):
    run = {k: 0 for k in R.T}          # mutated in place, so a copied context shares it
    token = otel_context.attach(otel_context.set_value(R.RUN, run))
    try:
        with using_attributes(session_id=session_id, user_id=user_id,
                              tags=["prod"], metadata={"channel": "web"}):
            with tracer.start_as_current_span("support.turn") as root:
                root.set_attribute("gen_ai.span.kind", "CHAIN")
                root.set_attribute("input.value", question[:8000])
                root.set_attribute(R.M, MODEL)   # the Model column reads the root, not the
                                                 # LLM spans the instrumentor writes it on
                result = await Runner.run(
                    triage_agent, [{"content": question, "role": "user"}],
                    context=AirlineAgentContext(), run_config=RunConfig(model=MODEL))
                answer = str(result.final_output)
                root.set_attribute("output.value", answer[:8000])
                run[R.T[2]] = run.get(R.T[2]) or run.get(R.T[0], 0) + run.get(R.T[1], 0)
                for k in R.T: root.set_attribute(k, run.get(k, 0))
                return result, answer
    finally:
        otel_context.detach(token)


async def main():
    question = " ".join(sys.argv[1:])
    session_id = os.getenv("FI_SESSION_ID") or "conv_" + uuid.uuid4().hex[:12]
    user_id = os.getenv("ACCOUNT_ID", "acct_10427")

    result, answer = await handle(question, session_id, user_id)
    print(f"{result.last_agent.name}: {answer}")

    if tracer_provider: tracer_provider.force_flush()   # a batch sends on a timer


if __name__ == "__main__":
    asyncio.run(main())
export FI_VERIFY=1 FI_SESSION_ID=conv_94ac3e74fcc4

python observability/futureagi/fi_verify.py preflight
python examples/customer_service/run_traced.py "How much baggage am I allowed to bring on the plane?"
python observability/futureagi/fi_verify.py check
  PASS  G1   preflight ok, delivery ok
  PASS  G2   project_name='support-agent-quickstart' project_type='observe'
  PASS  G3   13 spans, 1 trace(s), 1 root(s), 0 orphan(s)
  PASS  G4   3 LLM span(s), 0 untyped
  PASS  G5   prompt and completion on every LLM span
  PASS  G6   session.id=['conv_94ac3e74fcc4']
  PASS  G7   user.id=['acct_10427']
  PASS  G8   model on every LLM span
  PASS  G9   3 LLM span(s): 3 priceable from the model and tokens, 0 carrying your own cost
  PASS  G10  no credential in any span attribute

  Future AGI integrated
  GREEN LIGHT achieved

Run it a second time with the same FI_SESSION_ID and a second message, and the two turns join one session.

What that produced

The trace list reads the root and nothing else, which is why Step 4 puts the totals there. Latency and status arrive on their own; the tokens, the cost and the model are on the root because this run put them there.

The Future AGI trace list showing two support.turn traces with input, output, latency, tokens, total cost and model

Two support.turn traces, the Cost column reading the total this run rolled onto each root because our table does not price this model

Inside a trace, the instrumentor typed the agents and the model calls, and the handoff. faq_lookup_tool is a local function the instrumentor cannot see, so its TOOL span is the three lines from Step 4. The attributes panel is the same list Step 4 sends, read back off a real span.

The trace tree for one support turn, from the support.turn root through the triage agent, the handoff, the FAQ agent, the tool span and three LLM spans, with the attributes panel showing session.id, user.id and cost

One support.turn trace: the CHAIN root, the triage agent, the handoff, the FAQ agent, the local faq_lookup_tool span and the three LLM spans, with the attributes panel open on a real span

Because session.id was set once at the edge, every turn in the conversation groups under one session without any turn knowing about the other.

The Future AGI sessions view showing one session with its first and last message, duration, total cost, three traces and the user id

One session, grouped by the session.id Step 3 set once at the edge

Note

The model on these captures reads llama-3.3-70b-versatile because the run pointed at an OpenAI-compatible endpoint that is not OpenAI. Our table keys that model as groq/llama-3.3-70b-versatile, so the bare name does not match and nothing prices it: this run is the second case from Step 4, and FI_MODEL_RATES is where its published rate goes. On a model we do price, gpt-4o-mini among them, drop the roll-up and the totals arrive on their own. Set AGENT_MODEL to whatever you use; nothing else in the integration changes with the provider.

The evals bound to it

Each of these binds only to an attribute the run above already carries, read out of .fi_verify/spans.jsonl.

EvalScopeBound to
Task CompletionTracesinput.value and output.value on the root
Evaluate Function CallingSpansthe tool call arguments on the first LLM span
Detect HallucinationTracesinput.value and output.value, catching an answer the tool never returned
Instruction AdherenceTracesinput.value and output.value against the agent’s own instructions
PII DetectionSpansoutput.value, which G5 already proved is on every LLM span

On an instrumented LLM span, input.value holds only the first message and the full turn sits under gen_ai.input.messages.*. Bind anything that reads the request at Traces scope against the root, where input.value is the customer’s actual question.

If your app is not Python

The listings translate line for line, and everything reaches the same endpoint over plain OpenTelemetry. Carry across:

  • Both headers, X-Api-Key and X-Secret-Key, on https://api.futureagi.com/tracer/v1/traces
  • project_name and project_type on the resource, not the span
  • One root per unit of work
  • The scope from Step 3, set at the edge
  • The Step 4 attribute keys, byte for byte
  • The roll-up, only if you need the trace list’s Cost column or your own rates

Spring Boot also needs management.tracing.sampling.probability=1.0. First-party SDKs and the full framework list are in the instrumentor reference.

The checker still runs. preflight and check are plain Python with no dependency at all, so they work next to an app in any language. Only fi_verify.attach() is Python-bound, because it installs a processor inside your process. Without it, write the capture yourself: one JSON object per line in .fi_verify/spans.jsonl, each with name, trace_id, span_id, parent_id (null on the root), attrs, and resource. That is roughly ten lines in any OpenTelemetry span exporter, and check reads it the same way either way.

Troubleshooting

SymptomCauseFix
preflight answers 400 no project_nameproject_name went on the span instead of the resourcePut it on the Resource, as Step 2 does; register() does it for you
check reports no spans captured, so the traced path never ranFI_VERIFY=1 was not exported, so fi_verify.attach() never installed its processorExport FI_VERIFY=1, re-run the entry point, then re-run check
G3 reports more than one root, or orphansA thread hand-off, a stream, or no entry point at all, which are the only three causesCopy the context with contextvars.copy_context(), open the model span inside the generator, or open a CHAIN root by hand and flush before returning
G6 or G7 pass on the children and the root is emptyThe scope was opened inside the root instead of around it, and both gates read every spanMove using_attributes() (or _scope.set()) outside start_as_current_span, as in Step 3
G8 fails on a streamed call onlyThe instrumentor’s LLM span is never current in your code, so the model name is never set on itSet it in RollUp.on_start, as the span opens, which is what the Step 4 listing does
G9 names a span with no model, no tokens and no costA hand-rolled client with no instrumentor, or a stream without usagePass stream_options={"include_usage": True}, or write the counts off the response object yourself
Every gate passes and the trace list’s Cost column is still emptyThe trace list reads the root, which carries no model and no tokens, so nothing prices itExpected. Roll the total onto the root only if that column is the one you need, and read the Step 4 warning first
Spans stop arriving as soon as the roll-up is added, on the Future AGI SDK trackregister() leaves _default_processor set, and the first add_span_processor discards the exporter it installedClear P._default_processor before add_span_processor, as futureagi_rollup.py does
ai-evaluation is not installed, please install it to trace protect on stderrThe optional Protect package is absentExpected, and it affects no gate. Tracing does not need it

Keep that one trace across a service boundary with Distributed Tracing.

Was this page helpful?

Questions & Discussion