Free lesson · GenAI Application Engineering
Build OpenTelemetry distributed trace pipelines
Build an OTelTraceProvider class configuring OpenTelemetry for the FastAPI application. Implement setup_provider() creating a TracerProvider with BatchSpanProcessor exporting to Langfuse via OTLPSpanExporter(endpoint=langfuse_otlp_url). Create a @traced decorator wrapping async functions with custom spans, recording function name, arguments hash, duration_ms, and exception details. Build instrument_request_lifecycle() creating spans for each stage: api_ingestion (request parsing), guardrail_check (safety filtering), context_assembly (RAG retrieval), llm_call (LiteLLM invocation), and post_processing (output formatting). Implement add_span_attributes() attaching model_name, token_count, and cache_hit to active spans. Configure resource attributes: service.name, service.version, deployment.environment. Test with SpanCapture helper collecting exported spans in memory for pytest assertions.
Course: Full-Stack GenAI Applications · Chapter 16 · Observability with Langfuse & OpenTelemetry
Free to read — no subscription required.
Introduction
When a GenAI request spikes from 2 seconds to 18 seconds in production, you need to know whether the retrieval, the prompt build, the LLM call, or the guardrail step caused it — and without distributed tracing you're stuck guessing from log timestamps while support tickets pile up. Teams that wire OpenTelemetry across the full request lifecycle can answer "which span regressed?" in seconds; teams that skip it ship blind and end up rolling back releases on a hunch. By the end of this lesson you will be able to configure an OTel TracerProvider, export spans to Langfuse via OTLP, and instrument a FastAPI GenAI endpoint so every phase of the request shows up as a child span in the trace waterfall.
Key Terminology
- TracerProvider: The OpenTelemetry SDK component that owns tracing configuration (resource attributes, span processors, samplers) and produces named
Tracerinstances application code uses to start spans. - BatchSpanProcessor: A span processor that buffers finished spans in memory and flushes them in batches on a schedule, trading a few seconds of export latency for far lower network overhead than per-span exports.
- OTLPSpanExporter: The exporter that serializes spans into the OpenTelemetry Protocol (OTLP) and ships them over HTTP/protobuf to any OTLP-compatible backend — in this lesson, Langfuse's
/api/public/otel/v1/tracesendpoint. - Span: A single timed unit of work in a trace (e.g. "rag_retrieval", "llm_call") with a name, start/end timestamps, status, and arbitrary key-value attributes; spans nest into a parent/child waterfall sharing one trace ID.
- gen_ai. attributes*: OpenTelemetry semantic-convention span attributes (
gen_ai.system,gen_ai.request.model,gen_ai.usage.prompt_tokens, …) that Langfuse recognizes to populate its LLM-specific cost, token, and model dashboards.
Concepts
The two ideas that determine whether your OTel pipeline produces useful GenAI traces or noise: how OTel spans map onto Langfuse's trace/observation model when emitted through OTLP, and how to tune the BatchSpanProcessor for the 2-15s span lifetimes typical of LLM calls.
Connecting OTel Traces to Langfuse Native Features
A critical architectural decision is whether to use OTel instrumentation exclusively or combine it with Langfuse's native @observe() decorator from another goal. The practical answer for most teams is to use both: OTel for infrastructure-level spans (HTTP middleware, database calls, queue consumers) and @observe() for GenAI-specific logic where Langfuse's native features like prompt linking, score attachment, and session grouping add value. Langfuse correlates spans from both sources using the trace ID. When you set the langfuse_trace_id to match the OTel trace_id, native observations appear as children within the OTel trace waterfall. This hybrid approach gives you the vendor-neutral portability of OTel for infrastructure telemetry while retaining Langfuse's purpose-built GenAI analytics for the LLM-specific layers.
The BatchSpanProcessor configuration deserves particular attention in GenAI workloads. LLM calls routinely take 2-15 seconds, meaning spans are long-lived compared to typical microservice operations. Setting schedule_delay_millis too low causes frequent small exports that waste network resources; setting it too high risks losing spans during deployments. The 5000ms value in the provider configuration balances these concerns. For latency-sensitive alerting—such as detecting when LLM P99 exceeds an SLA—pair the Langfuse export with a secondary OTLPSpanExporter targeting a real-time backend like Grafana Tempo, and use Grafana alerting rules on span duration metrics.
Code Walkthrough
Now that you've seen how OTel spans map onto Langfuse's trace/observation model and how to tune the BatchSpanProcessor for LLM-scale span lifetimes, the walkthrough below moves from the OTLP wire path that delivers spans into Langfuse, through an OTelTraceProvider class that wires up a TracerProvider + BatchSpanProcessor + OTLPSpanExporter, and ends with the FastAPI instrumentation pattern that produces the retrieval → prompt → LLM → guardrail child-span waterfall.
How OTLP Export to Langfuse Works
Langfuse exposes an OTLP-compatible HTTP ingestion endpoint at https:///api/public/otel/v1/traces. When you configure the OTLPSpanExporter to point at this URL and authenticate with your Langfuse public/secret key pair encoded as Basic Auth, every span your application emits flows into Langfuse's trace viewer. Langfuse maps OTel spans onto its native trace/observation model: the root span becomes the Langfuse trace, child spans become observations, and any span attribute prefixed with gen_ai. populates the LLM-specific metadata panels. This means you get the structured LLM analytics of Langfuse without abandoning the OTel ecosystem—your spans can simultaneously route to Jaeger, Datadog, or any other OTLP-compatible backend via a secondary exporter.
Code snippet mermaid
Loading diagram...
- Line 1: Declares a Mermaid flowchart with left-to-right (LR) layout direction.
- Line 2: Defines node A ("FastAPI Request") connecting to node B ("OTel Middleware Span"), showing that incoming API requests are first intercepted by OpenTelemetry middleware to create a root tracing span.
- Line 3: Connects the middleware span B to node C ("RAG Retrieval Span"), representing a child span that traces the retrieval-augmented generation document lookup.
- Line 4: Connects the middleware span B to node D ("Prompt Build Span"), representing a parallel child span that traces the construction of the LLM prompt.
- Line 5: Connects the prompt build span D to node E ("LiteLLM Call Span"), showing that after the prompt is built, a child span traces the actual LLM API call made through LiteLLM.
- Line 6: Connects the LiteLLM call span E to node F ("Guardrail Span"), representing a subsequent span that traces safety/guardrail checks applied to the LLM output.
- Line 7: Connects the guardrail span F to node G ("Response Span"), representing the final processing span that traces the construction and return of the API response.
- Line 8: Connects the middleware span B to node H ("BatchSpanProcessor"), showing that all collected spans are forwarded to OpenTelemetry's batch processor for efficient, buffered export.
- Line 9: Connects the batch processor H to node I ("OTLPSpanExporter"), indicating spans are serialized and sent out via the OpenTelemetry Protocol exporter.
- Line 10: Connects the OTLP exporter I to node J ("Langfuse OTLP Endpoint"), showing the primary telemetry destination is Langfuse's OTLP-compatible ingestion endpoint.
- Line 11: Connects the OTLP exporter I to node K ("Jaeger / Datadog Optional"), showing that additional observability backends can optionally receive the same span data.
- Line 12: Connects the Langfuse endpoint J to node L ("Langfuse Trace Viewer"), representing the final UI where developers visualize and inspect the full distributed trace.
This diagram shows the span hierarchy within a single request. The OTel middleware creates the root SERVER span. Each downstream operation—retrieval, prompt construction, LLM invocation, and guardrail evaluation—becomes a child span. The BatchSpanProcessor collects all completed spans and flushes them to the OTLPSpanExporter, which transmits them to Langfuse's OTLP endpoint. Optionally, a second exporter can send the same spans to Jaeger or Datadog for infrastructure-level observability, while Langfuse handles GenAI-specific analytics.
Configuring the OTelTraceProvider
The following implementation defines an OTelTraceProvider class that encapsulates the full OpenTelemetry setup for a FastAPI application. The class configures a TracerProvider with a Resource describing the service, attaches a BatchSpanProcessor backed by an OTLPSpanExporter targeting Langfuse, and exposes a setup_provider() method that registers the provider globally. It also provides a get_tracer() convenience method that returns a named Tracer instance for creating spans in application code. Pay attention to how the Langfuse authentication credentials are encoded into the OTLP exporter headers using Base64—this is required because Langfuse's OTLP endpoint expects HTTP Basic Auth in the Authorization header rather than separate API key parameters.
Code snippet python
1import base64 2from opentelemetry import trace 3from opentelemetry.sdk.trace import TracerProvider 4from opentelemetry.sdk.trace.export import BatchSpanProcessor 5from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( 6 OTLPSpanExporter, 7) 8from opentelemetry.sdk.resources import Resource 9 10class OTelTraceProvider: 11 """Configures OpenTelemetry to export spans to Langfuse.""" 12 13 def __init__( 14 self, 15 service_name: str, 16 langfuse_host: str, 17 langfuse_public_key: str, 18 langfuse_secret_key: str, 19 service_version: str = "0.1.0", 20 environment: str = "development", 21 ): 22 self._resource = Resource.create({ 23 "service.name": service_name, 24 "service.version": service_version, 25 "deployment.environment": environment, 26 }) 27 credentials = f"{langfuse_public_key}:{langfuse_secret_key}" 28 encoded = base64.b64encode(credentials.encode()).decode() 29 self._exporter = OTLPSpanExporter( 30 endpoint=f"{langfuse_host}/api/public/otel/v1/traces", 31 headers={"Authorization": f"Basic {encoded}"}, 32 ) 33 self._provider: TracerProvider | None = None 34 35 def setup_provider(self) -> TracerProvider: 36 self._provider = TracerProvider(resource=self._resource) 37 processor = BatchSpanProcessor( 38 self._exporter, 39 max_queue_size=2048, 40 max_export_batch_size=512, 41 schedule_delay_millis=5000, 42 ) 43 self._provider.add_span_processor(processor) 44 trace.set_tracer_provider(self._provider) 45 return self._provider 46 47 def get_tracer(self, name: str = __name__) -> trace.Tracer: 48 if self._provider is None: 49 raise RuntimeError("Call setup_provider() before get_tracer()") 50 return self._provider.get_tracer(name) 51 52 def shutdown(self) -> None: 53 if self._provider is not None: 54 self._provider.shutdown()
- Lines 1-7: Import the core OpenTelemetry SDK components. The
base64module is needed to encode Langfuse credentials for HTTP Basic Auth. TheOTLPSpanExporterfrom theproto.httppackage sends spans over HTTP/protobuf, which Langfuse's OTLP endpoint accepts. - Lines 10-11: The
classdocstring makes the intent explicit—this provider targets Langfuse specifically, not a generic OTLP backend. - Lines 13-21: The constructor accepts Langfuse connection parameters alongside standard service metadata. The
service_versionandenvironmentdefault to safe development values, preventing accidental production data pollution when developers forget to override them. - Lines 22-25: The
Resourceencodes three semantic conventions that Langfuse uses to group and filter traces:service.nameappears as the project identifier,service.versionenables version-to-version latency comparison, anddeployment.environmentseparates staging from production traces. - Lines 26-28: The Langfuse public and secret keys are concatenated with a colon separator and Base64-encoded, producing the value expected by HTTP Basic Auth. This is the same encoding pattern used by curl's
-uflag. - Lines 29-32: The
OTLPSpanExporteris instantiated with the full Langfuse OTLP path and the encoded authorization header. Langfuse validates these credentials on every batch submission. - Lines 35-43:
setup_provider()creates theTracerProviderwith the resource, then attaches aBatchSpanProcessorconfigured with explicit tuning parameters. Themax_queue_sizeof 2048 prevents memory growth under burst traffic,max_export_batch_sizeof 512 balances network efficiency against latency, andschedule_delay_millisof 5000 means spans flush every 5 seconds—acceptable for observability but not real-time alerting. - Lines 44-45:
trace.set_tracer_provider()registers this provider globally, meaning any library that callstrace.get_tracer()will route spans through this pipeline. This is critical for auto-instrumentation libraries likeopentelemetry-instrumentation-fastapito work without explicit wiring. - Lines 47-49:
get_tracer()returns a namedTracerinstance. The RuntimeError guard ensures developers cannot accidentally create spans before the provider is initialized, catching misconfigured startup sequences early. - Lines 51-53:
shutdown()flushes all pending spans and releases resources. Call this in FastAPI'slifespanshutdown hook to avoid losing the final batch of spans when the process terminates.
Instrumenting the Request Lifecycle
With the provider configured, you need to create spans that capture each meaningful phase of a GenAI request. The following code demonstrates a FastAPI endpoint that manually instruments the RAG retrieval, prompt construction, LLM call, and post-processing phases. Each span carries GenAI-specific attributes that Langfuse extracts into its analytics panels. The create_llm_span helper function shows how to attach gen_ai.* semantic attributes—including gen_ai.system, gen_ai.request.model, and gen_ai.usage.prompt_tokens—so Langfuse can populate its token usage and cost dashboards without relying on the native @observe() decorator.
Code snippet python
1from fastapi import FastAPI, Request 2from opentelemetry import trace, context 3from opentelemetry.trace import SpanKind, StatusCode 4 5app = FastAPI() 6tracer = trace.get_tracer("genai.service") 7 8@app.post("/api/chat") 9async def chat_endpoint(request: Request): 10 body = await request.json() 11 user_query = body["query"] 12 13 with tracer.start_as_current_span( 14 "chat_request", kind=SpanKind.SERVER 15 ) as root_span: 16 root_span.set_attribute("user.query_length", len(user_query)) 17 18 # Phase 1: RAG retrieval 19 with tracer.start_as_current_span("rag_retrieval") as rag_span: 20 docs = await retrieve_documents(user_query) 21 rag_span.set_attribute("rag.document_count", len(docs)) 22 rag_span.set_attribute("rag.strategy", "hybrid_search") 23 24 # Phase 2: Prompt construction 25 with tracer.start_as_current_span("prompt_build") as prompt_span: 26 prompt = build_prompt(user_query, docs) 27 prompt_span.set_attribute("prompt.template_version", "v2.3") 28 prompt_span.set_attribute("prompt.token_estimate", len(prompt) // 4) 29 30 # Phase 3: LLM invocation 31 with tracer.start_as_current_span( 32 "llm_call", kind=SpanKind.CLIENT 33 ) as llm_span: 34 llm_span.set_attribute("gen_ai.system", "openai") 35 llm_span.set_attribute("gen_ai.request.model", "gpt-4o") 36 response = await call_litellm(prompt) 37 llm_span.set_attribute( 38 "gen_ai.usage.prompt_tokens", response.usage.prompt_tokens 39 ) 40 llm_span.set_attribute( 41 "gen_ai.usage.completion_tokens", 42 response.usage.completion_tokens, 43 ) 44 llm_span.set_attribute("gen_ai.response.model", response.model) 45 46 # Phase 4: Guardrail post-processing 47 with tracer.start_as_current_span("guardrail_check") as guard_span: 48 result = await apply_guardrails(response.choices[0].message.content) 49 guard_span.set_attribute("guardrail.passed", result.is_safe) 50 if not result.is_safe: 51 guard_span.set_status(StatusCode.ERROR, "Guardrail blocked") 52 53 return {"response": result.content, "trace_id": format(root_span.context.trace_id, "032x")}
- Lines 1-3: Import FastAPI alongside OTel's
contextmodule (needed for propagation inasyncscenarios) andSpanKind/StatusCodeenums for span classification and error signaling. - Lines 5-6: The
traceris obtained at module level after the global provider has been set. The name"genai.service"appears in Langfuse as the instrumentation scope, letting you filter spans by origin. - Lines 9-12: The endpoint extracts the user query from the request body. In production, you would validate with Pydantic models, but the focus here is on trace instrumentation.
- Lines 14-17:
start_as_current_spancreates the root span withSpanKind.SERVER, signaling this is an inbound request handler. Langfuse maps this root span to a top-level trace. Theuser.query_lengthattribute enables correlation between query complexity and latency. - Lines 20-23: The RAG retrieval span captures how many documents were returned and which search strategy was used. These attributes let you build Langfuse dashboards comparing latency across retrieval strategies.
- Lines 26-29: The prompt construction span records the template version (critical for correlating with prompt A/B tests from another goal) and an estimated token count. The
// 4heuristic approximates tokens for English text—usetiktokenin production for accuracy. - Lines 32-45: The LLM invocation span uses
SpanKind.CLIENTbecause this is an outbound call to an external service. Thegen_ai.*attributes follow the emerging OpenTelemetry GenAI semantic conventions. Langfuse specifically recognizesgen_ai.system,gen_ai.request.model,gen_ai.usage.prompt_tokens, andgen_ai.usage.completion_tokensto populate its cost and token usage panels. - Lines 48-53: The guardrail span records whether the response passed safety checks. When
result.is_safeis False, the span status is set toStatusCode.ERRORwith a descriptive message. Langfuse surfaces errored spans with red highlights in the trace waterfall, making it trivial to spot blocked responses during quality reviews. - Line 55: The response includes the trace_id formatted as a 32-character hex string. This ID can be passed to frontend clients for user feedback correlation (covered in another goal), allowing users to report issues that link directly to the full distributed trace.
Do's and Don'ts
Do's
- ✓Do encode Langfuse public/secret key credentials as a Base64-encoded
Basicvalue in theOTLPSpanExporterAuthorizationheader — Langfuse's OTLP endpoint authenticates over HTTP Basic Auth, not separate query parameters; sending raw credential strings produces 401 rejections and silently drops every span before it ever reaches the trace viewer. - ✓Do nest each pipeline phase — retrieval, prompt build, LiteLLM call, and guardrail check — as a child span under the root
SERVERspan created by FastAPI's OTel middleware — without this parent-child hierarchy Langfuse renders the waterfall as disconnected observations, eliminating the per-phase breakdown you need to isolate whether a latency spike originated in retrieval, the LLM call, or a guardrail step. - ✓Do prefix span attributes that carry LLM-specific metadata with
gen_ai.— Langfuse's span-to-observation mapping routes only attributes in thegen_ai.namespace into its LLM analytics panels; attributes named outside that prefix appear only in the raw attribute dump and bypass token-count, model, and cost views entirely.
Don'ts
- ✗Don't substitute
SimpleSpanProcessorforBatchSpanProcessor— a synchronous processor blocks the FastAPI request thread on every export flush; at LLM-call volumes this adds export latency directly to the hot path and distorts the span timings you are trying to measure, undermining the reliability of every duration recorded in Langfuse. - ✗Don't flatten all pipeline phases onto the root request span as attributes — merging retrieval latency, LLM call duration, and guardrail timing into a single span's attribute bag produces an opaque blob; when total request time spikes from 2 seconds to 18 seconds there is no child span waterfall to reveal which stage regressed.
- ✗Don't configure only one OTLP destination when both GenAI analytics and infrastructure observability are required — routing spans exclusively to Langfuse leaves Jaeger or Datadog blind to host-level signals; routing them exclusively to Jaeger drops the
gen_ai.attribute mapping that populates Langfuse's LLM metadata panels. A secondary exporter attached to the sameTracerProviderserves both backends from a single instrumentation path.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime
More free lessons in Full-Stack GenAI Applications
- Ch 10Build Llama Guard 4 content classifier
- Ch 14Build a semantic cache with Redis + embedding similarity
- Ch 16Build OpenTelemetry distributed trace pipelinesYou are here
- Ch 16Manage prompt template versions with Langfuse
- Ch 16Use Pydantic AI + Logfire as an alternative observability stack
- Ch 18Deploy FastAPI to Cloud Run with auto-scaling
- Ch 18Deploy MCP tool servers as sidecars with external-secrets-operator