AgentScope tracing with Future AGI

Send the OpenTelemetry traces of an AgentScope 2.x agent to Future AGI with the framework's own TracingMiddleware and an exporter you configure. Covers what is captured, the metadata-only default and the opt-in for content, and the AgentScope 1.x path.

Trace AgentScope 2.x agent replies, model calls, and tool calls in a self-hosted Future AGI Observe project using TracingMiddleware and an OTLP/HTTP exporter.

This guide uses OpenAIChatModel and AgentScope 2.0.9 or 2.0.10. For the different 1.x setup, see AgentScope 1.x. Do not add a separate instrumentor for the same model calls: it can duplicate spans or interfere with streaming.

1. Install

pip install agentscope==2.0.10

agentscope depends on opentelemetry-sdk and opentelemetry-exporter-otlp, and the second one includes the OTLP/HTTP exporter used below. Python 3.11 or later is required.

2. Set environment variables

export FI_API_KEY="your-futureagi-api-key"
export FI_SECRET_KEY="your-futureagi-secret-key"
export FI_PROJECT_NAME="my-chatbot"             # the Observe project the traces land in
export OPENAI_API_KEY="your-model-provider-api-key"
# Self-hosted Future AGI origin. Traces go to $FI_BASE_URL/tracer/v1/traces.
export FI_BASE_URL="http://localhost:4318"
# export FI_CAPTURE_CONTENT=1                   # opt in to prompts, answers and tool payloads

Set FI_BASE_URL to the origin only. The code adds /tracer/v1/traces.

3. Add the tracing module

AgentScope does not configure an exporter, so this module does. Save it next to your agent code as futureagi_tracing.py. It builds the tracer provider and installs it as the global one.

PieceValue
EndpointFI_BASE_URL followed by /tracer/v1/traces. HTTP, not gRPC. Traces only: the collector has no logs or metrics route.
HeadersBoth X-Api-Key and X-Secret-Key. A Bearer token does not authenticate.
Resourceproject_name and project_type=observe. Future AGI files spans by the resource attribute project_name. A span attribute with that key does not count, and a batch without it is rejected. service.name is not read as a project name.
CompressionOff, which works on every Future AGI release. Future AGI cloud and self-hosted v1.56.0 and later also accept gzip; earlier self-hosted releases answered it with a 400. The module sets compression explicitly so OTEL_EXPORTER_OTLP_COMPRESSION=gzip in the environment cannot turn it on.
ContentOff by default. See Privacy.
"""Send AgentScope 2.x traces to Future AGI.

AgentScope 2.x does not take an endpoint, headers or a resource. Its
``TracingMiddleware`` creates spans on the global OpenTelemetry tracer and nothing else,
so the application owns the tracer provider. ``setup_tracing`` builds that provider:

* the resource carries ``project_name`` (Future AGI files spans by it) and ``project_type``;
* the OTLP/HTTP exporter carries ``X-Api-Key`` and ``X-Secret-Key``, sends no gzip, and
  posts to ``<FI_BASE_URL>/tracer/v1/traces`` (traces only, no logs or metrics);
* by default only metadata leaves the process (see ``METADATA_ATTRIBUTES``). Set
  ``FI_CAPTURE_CONTENT=1`` (or ``capture_content=True``) to also export prompts, answers,
  tool arguments and tool results, and the text of an error;
* a stack trace is never exported, in either mode: it holds local file paths.

AgentScope itself has no switch for content: ``TracingMiddleware`` always writes the
messages and tool payloads onto the spans. ``ContentFilterExporter`` is the off switch.

Call ``setup_tracing`` once, before the first agent reply.
"""

from __future__ import annotations

import os
from collections.abc import Mapping, Sequence

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
    Compression,
    OTLPSpanExporter,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import Event, ReadableSpan, TracerProvider
from opentelemetry.sdk.trace.export import (
    BatchSpanProcessor,
    SpanExporter,
    SpanExportResult,
)
from opentelemetry.trace import Status, StatusCode

