LLM generation spans
as_type="generation" marks an observation as an LLM call in Langfuse. Combined
with model and token usage data, it activates the Langfuse cost dashboard and
TTFT panel. This file covers everything beyond the basic @step wrapper that
matters specifically for generation spans.
Read 02-tracing-observations.md first — the
@step, update_current_step(), and as_type mechanics covered there apply
here too.
Why generation spans are special
A generation span in Langfuse has fields that other span types do not use:
| Field | Set via | Langfuse effect |
|---|---|---|
model |
update_current_step(model=...) |
Groups cost by model in the cost dashboard |
usage |
update_current_step(usage={"input": N, "output": M}) |
Shows prompt/completion token counts; drives cost calculation |
completion_start_time |
update_current_step(completion_start_time=dt) |
Drives the native TTFT metric shown in the generation panel |
usage_details |
update_current_step(usage_details={...}) |
Extended token breakdown — cached tokens, reasoning tokens |
model_parameters |
update_current_step(model_parameters={...}) |
Sampling parameters (temperature, reasoning_effort) shown in the generation panel |
All five require as_type="generation" on the enclosing @step or span().
Passing them through metadata does not activate cost or TTFT tracking.
When any of these generation-specific kwargs is present, update_current_step()
dispatches to langfuse.update_current_generation() instead of
update_current_span(). Both functions are available when there is an active
Langfuse span in context — from @step or from span() — so the SDK-level
helpers work in both patterns.
Generation levels (0–3)
Four instrumentation levels cover the range from non-streaming to fully-automated streaming. Pick the level that matches your call pattern.
The simplest case: the client.chat.completions.create() call is synchronous
or awaited, and the full response object is available before returning. TTFT is
not tracked because the first-token time is not distinguishable from the
final-response time in a non-streaming call.
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(
model="gpt-4o",
messages=[{"role": "user", "content": f"Summarize:\n{text}"}],
)
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
When streaming, you control the chunk loop. Record TTFT yourself by calling
update_current_step(completion_start_time=datetime.now(timezone.utc)) at the
moment the first content chunk arrives. After the loop, call it again with
usage data.
from datetime import datetime, timezone
from semantic_tracing import step, update_current_step
@step("stream_response", as_type="generation")
async def stream(prompt: str) -> str:
stream = await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
stream=True,
stream_options={"include_usage": True}, # required for usage on last chunk
)
chunks = []
first_token_recorded = False
model = None
async for chunk in stream:
if model is None:
model = chunk.model
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta is not None and not first_token_recorded:
first_token_recorded = True
# stamp TTFT the moment the first content token arrives
update_current_step(completion_start_time=datetime.now(timezone.utc))
if delta:
chunks.append(delta)
if chunk.usage:
# Final chunk carries usage when stream_options={"include_usage": True}
update_current_step(
model=model,
usage={
"input": chunk.usage.prompt_tokens,
"output": chunk.usage.completion_tokens,
},
usage_details={
k: v for k, v in {
"cached_tokens": getattr(
chunk.usage.prompt_tokens_details, "cached_tokens", None
),
"reasoning_tokens": getattr(
chunk.usage.completion_tokens_details, "reasoning_tokens", None
),
}.items() if v
},
)
return "".join(chunks)
record_first_token() is a convenience wrapper for the TTFT stamp. It calls
update_current_step(completion_start_time=datetime.now(timezone.utc))
internally, removing the boilerplate from the chunk loop.
from semantic_tracing import record_first_token, step, update_current_step
@step("stream_response", as_type="generation")
async def stream(prompt: str) -> str:
stream = await client.chat.completions.create(...)
chunks = []
first_token_recorded = False
async for chunk in stream:
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta is not None and not first_token_recorded:
first_token_recorded = True
record_first_token() # cleaner than datetime boilerplate
...
wrap_azure_stream() handles TTFT recording and usage extraction for you,
wrapping the chunk loop. It is compatible with openai.AsyncOpenAI and
openai.AsyncAzureOpenAI streaming responses. The caller's loop is unchanged —
wrap_azure_stream() transparently yields the same chunks the original stream
yields, then calls update_current_step() once after the stream ends.
from semantic_tracing import step, wrap_azure_stream
@step("stream_response", as_type="generation")
async def stream(prompt: str) -> str:
raw = await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
stream=True,
stream_options={"include_usage": True},
)
chunks = []
async for chunk in wrap_azure_stream(raw):
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta:
chunks.append(delta)
return "".join(chunks)
Using span() instead of @step
The examples above use @step because most LLM calls live inside a dedicated
function. When the call is already inside a with block — a route handler that
opens a chain span first, or a loop that manages its own context — wrapping it
in an extra function just to use @step is unnecessary. Use span() and
update_current_step() directly:
with span("answer", as_type="generation"):
response = await call_llm(prompt)
update_current_step(
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},
)
update_current_step() looks up the current Langfuse observation from the OTel
context stack — inside with span("answer", as_type="generation"): that
observation is the answer generation span. The same dispatch logic applies:
generation-specific kwargs trigger update_current_generation() so they are
stored correctly.
The levels (0–3) for TTFT and streaming apply inside with span(...): blocks
exactly as they do inside @step functions — call
update_current_step(completion_start_time=...) manually (Level 1),
record_first_token() (Level 2), or wrap_azure_stream() (Level 3).
When to use each level
| Situation | Level |
|---|---|
| Non-streaming response, no TTFT needed | Level 0 — update_current_step(model=, usage=) after the call |
| Streaming, you write your own chunk loop, TTFT matters | Level 1 — update_current_step(completion_start_time=...) manually |
| Same as Level 1 but want less boilerplate | Level 2 — record_first_token() inside your loop |
| OpenAI / Azure OpenAI streaming, no custom loop logic needed | Level 3 — wrap_azure_stream() around the raw stream |
Important notes for Level 3
- Requires
stream_options={"include_usage": True}in thecreate()call. Without it, the final chunk has no usage field and token counts are not recorded. - Does not import from
openai— duck-typed on chunk attributes. Works with any client that produces chunks with.choices[0].delta.content,.model, and.usage(on the final chunk). wrap_azure_stream()callsupdate_current_step()once after the stream ends. If your loop also callsupdate_current_step(), both calls are merged by Langfuse.