Instrumentation
Langfuse SDKs lets you manually create observations and traces. You can also use the manual creation patters together with one of the native integrations.
Native integrations
Langfuse supports native integrations for popular LLM and agent libraries such as OpenAI, LangChain or the Vercel AI SDK. They automatically create observations and traces and capture prompts, responses, usage, and errors.
Custom observations
For some use cases you might want to have more control over the observations and traces. For this, you can create custom observations using the Langfuse SDK. The SDKs provide 3 ways to create custom observations:
All custom patterns are interoperable. You can nest a decorator-created observation inside a context manager or mix manual spans with native integrations.
Context manager
The context manager allows you to create a new span and set it as the currently active observation in the OTel context for its duration. Any new observations created within this block will be its children.
langfuse.start_as_current_observation() is the primary way to create observations while ensuring the active OpenTelemetry context is updated. Any child observations created inside the with block inherit the parent automatically.
from langfuse import get_client, propagate_attributes
langfuse = get_client()
with langfuse.start_as_current_observation(
as_type="span",
name="user-request-pipeline",
input={"user_query": "Tell me a joke"},
) as root_span:
with propagate_attributes(user_id="user_123", session_id="session_abc"):
with langfuse.start_as_current_observation(
as_type="generation",
name="joke-generation",
model="gpt-4o",
) as generation:
generation.update(output="Why did the span cross the road?")
root_span.update(output={"final_joke": "..."})Observe wrapper
Use the observe wrapper to automatically capture inputs, outputs, timings, and errors of a wrapped function without modifying the function’s internal logic.
from langfuse import observe
@observe()
def my_data_processing_function(data, parameter):
return {"processed_data": data, "status": "ok"}
@observe(name="llm-call", as_type="generation")
async def my_async_llm_call(prompt_text):
return "LLM response"Parameters: name, as_type, capture_input, capture_output, transform_to_string. Special kwargs such as langfuse_trace_id or langfuse_parent_observation_id let you stitch into existing traces.
The decorator automatically propagates the OTEL trace context. Pass langfuse_trace_id when you need to force a specific trace ID (e.g., to align with an external system) and langfuse_parent_observation_id to attach to an existing parent span.
Capturing large inputs/outputs may add overhead. Disable IO capture per decorator (capture_input=False, capture_output=False) or via the LANGFUSE_OBSERVE_DECORATOR_IO_CAPTURE_ENABLED env var.
Manual observations
Using the manual methods is useful when you need to create observations without altering the currently active context (e.g., background work or parallel tasks).
Use start_span() / start_generation() when you need manual control without changing the active context.
from langfuse import get_client
langfuse = get_client()
span = langfuse.start_span(name="manual-span")
span.update(input="Data for side task")
child = span.start_span(name="child-span")
child.end()
span.end()If you use langfuse.start_span() or langfuse.start_generation(), you are
responsible for calling .end() on the returned observation object. Failure
to do so will result in incomplete or missing observations in Langfuse. Their
start_as_current_... counterparts used with a with statement handle this
automatically.
Key Characteristics:
- No Context Shift: Unlike their
start_as_current_...counterparts, these methods do not set the new observation as the active one in the OpenTelemetry context. The previously active span (if any) remains the current context for subsequent operations in the main execution flow. - Parenting: The observation created by
start_span()orstart_generation()will still be a child of the span that was active in the context at the moment of its creation. - Manual Lifecycle: These observations are not managed by a
withblock and therefore must be explicitly ended by calling their.end()method. - Nesting Children:
- Subsequent observations created using the global
langfuse.start_as_current_observation()(or similar global methods) will not be children of these “manual” observations. Instead, they will be parented by the original active span. - To create children directly under a “manual” observation, you would use methods on that specific observation object (e.g.,
manual_span.start_as_current_observation(...)).
- Subsequent observations created using the global
When to Use:
This approach is useful when you need to:
- Record work that is self-contained or happens in parallel to the main execution flow but should still be part of the same overall trace (e.g., a background task initiated by a request).
- Manage the observation’s lifecycle explicitly, perhaps because its start and end are determined by non-contiguous events.
- Obtain an observation object reference before it’s tied to a specific context block.
Example with more complex nesting:
from langfuse import get_client
langfuse = get_client()
# This outer span establishes an active context.
with langfuse.start_as_current_observation(as_type="span", name="main-operation") as main_operation_span:
# 'main_operation_span' is the current active context.
# 1. Create a "manual" span using langfuse.start_span().
# - It becomes a child of 'main_operation_span'.
# - Crucially, 'main_operation_span' REMAINS the active context.
# - 'manual_side_task' does NOT become the active context.
manual_side_task = langfuse.start_span(name="manual-side-task")
manual_side_task.update(input="Data for side task")
# 2. Start another operation that DOES become the active context.
# This will be a child of 'main_operation_span', NOT 'manual_side_task',
# because 'manual_side_task' did not alter the active context.
with langfuse.start_as_current_observation(as_type="span", name="core-step-within-main") as core_step_span:
# 'core_step_span' is now the active context.
# 'manual_side_task' is still open but not active in the global context.
core_step_span.update(input="Data for core step")
# ... perform core step logic ...
core_step_span.update(output="Core step finished")
# 'core_step_span' ends. 'main_operation_span' is the active context again.
# 3. Complete and end the manual side task.
# This could happen at any point after its creation, even after 'core_step_span'.
manual_side_task.update(output="Side task completed")
manual_side_task.end() # Manual end is crucial for 'manual_side_task'
main_operation_span.update(output="Main operation finished")
# 'main_operation_span' ends automatically here.
# Expected trace structure in Langfuse:
# - main-operation
# |- manual-side-task
# |- core-step-within-main
# (Note: 'core-step-within-main' is a sibling to 'manual-side-task', both children of 'main-operation')Nesting observations
The function call hierarchy is automatically captured by the @observe decorator reflected in the trace.
from langfuse import observe
@observe
def my_data_processing_function(data, parameter):
# ... processing logic ...
return {"processed_data": data, "status": "ok"}
@observe
def main_function(data, parameter):
return my_data_processing_function(data, parameter)Update observations
You can update observations with new information as your code executes.
- For spans/generations created via context managers or assigned to variables: use the
.update()method on the object. - To update the currently active observation in the context (without needing a direct reference to it): use
langfuse.update_current_span()orlangfuse.update_current_generation().
LangfuseSpan.update() / LangfuseGeneration.update() parameters:
Observation Parameters
| Parameter | Type | Description | Applies To |
|---|---|---|---|
input | Optional[Any] | Input data for the operation. | Both |
output | Optional[Any] | Output data from the operation. | Both |
metadata | Optional[Any] | Additional metadata (JSON-serializable). | Both |
version | Optional[str] | Version identifier for the code/component. | Both |
level | Optional[SpanLevel] | Severity: "DEBUG", "DEFAULT", "WARNING", "ERROR". | Both |
status_message | Optional[str] | A message describing the status, especially for errors. | Both |
completion_start_time | Optional[datetime] | Timestamp when the LLM started generating the completion (streaming). | Generation |
model | Optional[str] | Name/identifier of the AI model used. | Generation |
model_parameters | Optional[Dict[str, MapValue]] | Parameters used for the model call (e.g., temperature). | Generation |
usage_details | Optional[Dict[str, int]] | Token usage (e.g., {"input_tokens": 10, "output_tokens": 20}). | Generation |
cost_details | Optional[Dict[str, float]] | Cost information (e.g., {"total_cost": 0.0023}). | Generation |
prompt | Optional[PromptClient] | Associated PromptClient object from Langfuse prompt management. | Generation |
from langfuse import get_client
langfuse = get_client()
with langfuse.start_as_current_observation(as_type="generation", name="llm-call", model="gpt-5-mini") as gen:
gen.update(input={"prompt": "Why is the sky blue?"})
# ... make LLM call ...
response_text = "Rayleigh scattering..."
gen.update(
output=response_text,
usage_details={"input_tokens": 5, "output_tokens": 50},
metadata={"confidence": 0.9}
)
# Alternatively, update the current observation in context:
with langfuse.start_as_current_observation(as_type="span", name="data-processing"):
# ... some processing ...
langfuse.update_current_span(metadata={"step1_complete": True})
# ... more processing ...
langfuse.update_current_span(output={"result": "final_data"})Add attributes to observations
Propagate attributes such as userId, sessionId, metadata, version, and tags to keep downstream analytics consistent. These helpers mirror the Python propagate_attributes context manager and the TypeScript propagateAttributes callback wrapper from the standalone SDK docs.
Use propagation for attributes that should appear on every observation and updateTrace()/update_current_trace() for single-trace fields like name, input, output, or public.
Propagatable attributes
userId/user_idsessionId/session_idmetadataversiontags
Trace-only attributes (use updateTrace / update_current_trace)
nameinputoutputpublic
from langfuse import get_client, propagate_attributes
langfuse = get_client()
with langfuse.start_as_current_observation(as_type="span", name="user-workflow"):
with propagate_attributes(
user_id="user_123",
session_id="session_abc",
metadata={"experiment": "variant_a"},
version="1.0",
):
with langfuse.start_as_current_observation(as_type="generation", name="llm-call"):
pass- Values must be strings ≤200 characters
- Metadata keys: Alphanumeric characters only (no whitespace or special characters)
- Call early in your trace to ensure all observations are covered. This way you make sure that all Metrics in Langfuse are accurate.
- Invalid values are dropped with a warning
Cross-service propagation
Use baggage propagation only when you need to forward attributes across HTTP boundaries. It pushes the values into every outbound request header, so prefer non-sensitive identifiers (session IDs, experiment versions, etc.).
from langfuse import get_client, propagate_attributes
import requests
langfuse = get_client()
with langfuse.start_as_current_observation(as_type="span", name="api-request"):
with propagate_attributes(
user_id="user_123",
session_id="session_abc",
as_baggage=True,
):
requests.get("https://service-b.example.com/api")When baggage propagation is enabled, attributes are added to all outbound HTTP headers. Only use it for non-sensitive values needed for distributed tracing.
Trace-level metadata & inputs/outputs
By default, trace input/output mirror whatever you set on the root observation. Override them explicitly whenever evaluations, AB-tests, or judge models need a different payload than the root span captured.
The snippets below illustrate both the default behavior and how to call update_current_trace / updateActiveTrace() to set trace-level payloads later in the workflow.
LLM-as-a-judge and evaluation workflows typically rely on trace-level inputs/outputs. Make sure to set them deliberately rather than relying on the root span if your evaluation payload differs.
Trace input/output default to the root observation. Override them explicitly when needed (e.g., for evaluations).
from langfuse import get_client
langfuse = get_client()
with langfuse.start_as_current_observation(as_type="span", name="complex-pipeline") as root_span:
root_span.update(input="Step 1 data", output="Step 1 result")
root_span.update_trace(
input={"original_query": "User question"},
output={"final_answer": "Complete response", "confidence": 0.95},
)from langfuse import observe, get_client
langfuse = get_client()
@observe()
def process_user_query(user_question: str):
answer = call_llm(user_question)
langfuse.update_current_trace(
input={"question": user_question},
output={"answer": answer},
)
return answerTrace and observation IDs
Langfuse follows the W3C Trace Context standard: trace IDs are 32-character lowercase hex strings (16 bytes) and observation IDs are 16-character lowercase hex strings (8 bytes). You cannot set arbitrary observation IDs, but you can generate deterministic trace IDs to correlate with external systems.
Langfuse uses W3C Trace Context IDs. Access current IDs or create deterministic ones.
from langfuse import get_client, Langfuse
langfuse = get_client()
with langfuse.start_as_current_observation(as_type="span", name="my-op") as current_op:
trace_id = langfuse.get_current_trace_id()
observation_id = langfuse.get_current_observation_id()
print(trace_id, observation_id)
external_request_id = "req_12345"
deterministic_trace_id = Langfuse.create_trace_id(seed=external_request_id)Link to existing traces
When integrating with upstream services that already have trace IDs, supply the W3C trace context so Langfuse spans join the existing tree rather than creating a new one.
from langfuse import get_client
langfuse = get_client()
existing_trace_id = "abcdef1234567890abcdef1234567890"
existing_parent_span_id = "fedcba0987654321"
with langfuse.start_as_current_observation(
as_type="span",
name="process-downstream-task",
trace_context={
"trace_id": existing_trace_id,
"parent_span_id": existing_parent_span_id,
},
):
passClient lifecycle & flushing
Both SDKs buffer spans in the background. Always flush or shut down the exporter in short-lived processes (scripts, serverless functions, workers) to avoid losing data.
Flush or shut down the client to ensure all buffered data is delivered—especially in short-lived jobs.
from langfuse import get_client
langfuse = get_client()
# ... create traces ...
langfuse.flush()
langfuse.shutdown()