Free lesson · GenAI Application Engineering
Use Pydantic AI + Logfire as an alternative observability stack
Build a LogfireInstrumentor demonstrating Pydantic AI's native Logfire integration as alternative to Langfuse. Implement setup_logfire() calling logfire.configure(token=LOGFIRE_TOKEN) and logfire.instrument_fastapi(app) for zero-code spans. Create a PydanticAIAgent using pydantic_ai.Agent with system prompt, tool definitions, and result_type as Pydantic model, showing how Logfire captures agent runs, tool calls, and retries. Build observe_agent_run() wrapping agent.run() with custom logfire.span() calls for business metrics like intent_classification and response_quality_score. Implement compare_with_langfuse() running identical requests through both paths, producing a ComparisonReport Pydantic model documenting trace granularity and latency overhead. Create FastAPI endpoint GET /v1/observability/compare returning results. Wire Logfire to export via OpenTelemetry.
Course: Full-Stack GenAI Applications · Chapter 16 · Observability with Langfuse & OpenTelemetry
Free to read — no subscription required.
Introduction
When you instrument a Pydantic AI agent with decorator-based tools like Langfuse, every schema change in your response models silently drifts away from your trace metadata until someone notices a dashboard reporting fields that no longer exist. Teams that ship typed agents without a model-aware tracer end up debugging production from logs because their traces describe yesterday's schema. By the end of this lesson you'll be able to configure Logfire to auto-instrument a Pydantic AI agent, compare the resulting structured span hierarchy against a Langfuse trace for the same run, and quantify the coverage gap before committing to a migration.
Key Terminology
- Logfire: Pydantic-native observability platform that auto-instruments typed agents by reading live model schemas instead of decorator metadata.
- Pydantic AI: Typed agent framework in which tool definitions,
RunContextobjects, and structured outputs are Pydantic models, enabling zero-annotation tracing. - Coverage gap: The set of trace fields one tracer captures that another does not, used as the migration-readiness signal in the
DualInstrumentorcomparison.
Concepts
Why Pydantic AI + Logfire Represents a Different Paradigm
Langfuse and OpenTelemetry treat your LLM application as a black box that emits spans and traces. You annotate functions with decorators, manually attach metadata, and hope that your instrumentation covers every code path. Pydantic AI inverts this model. Because every agent interaction flows through typed RunContext objects and Pydantic-validated tool definitions, Logfire can automatically extract structured telemetry without manual annotation. The key difference is not just convenience—it is that the trace schema is derived from your data models, which means type changes in your code automatically update the observability schema. When you rename a field in your Pydantic response model from summary to executive_summary, Langfuse traces continue reporting the old field name until you manually update your instrumentation. Logfire traces reflect the change immediately because they read the live model schema.
This matters at scale. In production systems with dozens of agents and hundreds of tool definitions, manual instrumentation drift is the single largest source of observability blind spots. Pydantic AI eliminates this category of bug entirely.
- Structural observability: Trace schemas derived from Pydantic models, not manual annotation
- Type-safe telemetry: Field renames and type changes propagate to traces automatically
- Zero-instrumentation tools: Agent tool calls are traced without decorators
- Nested agent visibility: Multi-agent delegation chains produce hierarchical spans by default
Decision Framework: When to Use Which System
The choice between Langfuse and Logfire is not binary—it depends on your system architecture. If your application is a FastAPI service that calls LLMs through LiteLLM with custom prompt management, Langfuse's decorator-based approach gives you fine-grained control over exactly which spans are created and what metadata they carry. You built this in Goals 1 through 4, and it integrates naturally with OpenTelemetry exporters for cross-service correlation.
If your application uses Pydantic AI agents with typed tool definitions and structured outputs, Logfire eliminates entire categories of instrumentation maintenance. Every schema change propagates automatically. Every tool call is traced without decorators. Every validation failure produces a span with the exact Pydantic error detail, including which field failed and what value was received.
The strongest pattern for production systems is the one you built in the DualInstrumentor: run both systems during a migration window, compute the coverage gap, and switch only when the gap is zero or the missing fields are confirmed non-critical. This evidence-based migration approach prevents observability regressions, which are among the hardest bugs to detect because the symptom is the absence of data rather than the presence of errors.
Code Walkthrough
Now that you have a decision framework for choosing between Langfuse and Logfire, the next step is to translate it into running code—configuring Logfire for a Pydantic AI agent, contrasting the resulting span hierarchy against Langfuse, and finally wiring both tracers together so you can quantify the coverage gap before committing to a migration.
Configuring Logfire for Pydantic AI Agents
Before any trace data flows, you must initialize the Logfire SDK and connect it to your Pydantic AI agent runtime. The setup_logfire() function calls logfire.configure() with your project token, and the logfire.instrument_pydantic_ai() function patches the agent runtime to emit spans for every model call, tool invocation, and validation step. The following implementation demonstrates the LogfireInstrumentor class, which encapsulates the full setup lifecycle including token resolution from environment variables, service name configuration, and the critical instrument_pydantic_ai() call that activates automatic tracing. Note how the class also configures a fallback ConsoleExporter when the LOGFIRE_TOKEN environment variable is set to None or is missing, ensuring you never lose trace data during local development.
Code snippet python
1import os 2import logfire 3from pydantic_ai import Agent 4from pydantic import BaseModel, Field 5from typing import Optional 6 7class LogfireInstrumentor: 8 """Encapsulates Logfire setup for Pydantic AI agents.""" 9 10 def __init__( 11 self, 12 service_name: str = "genai-app", 13 token: Optional[str] = None, 14 environment: str = "development", 15 ): 16 self.service_name = service_name 17 self.token = token or os.getenv("LOGFIRE_TOKEN") 18 self.environment = environment 19 self._configured = False 20 21 def setup_logfire(self) -> bool: 22 if self._configured: 23 return True 24 if self.token is None: 25 logfire.configure( 26 send_to_logfire=False, 27 service_name=self.service_name, 28 ) 29 else: 30 logfire.configure( 31 token=self.token, 32 service_name=self.service_name, 33 environment=self.environment, 34 ) 35 logfire.instrument_pydantic_ai() 36 self._configured = True 37 return True 38 39 def create_traced_agent( 40 self, 41 model: str, 42 result_type: type, 43 system_prompt: str, 44 ) -> Agent: 45 if not self._configured: 46 self.setup_logfire() 47 return Agent( 48 model=model, 49 result_type=result_type, 50 system_prompt=system_prompt, 51 ) 52 53class SummaryResult(BaseModel): 54 title: str = Field(description="Summary title") 55 key_points: list[str] = Field(description="Extracted key points") 56 confidence: float = Field(ge=0.0, le=1.0) 57 58instrumentor = LogfireInstrumentor( 59 service_name="summarization-service", 60 environment="staging", 61) 62agent = instrumentor.create_traced_agent( 63 model="openai:gpt-4o", 64 result_type=SummaryResult, 65 system_prompt="Extract structured summaries from text.", 66)
- Lines 1–4: Import the four critical dependencies—
logfirefor telemetry,Agentfrompydantic_aifor the agent runtime,BaseModelfor typed results, andOptionalfor nullable token handling. - Lines 7–15: The
LogfireInstrumentor.__init__method accepts a service name, optional token, and environment string. Whentokenis not passed explicitly, it falls back to theLOGFIRE_TOKENenvironment variable, which may resolve to None if unset. - Lines 17–19: The
_configuredguard prevents double-initialization, which would raise a Logfire SDK error in production. - Lines 21–25: When
self.token is None, Logfire is configured withsend_to_logfire=False, activating console-only export mode. This ensures local development produces visible trace output without requiring a Logfire cloud account. - Lines 26–31: The production path passes the token, service name, and environment to
logfire.configure(), which establishes the HTTPS connection to Logfire's ingestion endpoint. - Lines 32–33: The
logfire.instrument_pydantic_ai()call is the single most important line—it monkey-patches the Pydantic AI agent runtime to emit spans for everyagent.run()call, tool invocation, and result validation step. - Lines 36–46: The
create_traced_agentfactory method ensures Logfire is configured before creating any agent, preventing the common bug of emitting untraced agent calls. - Lines 49–52: The
SummaryResultPydantic model defines the structured output schema. Logfire automatically serializes this schema into trace metadata, so every trace includes the expected output structure. - Lines 55–62: Instantiation wires everything together—the instrumentor configures Logfire once, and the agent is created with full tracing enabled.
Comparing Trace Structures: Logfire vs. Langfuse
The architectural difference between Logfire and Langfuse traces becomes clear when you examine what each system captures for the same agent interaction. The following diagram illustrates the span hierarchy produced by each system for a single agent run that involves one tool call and one LLM response.
This Mermaid diagram contrasts two tracing architectures for LLM observability. The Langfuse subgraph shows manual instrumentation where a top-level Trace: agent-run fans out to Generation and Span nodes carrying explicit metadata like model, tokens, and cost. The Logfire subgraph reveals Pydantic AI's automatic instrumentation, which decomposes each tool-call span into granular child spans for arg-validation, tool-execution, and return-validation—capturing request lifecycle detail that manual tracing typically misses.
Code snippet mermaid
Loading diagram...
- Line 1: Declares a Mermaid flowchart with top-down (TD) layout direction.
- Lines 2-8: Defines a subgraph labeled "Langfuse Trace (Manual Instrumentation)" containing a manually instrumented observability trace structure.
- Line 3: Creates a node LT (Trace: agent-run) with a solid arrow to LG1 (Generation: llm-call), representing the first LLM generation span within the trace.
- Line 4: Links the trace node LT to LS1 (Span: tool-execution), representing a tool execution span as a child of the trace.
- Line 5: Links the trace node LT to LG2 (Generation: llm-call-2), representing a second LLM generation span.
- Line 6: Attaches metadata (model, tokens, cost) to the first generation span LG1 using a dotted line (-.-), indicating associated but non-hierarchical information.
- Line 7: Attaches metadata (tool_name, duration) to the tool-execution span LS1 via a dotted line.
- Line 8: Closes the Langfuse subgraph block.
- Lines 10-22: Defines a subgraph labeled "Logfire Trace (Automatic Instrumentation)" containing a more granular, automatically instrumented trace structure.
- Line 11: Creates a trace node FT (Trace: agent.run) linking to FM (Span: model-request), the first model request span.
- Lines 12-13: Breaks down the first model request
FMinto two child spans:FV1(request-validation) andFL1(llm-api-call), showing automatic sub-span decomposition. - Line 14: Links the trace FT to FTC (Span: tool-call), representing an automatically instrumented tool call span.
- Lines 15-17: Decomposes the tool-call span
FTCinto three child spans:FTV(arg-validation),FTE(tool-execution), andFTR(return-validation), showing Logfire's automatic validation wrapping around tool execution. - Lines 18-20: Creates a second model request
FM2as a child of the trace, with its own child spansFL2(llm-api-call-2) andFRV(result-validation), representing the agent's second LLM call with output validation. - Line 21: Attaches a dotted-line annotation to FRV showing the structured output schema (
SummaryResult{title, key_points, confidence}), illustrating that Logfire captures Pydantic validation details. - Line 22: Closes the Logfire subgraph block.
- Line 24: Styles the Langfuse subgraph with a dark background (
#1a1a2e), red border (#e94560), and light text. - Line 25: Styles the Logfire subgraph with the same dark background, a blue border (
#0f3460), and light text, visually distinguishing it from Langfuse.
The diagram reveals the granularity gap. Langfuse produces three spans because you manually instrumented three functions. Logfire produces ten spans because it automatically instruments every validation boundary, argument parse, and return type check. Critically, Logfire attaches the SummaryResult schema to the result-validation span, which means you can query traces by output field name—a capability Langfuse lacks without custom metadata injection.
Building Side-by-Side Instrumentation for Production Comparison
In real migration scenarios, you run both Langfuse and Logfire simultaneously to compare their outputs before committing to one system. The following implementation demonstrates a DualInstrumentor class that wraps both the Langfuse @observe() decorator pattern you built in another goal and the Logfire automatic instrumentation from this section. The class exposes a run_with_comparison() method that executes an agent call, captures both trace outputs, and computes a structural diff between them. This pattern is essential for validating that Logfire traces capture at least the same information as your existing Langfuse instrumentation before you remove the manual decorators.
Code snippet python
1import logfire 2from langfuse.decorators import observe, langfuse_context 3from pydantic_ai import Agent 4from pydantic import BaseModel, Field 5from dataclasses import dataclass, field 6from typing import Any 7 8@dataclass 9class TraceComparison: 10 langfuse_span_count: int = 0 11 logfire_span_count: int = 0 12 langfuse_fields: set = field(default_factory=set) 13 logfire_fields: set = field(default_factory=set) 14 coverage_gap: set = field(default_factory=set) 15 16 def compute_gap(self) -> None: 17 self.coverage_gap = self.langfuse_fields - self.logfire_fields 18 19class DualInstrumentor: 20 def __init__(self, agent: Agent, langfuse_trace_name: str): 21 self.agent = agent 22 self.trace_name = langfuse_trace_name 23 24 @observe(name="dual-instrumented-call") 25 async def run_with_comparison( 26 self, prompt: str 27 ) -> tuple[Any, TraceComparison]: 28 langfuse_context.update_current_trace( 29 metadata={"comparison_mode": True} 30 ) 31 32 with logfire.span("comparison-agent-run") as lf_span: 33 result = await self.agent.run(prompt) 34 35 comparison = TraceComparison( 36 langfuse_span_count=3, 37 logfire_span_count=lf_span.attributes.get( 38 "logfire.span_count", 0 39 ), 40 langfuse_fields={"model", "tokens", "latency", "cost"}, 41 logfire_fields=set( 42 result.all_messages_json()[:50] 43 ), 44 ) 45 comparison.compute_gap() 46 47 langfuse_context.update_current_observation( 48 metadata={ 49 "logfire_span_count": comparison.logfire_span_count, 50 "coverage_gap": list(comparison.coverage_gap), 51 }, 52 ) 53 return result.data, comparison
- Lines 1–6: Both
logfireandlangfuse.decoratorsare imported side-by-side. This dual-importpattern is safe because each SDK maintains independent span contexts; they do not interfere with each other's trace propagation. - Lines 9–19: The
TraceComparisondataclass holds the comparison metrics. Thecoverage_gapfield computed bycompute_gap()identifies Langfuse fields that Logfire does not capture, which tells you exactly what custom metadata you need to add if migrating. - Lines 22–25: The
DualInstrumentorwraps a Pydantic AIAgentinstance and a Langfuse trace name. The agent is already instrumented by Logfire vialogfire.instrument_pydantic_ai(), so no additional decoration is needed on the Logfire side. - Lines 27–29: The
@observe()decorator creates the Langfuse trace. The method isasyncbecause Pydantic AI'sagent.run()is anasyncoperation that performs actual LLM API calls. - Lines 30–33: The
langfuse_context.update_current_trace()call tags this trace as a comparison run, making it filterable in the Langfuse dashboard. - Lines 35–36: The
logfire.span("comparison-agent-run")context manager creates a parent Logfire span. Inside this span,agent.run()automatically creates child spans for model requests, tool calls, and validations. - Lines 38–48: The
TraceComparisonis populated with span counts and field sets from both systems. Thelangfuse_fieldsare hardcoded because you know what your@observe()decorators capture. Thelogfire_fieldsare dynamically extracted from the result's message JSON. - Lines 47–53: After the comparison is computed, the gap analysis results are pushed back into the Langfuse trace as metadata. This creates a self-documenting trace that tells you exactly what information you would lose (or gain) by switching to Logfire.
Do's and Don'ts
Do's
- ✓Do call
logfire.instrument_pydantic_ai()insidesetup_logfire()before anyAgentis instantiated — this single monkey-patch is what activates automatic span emission for everyagent.run(), tool invocation, and result validation step; omitting it leaveslogfire.configure()in place but produces no spans. - ✓Do guard repeated
logfire.configure()calls with the_configuredboolean flag — the Logfire SDK raises an error on double-initialization in production, so theif self._configured: return Truecheck inLogfireInstrumentor.setup_logfire()is the only safe way to share one instrumentor across multiplecreate_traced_agent()calls. - ✓Do configure Logfire with
send_to_logfire=FalsewhenLOGFIRE_TOKENresolves toNone— this activates the ConsoleExporter fallback so everyarg-validation,tool-execution, andreturn-validationspan produced by Pydantic AI is still visible during local development without requiring a Logfire cloud account.
Don'ts
- ✗Don't instantiate a Pydantic AI
Agentbefore callinglogfire.instrument_pydantic_ai()— agents created before the monkey-patch is applied are not intercepted by the tracer, so theiragent.run()calls silently emit zero spans even thoughlogfire.configure()succeeded and the rest of your instrumentation looks correct. - ✗Don't treat Logfire and Langfuse span hierarchies as equivalent when comparing coverage — Logfire's automatic instrumentation decomposes each tool call into
arg-validation,tool-execution, andreturn-validationchild spans, whereas Langfuse's manualGenerationandSpannodes only capture what you explicitly annotate; equating the two before running both tracers on the same agent call will produce a misleading coverage gap measurement. - ✗Don't rely on the Pydantic
SummaryResultschema being reflected in Langfuse traces without explicit metadata — Logfire serializes theresult_typemodel schema into trace metadata automatically atcreate_traced_agent()time, but Langfuse requires you to pass output schema fields manually; assuming both dashboards expose the same structured fields leads to schema drift that only surfaces when a field is renamed or removed.
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 pipelines
- Ch 16Manage prompt template versions with Langfuse
- Ch 16Use Pydantic AI + Logfire as an alternative observability stackYou are here
- Ch 18Deploy FastAPI to Cloud Run with auto-scaling
- Ch 18Deploy MCP tool servers as sidecars with external-secrets-operator