Skip to content

Framework integration

semantic_tracing is designed so that Microsoft Agent Framework and LangChain both land in the same Langfuse trace as your own @step and span() observations. This file explains the mechanics and what you need to configure for each framework.

How frameworks share one trace

flowchart TD
  subgraph Input["Instrumented code — same process"]
    direction LR
    MAF["Microsoft Agent Framework"]
    LC["LangChain"]
    Custom["@step / span() / event()"]
  end
  subgraph Provider["setup_tracing()"]
    direction LR
    TP["Langfuse TracerProvider as global OTel provider"]
  end
  subgraph Out["Langfuse"]
    direction LR
    T["Single trace per request"]
  end
  MAF -->|"reads global OTel provider"| TP
  LC -->|"via OTel context"| TP
  Custom -->|"@observe and create_span"| TP
  TP --> T
  style Input fill:#e9ebec,stroke:#273943,stroke-width:2px
  style Provider fill:#e5f4fd,stroke:#0098eb,stroke-width:2px
  style Out fill:#efe9ed,stroke:#62294b,stroke-width:2px

setup_tracing() registers Langfuse's own TracerProvider as the global OTel provider. After that call, any framework that calls opentelemetry.trace.get_tracer() inherits the same provider — and therefore emits spans into the same Langfuse trace.

Microsoft Agent Framework

Agent Framework's executors, telemetry layers, and the underlying microsoft-kiota HTTP client all use OTel automatically. No additional configuration is needed beyond calling setup_tracing() before the workflow runs.

Span noise

Agent Framework and microsoft-kiota emit a large number of internal bookkeeping spans. These are typically not useful in the Langfuse trace view and should be filtered out. The microsoft-kiota dispatch internals are already covered by setup_tracing()'s built-in defaults, which merge with whatever a pod adds, so a pod only has to name Agent Framework's own workflow machinery:

setup_tracing(
    service_name="my_workflow",
    drop_span_names=[
        "workflow.build",
        "workflow.run",
        "edge_group.process SingleEdgeGroup",
        "message.send",
    ],
    drop_span_name_prefixes=["executor.process"],
)

workflow.run and executor.process <name> sit between the application's own spans, so dropping them would ordinarily detach everything nested inside. It does not: each surviving span is reattached to the nearest ancestor that survives with it, which is why this list can name a span that has children.

The agent span (invoke_agent <name>) usually wraps exactly one model call per run and holds nothing of its own beyond what a pod puts on it, yet it is a billed observation. Dropping it puts the model and tool calls directly under the turn, and carry_dropped_span_attributes keeps what the pod put on it. A pod that renames the span as it starts, say to model_round with the round in langfuse.observation.metadata.round, names the renamed span:

setup_tracing(
    service_name="my_workflow",
    drop_span_names=["model_round"],
    carry_dropped_span_attributes=["langfuse.observation.metadata.round"],
)

No tokens are lost or counted twice: the model call spans beneath it hold the usage Langfuse prices, and the agent span's own usage, if a release still rolls it up there, leaves with it. A pod that does not rename the span drops it with drop_span_name_prefixes=["invoke_agent"]. Drop it only beneath a span of the pod's own, such as a @step on the entry point: with nothing above it, its children become the trace's roots.

drop_span_name_prefixes covers two cases. executor.process carries the executor's id in the span name, and microsoft-kiota templates its top-level request span names as "<method> - <uri-template>", so in both the full name varies per call and exact matching does not work.

Token usage and cost

Langfuse prices every usage bucket separately and adds them up, while a model provider reports cached input inside the input count and reasoning inside the output count. Left as the framework reports them, a cached token is charged at the full input price and again at the cached price.

setup_tracing() therefore wraps one of Agent Framework's telemetry internals, so every model call's span also carries langfuse.observation.usage_details with buckets that do not overlap, which Langfuse prefers over gen_ai.usage.*:

Bucket What it holds
input Input tokens not read from the prompt cache
input_cached_tokens Input tokens read from the cache, priced lower
output Output tokens that were not reasoning
output_reasoning_tokens Reasoning tokens, priced as output
total The provider's own total

The two detail buckets are omitted when they are zero. Only model call spans get them: the agent span that contains them carries no usage of its own, so nothing is counted twice. The wrapper forwards every argument the framework passes, so a keyword added to that internal in a later release does not break a run. It was written against the 1.0 betas and is verified against 1.19.

The buckets carry tokens; the price comes from the Langfuse project's model definition for the model, which an Azure deployment usually needs its own of. See 14-tracing-model-prices.md.

Capturing prompt and tool bodies

Agent Framework's telemetry layers only attach prompt/completion text and tool arguments to spans when sensitive data capture is enabled. Without it, MAF spans appear in Langfuse but carry no message content. Enable it for evaluators that need to score against actual model responses:

setup_tracing(
    service_name="my_workflow",
    capture_sensitive_data=True,
)
Stamping tool spans without enabling full sensitive-data capture

When capture_sensitive_data=False, Agent Framework still creates an execute_tool <name> OTel span for each tool invocation using start_as_current_span. That span is the OTel current span for the duration of the tool body, so you can stamp lightweight metadata directly on it from inside your tool function:

from opentelemetry import trace as _otel_trace

@tool(name="read_policy", description="...")
async def read_policy(policy_id: str, sections: list[str] | None = None) -> str:
    _otel_trace.get_current_span().set_attribute(
        "langfuse.input",
        json.dumps({"policy_id": policy_id, "sections": sections}),
    )
    result = await _fetch(policy_id, sections)
    _otel_trace.get_current_span().set_attribute(
        "langfuse.output",
        json.dumps({"mode": result["mode"], "chars": len(result["text"])}),
    )
    return json.dumps(result)

langfuse.input and langfuse.output are the two attributes Langfuse reads to show input/output on an observation. Setting them here:

  • makes the execute_tool span visible as a real invocation (with arguments and a result summary) in the Langfuse trace, even with capture_sensitive_data=False
  • keeps sensitive payload text (policy body, employee records) out of Langfuse — stamp only the metadata you want to see (IDs, modes, counts)

The pattern works for any tool type (handbook lookups, API calls, graph queries). The key constraint is that the attribute must be set while the execute_tool span is still open, which is guaranteed as long as it is set synchronously inside the tool function body before returning.

LangChain

get_langchain_callback() uses Langfuse's LangChain integration, which needs langchain-core 1.x, so a project that calls it needs LangChain 1.x.

LangChain does not read the global OTel provider automatically. Instead, obtain a CallbackHandler from the tracing module and pass it to any chain.invoke() or chain.ainvoke() call:

from semantic_tracing import get_langchain_callback, step

@step("generate_answer")
async def answer_with_chain(query: str) -> str:
    handler = get_langchain_callback()
    result = await chain.ainvoke(
        {"query": query},
        config={"callbacks": [handler]},
    )
    return result["output"]

The handler reads the active OTel context (created by the enclosing @step) and nests LangChain's spans — prompt templates, model calls, output parsers — under it. The result is a single trace where your own observations and LangChain's internal spans appear in the correct parent-child relationship.

Call get_langchain_callback() inside the @step function body, not at module level. A module-level call would capture the OTel context at import time, which is before any trace exists.