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_toolspan visible as a real invocation (with arguments and a result summary) in the Langfuse trace, even withcapture_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.