# Attributes of AgentScope's own spans that describe a call without containing what was
# said or done. Anything not listed here is dropped unless content capture is on, so an
# attribute added by a later AgentScope release stays private until it is reviewed.
METADATA_ATTRIBUTES = frozenset(
    {
        "gen_ai.operation.name",
        "gen_ai.provider.name",
        "gen_ai.request.model",
        "gen_ai.request.temperature",
        "gen_ai.request.top_p",
        "gen_ai.request.top_k",
        "gen_ai.request.max_tokens",
        "gen_ai.request.presence_penalty",
        "gen_ai.request.frequency_penalty",
        "gen_ai.request.seed",
        "gen_ai.response.id",
        "gen_ai.response.finish_reasons",
        "gen_ai.usage.input_tokens",
        "gen_ai.usage.output_tokens",
        "agentscope.usage.cache_input_tokens",
        "agentscope.usage.cache_creation_input_tokens",
        "gen_ai.agent.name",
        "gen_ai.conversation.id",
        "gen_ai.tool.name",
        "gen_ai.tool.call.id",
        "agentscope.agent.reply_id",
        "agentscope.agent.hitl_pending_tools",
        "agentscope.agent.external_execution_pending_tools",
        "agentscope.agent.incoming_event_type",
        "agentscope.agent.is_external_execution",
    }
)

_TRUE = {"1", "true", "yes", "on"}


def _filter_event(event: Event, capture_content: bool) -> Event:
    """Keep an event's name and time. The stack trace of an exception event is always
    dropped (it holds local file paths); its message, which can hold user text, is kept
    only with content on."""
    attributes = dict(event.attributes or {})
    if event.name == "exception":
        keep = {"exception.type", "exception.message"} if capture_content else {"exception.type"}
        attributes = {key: value for key, value in attributes.items() if key in keep}
    elif not capture_content:
        attributes = {}
    return Event(name=event.name, attributes=attributes, timestamp=event.timestamp)


class _FilteredSpan:
    """A read-only view of a finished span, with the content removed.

    Everything the OTLP encoder reads is delegated to the real span, except the
    attributes, the events and the status.
    """

    def __init__(self, span: ReadableSpan, capture_content: bool) -> None:
        self._span = span
        self._attributes = {
            key: value
            for key, value in (span.attributes or {}).items()
            if capture_content or key in METADATA_ATTRIBUTES
        }
        self._events = tuple(_filter_event(event, capture_content) for event in span.events)
        status = span.status
        description = status.description
        if status.status_code == StatusCode.ERROR and not capture_content:
            # The status message is str(exception). Keep the class name instead.
            description = next(
                (
                    event.attributes["exception.type"]
                    for event in self._events
                    if "exception.type" in event.attributes
                ),
                None,
            )
        self._status = Status(status.status_code, description)

    def __getattr__(self, name: str):
        return getattr(self._span, name)

    @property
    def attributes(self) -> Mapping[str, object]:
        return self._attributes

    @property
    def events(self) -> Sequence[Event]:
        return self._events

    @property
    def status(self) -> Status:
        return self._status


class ContentFilterExporter(SpanExporter):
    """Wrap an exporter so that content, and always stack traces, never leave the process."""

    def __init__(self, inner: SpanExporter, capture_content: bool = False) -> None:
        self._inner = inner
        self._capture_content = capture_content

    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
        return self._inner.export([_FilteredSpan(span, self._capture_content) for span in spans])

    def shutdown(self) -> None:
        self._inner.shutdown()

    def force_flush(self, timeout_millis: int = 30000) -> bool:
        return self._inner.force_flush(timeout_millis)


