Writing observations
This file covers the four primitives for emitting observations and the helpers for enriching them at runtime. After reading 01-tracing-setup.md, this is where you learn to instrument code.
When to use what
| Situation | Use |
|---|---|
| Named function that represents a unit of work | @step("name") |
| Code block that does not map to a single function | span("name") |
| Point-in-time marker — cache hit, fallback trigger, decision point | event("name") |
| Top-level entry where you set user / session context | propagate(...) wrapping the root call |
@step
@step wraps a function as a Langfuse observation. It works on both sync and
async functions. Arguments are automatically captured as the observation input;
the return value is captured as the output.
from semantic_tracing import step, update_current_step
@step("route_request")
async def route(query: str) -> RoutingDecision:
decision = deterministic_route(query)
update_current_step(metadata={"handoff_to": decision.handoff_to})
return decision
Resulting trace structure:
trace
└── route_request (span)
input: {"query": "..."}
output: {"handoff_to": "A", "reason": "..."}
metadata: {"handoff_to": "A"}
Observation type
Pass as_type to give the observation a semantic type visible in the Langfuse
UI:
@step("fetch_documents", as_type="retriever")
async def retrieve(query: str) -> list[Document]:
...
@step("check_safety", as_type="guardrail")
def validate_output(text: str) -> bool:
...
Controlling capture
Pass capture_input=False or capture_output=False to suppress automatic
recording of arguments or return values for a specific step:
@step("process_image", capture_input=False)
async def handle(image_bytes: bytes) -> dict:
...
See 04-tracing-input-capture.md for argument-level filtering without disabling capture entirely.
span()
span() is a context manager for tracing a block of code that does not map to
a single function. It yields a span object whose .update() method lets you
attach data from inside the block.
from semantic_tracing import span
async def process_batch(items: list[Item]) -> list[Result]:
results = []
for item in items:
with span(f"validate_{item.id}") as s:
outcome = validate(item)
if not outcome.valid:
s.update(level="WARNING", output={"reason": outcome.reason})
break
results.append(outcome)
s.update(output={"status": "passed"})
return results
span() accepts the same as_type parameter as @step.
Generation observations with span()
When the code that calls the LLM is already inside another with block — for
example, a route handler that opens a chain span first — extract it into its own
function just to use @step would be artificial. Use span(as_type="generation")
and call .update() with the same generation kwargs that update_current_step()
accepts: model, usage, completion_start_time, and usage_details.
with span("answer", as_type="generation") as gen:
response = await call_llm(prompt)
gen.update(
output=response.text,
model="gpt-4o",
usage={"input": response.usage.input_tokens, "output": response.usage.output_tokens},
usage_details={"cached_tokens": response.usage.cached_tokens},
)
usage={"input": N, "output": M} is translated to usage_details= before
reaching the Langfuse SDK (which uses usage_details exclusively). The
public kwarg name is the same as in update_current_step() so call sites are
consistent. For full TTFT and streaming guidance see
03-tracing-generation.md.
event()
event() records a point-in-time marker with no duration. Use it to mark
decisions, fallbacks, or conditions that are worth surfacing in a trace without
creating a time range.
from semantic_tracing import event
if not tool_state["called"]:
decision = deterministic_fallback(query)
event("routing_fallback_used", metadata={
"reason": "agent did not call route_query",
"fallback_target": decision.handoff_to,
})
propagate()
propagate() stamps all child spans with user_id, session_id, and tags. Wrap
it around the top-level call where those values become known:
from semantic_tracing import propagate, step
@step("workflow_root")
async def run_workflow(request: WorkflowRequest):
with propagate(
user_id=request.user_id,
session_id=request.session_id,
tags=["offline-eval", "v2"],
):
result = await orchestrate(request)
return result
All @step and span() calls inside the propagate() scope inherit the
user_id, session_id, and tags — they appear on every observation in Langfuse
without being set individually.
Trace-level input
Supply input to record the semantically meaningful user input on the trace's
root observation. In Langfuse a trace has no input of its own: it shows the root
observation's. This is the text a human reviewer should see at a glance when opening the
trace:
with propagate(
user_id=request.user_id,
session_id=request.session_id,
input={"query": request.query},
):
result = await handle(request)
input is written as the input of the first span started inside the with
block, which is the root observation when propagate() wraps the turn. A span
that sets its own input keeps it. Pass the user-facing query text, not the full internal request
object — reviewers need the signal, not the noise.
Suppressing Langfuse export for a scope
Set langfuse_export=False to exclude the entire scope from Langfuse while
keeping the OTel trace intact. App Insights still receives every span; context
propagates normally; only the Langfuse exporter is bypassed for that scope.
with propagate(langfuse_export=False):
await run_smoke_test(request)
Use cases: - Background batch jobs that do not need Langfuse visibility - Synthetic smoke tests running in production - Feature-flagged paths you want to keep invisible in Langfuse during a rollout
This is independent of sample_rate in setup_tracing(). langfuse_export=False
is a hard exclusion for a specific call; sample_rate is a statistical exclusion
across all calls.
Ordering with span()
When combining propagate() with an explicit span() in the same scope — for
example, in a FastAPI route handler where the OTel root is an App Insights HTTP
span rather than a Langfuse observation — always open span() first and call
propagate() inside it.
# Langfuse observation is active when propagate_attributes() fires
with span("request.handle", as_type="chain", input=payload) as s:
with propagate(user_id=..., session_id=..., trace_name="request.handle"):
result = await handle(payload)
# propagate_attributes() runs before any Langfuse span exists
with propagate(user_id=..., session_id=..., trace_name="request.handle"):
with span("request.handle", as_type="chain", input=payload) as s:
result = await handle(payload)
The wrong order produces a log warning on every request:
WARNING langfuse — Context error: No active span in current context.
This does not apply when propagate() is called inside a @step-decorated
function — @step opens the Langfuse observation first, so the ordering is
already correct.
propagate() parameter reference
| Parameter | Type | Default | Description |
|---|---|---|---|
user_id |
str \| None |
None |
Langfuse user identifier |
session_id |
str \| None |
None |
Groups turns of the same conversation in Langfuse Sessions; also used as the sampling key when sample_rate < 1.0 |
tags |
list[str] \| None |
None |
Tags applied to the trace |
metadata |
dict \| Pydantic model \| None |
None |
Metadata propagated to all child observations. A release key (release_id, instructions_version, knowledge_version) overrides the release for this request; see Releases |
version |
str \| None |
None |
Langfuse's version field for the trace. It is not the release; set that with Release (Releases) |
trace_name |
str \| None |
None |
Override trace name; when omitted, auto-derived from the active OTel span name |
langfuse_export |
bool |
True |
When False, suppresses Langfuse export for all spans in this scope |
input |
Any |
None |
Input recorded on the first span started inside the scope (the root observation when propagate() wraps the turn) |
log_trace_url |
bool |
False |
When True, emits the Langfuse trace URL as an INFO log line (logger semantic_tracing) when the propagate() scope exits — useful during local development so engineers can jump directly to the trace |
update_current_step()
Call update_current_step() from inside a @step-decorated function to attach
data that is only available after execution starts:
from semantic_tracing import step, update_current_step
@step("summarize", as_type="generation")
async def summarize(text: str) -> str:
response = await client.chat.completions.create(...)
update_current_step(
model="gpt-4o",
usage={"input": response.usage.prompt_tokens, "output": response.usage.completion_tokens},
output=response.choices[0].message.content,
)
return response.choices[0].message.content
model and usage together with as_type="generation" activate the Langfuse
token cost widget. Passing them through metadata does not trigger cost
tracking — they must be top-level kwargs.
| Parameter | Type | Description |
|---|---|---|
metadata |
dict \| Pydantic model |
Merged into observation metadata |
output |
Any |
Overrides the auto-captured return value |
level |
str |
"DEBUG", "DEFAULT", "WARNING", or "ERROR" |
input |
Any |
Overrides or sets the observation input after the function started |
model |
str |
Model name or deployment ID — activates cost tracking with as_type="generation" |
usage |
dict |
Token counts — {"input": N, "output": M} — activates cost tracking with model |
completion_start_time |
datetime |
Timestamp of the first token — drives Langfuse native TTFT metric; only meaningful on as_type="generation" spans |
usage_details |
dict |
Extended token breakdown — {"cached_tokens": N, "reasoning_tokens": M}; only meaningful on as_type="generation" spans |
model_parameters |
dict |
Sampling parameters (e.g. {"temperature": 0.0}, {"reasoning_effort": "medium"}); stored in the Langfuse generation panel; only meaningful on as_type="generation" spans |
When any generation-specific kwarg (model, usage, usage_details,
completion_start_time, model_parameters) is present, update_current_step()
dispatches to langfuse.update_current_generation() so those fields are actually
stored by the Langfuse SDK. Base kwargs (metadata, output, level, input)
are accepted by both dispatch paths.
For generation-specific usage of completion_start_time and usage_details, and
for the record_first_token() and wrap_azure_stream() helpers, see
03-tracing-generation.md.
Observation types (as_type)
The as_type parameter is accepted by both @step and span(). The value is
passed directly to the Langfuse SDK and controls the icon and category visible
in the trace view.
as_type |
Meaning |
|---|---|
"span" |
Generic unit of work with a duration (default when as_type is omitted) |
"generation" |
LLM call — supports token usage and cost tracking |
"embedding" |
Embedding model call — supports token usage |
"agent" |
Agent deciding application flow |
"tool" |
Tool call (external API, function call, etc.) |
"chain" |
Link between steps passing context |
"retriever" |
Data retrieval from a vector store or database |
"evaluator" |
Assessment function |
"guardrail" |
Content safety or validation check |
If Langfuse adds new types in future SDK versions they work immediately, because the value passes through without validation at the SDK level.
Observation nesting
Each @step or span() that executes inside another creates a child
observation automatically through OTel context propagation. event() creates an
instant marker with no duration at the current nesting level.
flowchart TD
TR["Langfuse trace"]
S1["span: workflow root"]
S2["agent: route request"]
S3["generation: call LLM"]
S4["tool: search db"]
E1["event: cache hit"]
TR --> S1
S1 --> S2
S1 --> S3
S1 --> E1
S3 --> S4
style TR fill:#efe9ed,stroke:#62294b,stroke-width:2px
style S3 fill:#e5f4fd,stroke:#0098eb,stroke-width:2px
The depth mirrors the call stack. There is no manual parent-ID wiring — nesting
is automatic as long as child calls happen within the lexical scope of the
parent @step, span(), or propagate().