def setup_tracing(
    project_name: str | None = None,
    *,
    capture_content: bool | None = None,
    base_url: str | None = None,
    api_key: str | None = None,
    secret_key: str | None = None,
) -> TracerProvider:
    """Install a tracer provider that exports AgentScope's spans to Future AGI."""
    project_name = project_name or os.environ.get("FI_PROJECT_NAME") or "agentscope-app"
    if capture_content is None:
        capture_content = os.environ.get("FI_CAPTURE_CONTENT", "").lower() in _TRUE
    base_url = (base_url or os.environ.get("FI_BASE_URL") or "https://api.futureagi.com").rstrip("/")
    api_key = api_key or os.environ["FI_API_KEY"]
    secret_key = secret_key or os.environ["FI_SECRET_KEY"]

    exporter = OTLPSpanExporter(
        endpoint=f"{base_url}/tracer/v1/traces",
        headers={"X-Api-Key": api_key, "X-Secret-Key": secret_key},
        compression=Compression.NoCompression,  # optional: Future AGI cloud and self-hosted v1.56.0+ also accept gzip
    )

    provider = TracerProvider(
        resource=Resource.create({"project_name": project_name, "project_type": "observe"})
    )
    provider.add_span_processor(BatchSpanProcessor(ContentFilterExporter(exporter, capture_content)))
    trace.set_tracer_provider(provider)
    return provider

The ContentFilterExporter is there because AgentScope has no switch for content. TracingMiddleware always writes the messages, tool arguments and tool results onto its spans, so keeping them out has to happen on the way to the exporter. If you want everything AgentScope records, set FI_CAPTURE_CONTENT=1 and skip nothing else.

4. Add the middleware to your agent

Call setup_tracing once, before the first reply, then add TracingMiddleware() to the agent. Both steps are needed: without the middleware there are no spans, and without a provider the middleware does nothing. Save this as quickstart.py.

import asyncio
import os

from agentscope.agent import Agent
from agentscope.credential import OpenAICredential
from agentscope.message import UserMsg
from agentscope.middleware import TracingMiddleware
from agentscope.model import OpenAIChatModel
from agentscope.permission import PermissionBehavior, PermissionDecision
from agentscope.state import AgentState
from agentscope.tool import FunctionTool, Toolkit

from futureagi_tracing import setup_tracing

# 1. Install the tracer provider once, before the first reply.
provider = setup_tracing(project_name="my-chatbot")


def get_weather(city: str) -> str:
    """Get the current weather for a city.

    Args:
        city (`str`): The city name.
    """
    return f"It is 21 C and sunny in {city}."


agent = Agent(
    name="weather-agent",
    system_prompt="You are a weather assistant. Always call get_weather before answering.",
    model=OpenAIChatModel(
        credential=OpenAICredential(
            api_key=os.environ["OPENAI_API_KEY"],
            base_url=os.environ.get("OPENAI_BASE_URL"),  # any OpenAI-compatible endpoint
        ),
        model=os.environ.get("OPENAI_MODEL", "gpt-4.1"),
    ),
    toolkit=Toolkit(
        tools=[
            FunctionTool(
                get_weather,
                permission=PermissionDecision(behavior=PermissionBehavior.ALLOW, message="allowed"),
            )
        ]
    ),
    # 2. Add the middleware. It creates the spans.
    middlewares=[TracingMiddleware()],
    state=AgentState(session_id="support-chat-42"),
)

reply = asyncio.run(agent.reply(UserMsg(name="user", content="What is the weather in Paris?")))
print(reply.get_text_content())

# 3. Short scripts: send what is buffered before the process exits.
provider.force_flush()

Three things about the order and the lifecycle:

  • The middleware looks the provider up on every call, so building the agent before setup_tracing still works. A reply that runs before the provider exists produces no spans and no error.
  • The provider batches spans and flushes when the process exits, so a script that never calls force_flush still exports on a normal exit. Call it in short-lived scripts, notebooks and serverless handlers.
  • Only the middleware on the agent matters. Calling agentscope.init does not configure tracing in 2.x. If that is all you called, nothing leaves the process.

5. Look at the trace

Open Observe, select the project (my-chatbot above), and open a trace. One reply is one trace:

invoke_agent weather-agent       the reply (type shown as unknown)
├── chat gpt-4.1                 llm    first model call, asks for the tool
├── execute_tool get_weather     tool
└── chat gpt-4.1                 llm    second model call, writes the answer

Model calls and tool calls hang under the reply span, in the order they ran. AgentScope sets the hierarchy and this page does not change it.

What is captured

SignalWhat you get
ReplyOne invoke_agent <agent name> span per agent.reply(...), with the agent name and a reply id. Future AGI types it unknown, not agent. See the note below the table.
ModelOne chat <model> span per model call, with the model id, gen_ai.provider.name, finish reasons, and any request parameters you set (temperature, max_tokens, and so on). The provider name comes from the AgentScope model class, and for OpenAIChatModel from the base URL. An OpenAI-compatible endpoint that AgentScope does not recognise shows as openai.
TokensInput and output tokens from the provider’s usage, on streamed and non-streamed calls. If a provider reports none, the span has none: nothing is estimated. Cache token counts appear as agentscope.usage.* attributes when the provider reports them.
StreamingA streamed model call is one span, closed after the last chunk, and its usage comes from the last chunk.
ToolOne execute_tool <name> span per tool call, with the tool name and call id. Arguments and result are content, so they are in the span only with the opt-in.
Sessiongen_ai.conversation.id carries the AgentScope session id on every span. Set it with AgentState(session_id=...). Without one, AgentScope generates a random id for each AgentState. It is kept as a span attribute and is not mapped to Future AGI’s Sessions view.
CostNot captured.
Input and output columnsEmpty, even with content on. AgentScope writes prompts and answers to gen_ai.input.messages and gen_ai.output.messages, which Future AGI stores as span attributes. They are not mapped to the Input and Output columns.
Retrieval, embeddings, memoryNot captured. TracingMiddleware only instruments the reply, the model call and the tool call.

gen_ai.operation.name values chat and execute_tool become the types llm and tool, and invoke_agent stays unknown. The tree, model, tokens and latency are all there. The reply span’s own token total reads 0 because the tokens are on the model spans under it.

Errors

FailureWhat you see
The model call fails (a model id the endpoint does not serve, a refused key, an unreachable endpoint)The chat span and the invoke_agent span are ERROR. The trace is still exported, with no token counts. The status message is the exception class (for example openai.BadRequestError) by default, and the full error text with the opt-in.
A tool raisesThe execute_tool span stays OK. AgentScope catches the exception and returns an error result to the model, so nothing propagates through the middleware. With content on, the result attribute contains ToolResultState.ERROR and the error text. With the metadata-only default there is no signal on the span that the tool failed.
The collector is unreachable, slow, or rejects the batchYour agent is not affected. The exporter retries, logs a warning and drops the batch, so those spans are lost.

Privacy

Default. Only metadata is exported: model id, provider, token counts, finish reasons, agent name, tool names, tool call ids, the session id, reply id, span names, timings and status. The prompt, the system prompt, the model’s answer, tool definitions, tool arguments, tool results and the text of an error are removed before the exporter sees them. The agent still gets its full answer.

Why this needs code. AgentScope 2.x has no setting for it. TracingMiddleware writes gen_ai.input.messages, gen_ai.output.messages, gen_ai.tool.definitions, gen_ai.tool.call.arguments and gen_ai.tool.call.result onto its spans every time. The module’s METADATA_ATTRIBUTES list is an allow-list, so an attribute added by a later AgentScope release stays private until you review it.

Opt in. Set FI_CAPTURE_CONTENT=1 before the process starts, or call setup_tracing(capture_content=True). The spans then carry everything AgentScope records, unchanged, including the full prompt history on every model call, the system prompt and tool definitions, tool arguments and results, and the message of an exception. AgentScope’s converter writes base64 media blocks into the message attributes, so they are exported too. A stack trace is never exported, in either mode, because it holds local file paths.

What the default does not remove:

  • The model id, token counts, agent name, tool names, span names, timings and status. These are what the trace is for.
  • The session id. If you set AgentState(session_id=...) to something personal, such as an email address, it is exported. Use an opaque id.
  • The exception class name (for example openai.BadRequestError), as the status message and as the exception.type of the exception event.
  • Resource attributes from the OpenTelemetry SDK: telemetry.sdk.*, service.name and a random service.instance.id. If you add resource detectors or set OTEL_RESOURCE_ATTRIBUTES, whatever they add is exported as well. No host name is added by default.

Your model provider sees every prompt whatever you set here. This page only controls what Future AGI receives.

AgentScope 1.x

AgentScope 1.x has a different activation: agentscope.init(tracing_url=...) creates an OTLP/HTTP exporter and a tracer provider inside AgentScope, and 2.x removed it. Do not copy a 1.x snippet into a 2.x project, or the other way round.

1.x takes no headers and no resource, so tracing_url alone sends an unauthenticated batch with no project_name, which Future AGI rejects. The standard OpenTelemetry environment variables are the only way to supply them. AgentScope’s own 1.x docs do the same for Arize Phoenix and Langfuse, with OTEL_EXPORTER_OTLP_HEADERS:

export OTEL_EXPORTER_OTLP_TRACES_HEADERS="X-Api-Key=$FI_API_KEY,X-Secret-Key=$FI_SECRET_KEY"
export OTEL_RESOURCE_ATTRIBUTES="project_name=my-chatbot,project_type=observe"

Use the same self-hosted FI_BASE_URL from step 2:

import os
import agentscope

traces_url = f"{os.environ['FI_BASE_URL'].rstrip('/')}/tracer/v1/traces"
agentscope.init(tracing_url=traces_url)  # call before building agents

The 1.x path also emits one format span per formatter call, typed unknown.

Limits of the 1.x path:

  • There is no content switch. 1.x always exports prompts, answers and tool payloads, and also the full input and output of every instrumented function. Use 2.x if you need the metadata-only default.
  • For agentscope==1.0.21, install mcp<2, greenlet and tqdm: MCP 2.x removed a function required by AgentScope 1.x.
  • For streamed OpenAI responses with AgentScope 1.0.21, install openai<3 to avoid a streaming crash.

Troubleshooting

What you seeWhat it means
No traces for the projectThe batch has no project_name resource attribute, or the provider was never installed. Check that setup_tracing() ran before the first reply and that TracingMiddleware() is on the agent. A span attribute named project_name does not count.
Failed to export spans batch due to timeout, max retries or shutdownThe collector did not answer within the exporter’s timeout (10 seconds) and the batch was dropped after retries. The agent is not affected. Check FI_BASE_URL and that the collector is reachable, or raise OTEL_EXPORTER_OTLP_TRACES_TIMEOUT (seconds).
401 missing credentials in the exporter logA key is wrong, only one of X-Api-Key and X-Secret-Key was sent, or a Bearer token was used.
400 ... have no project_nameThe project_name resource attribute is missing. Set it on the provider’s resource, as setup_tracing does.
Traces never leave the processYou called agentscope.init and nothing else. 2.x has no tracing_url. Install a provider with setup_tracing().
A short script exits and the last spans are missingCall provider.force_flush() before the end of the script, in notebooks and in serverless handlers. A normal exit flushes by itself.
400 decode protobufA gzip body sent to an older self-hosted release: releases before v1.56.0 answered HTTP 400; current releases accept gzip. setup_tracing turns compression off. If you build your own exporter for an older release, do the same: OTEL_EXPORTER_OTLP_COMPRESSION=gzip in the environment switches it on for an exporter that does not set it.
A second provider is ignoredOpenTelemetry allows one global provider per process. The second set_tracer_provider call logs Overriding of current TracerProvider is not allowed and changes nothing. Install yours first, or add the exporter to the provider your app already has.
The reply span shows as unknownExpected. See What is captured.
The Input and Output columns are emptyExpected, with content on or off. The content is in span attributes.
A tool failed and its span is OKExpected. See Errors.

Next Steps

Was this page helpful?

Questions & Discussion