{"slug": "langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing", "title": "Langfuse for LLM Observability: Tracing Agent Calls Instead of Guessing", "summary": "A developer detailed how Langfuse provides the trace/span/generation hierarchy needed to debug multi-step agentic LLM workflows, contrasting it with observability tools that only capture top-level calls. The developer consolidated multiple tools onto Langfuse to reduce resource sprawl and attached evaluation scores directly to traces, and shared self-hosting guidance for Kubernetes using CloudNativePG.", "body_md": "An agent makes six tool calls, picks the wrong one on step four, and the final output is garbage. You stare at your logs. You see the input. You see the output. Everything in between is a void. That's the black box problem with agentic LLM workflows, and it's the reason I started looking at Langfuse seriously.\n\nIf you're running multi-step agents (LangChain, custom loops, or any orchestration layer), you need per-step tracing with enough context to reconstruct *why* the agent chose what it chose. Langfuse gives you that. But getting it wired up correctly, especially in a self-hosted Kubernetes environment alongside other observability tools, has a few sharp edges worth knowing about.\n\nBefore I get into Langfuse itself, I want to talk about a failure mode I see constantly with LLM tooling: observability sprawl.\n\nHere's how it usually plays out. You spin up Dify because it has a nice agent builder. You add Opik because someone recommended it for evaluation. You deploy AnythingLLM for RAG experiments. Each tool has its own Postgres database, its own PVC, its own memory footprint. Before you know it, you've got three separate platforms that each capture *some* traces, and none of them give you the full picture.\n\nResource costs compound quickly. In a homelab or small-cluster environment, those redundant tools can easily consume 8-10 GB of RAM and 50-70 GB of persistent storage. Those are real resources you're giving up for the privilege of having your debugging split across multiple dashboards.\n\nPick one tool, instrument everything through it, and delete the rest. Langfuse is the one I picked, and the consolidation alone was worth it. But the *reason* I picked it over alternatives comes down to one specific feature.\n\nMost LLM observability tools trace at the wrong granularity. They capture the top-level call: here's the prompt, here's the completion, here's the token count. Fine for a single `chat.completions`\n\ncall. Nearly useless for an agentic workflow.\n\nConsider what happens in a typical agent loop. An orchestrator receives a user query. It decides which tool to call. That tool might call an LLM itself (for summarization, extraction, or routing). Results come back, and the orchestrator decides whether to call another tool or return a final answer. A single user request might involve four or five LLM calls, each with different prompts, different models, and different failure modes.\n\nWhat I needed was the ability to trace the full execution tree: one top-level \"trace\" for the user request, with nested \"spans\" for each agent step, and nested \"generations\" for each LLM call within those steps. Langfuse calls this the trace/span/generation hierarchy, and it maps cleanly onto how [multi-agent systems](https://guatulabs.dev/posts/multi-agent-ai-systems-architecture-patterns/) actually work.\n\nEvaluation scores were the other hard requirement, and I wanted them attached to traces, not living in a separate system. I had a custom evaluation layer built with Zod schemas that validated agent outputs against expected structures. It worked, but it was brittle, lived in application code, and had no dashboard. Langfuse lets you attach numeric scores to any trace or span, which means your evaluation data lives right next to your trace data. One place to look.\n\nLangfuse has a managed cloud offering, but if you're already running a cluster, self-hosting is straightforward. The project provides a Helm chart and Docker images. The main dependency is Postgres.\n\nIf you're already running [CloudNativePG](https://guatulabs.dev/posts/cloudnativepg-running-postgresql-in-kubernetes-without-the-pain/), you can point Langfuse at an existing cluster. Create a dedicated database for it:\n\n```\napiVersion: postgresql.cnpg.io/v1\nkind: Cluster\nmetadata:\n  name: langfuse-db\n  namespace: observability\nspec:\n  instances: 2\n  storage:\n    size: 10Gi\n  bootstrap:\n    initdb:\n      database: langfuse\n      owner: langfuse\n```\n\nLangfuse itself is a single container with environment variables for the database connection, a secret key, and your desired auth settings. A minimal Kubernetes deployment looks like this:\n\n```\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: langfuse\n  namespace: observability\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: langfuse\n  template:\n    spec:\n      containers:\n        - name: langfuse\n          image: langfuse/langfuse:2.x\n          ports:\n            - containerPort: 3000\n          env:\n            - name: DATABASE_URL\n              valueFrom:\n                secretKeyRef:\n                  name: langfuse-db-credentials\n                  key: uri\n            - name: NEXTAUTH_SECRET\n              valueFrom:\n                secretKeyRef:\n                  name: langfuse-auth\n                  key: secret\n            - name: NEXTAUTH_URL\n              value: \"https://langfuse.example.com\"\n            - name: SALT\n              valueFrom:\n                secretKeyRef:\n                  name: langfuse-auth\n                  key: salt\n```\n\nIf you're deploying through [ArgoCD](https://guatulabs.dev/posts/gitops-for-homelabs-argocd-app-of-apps/), there's a gotcha worth flagging. If you organize your observability stack in a directory structure (say, `observability/langfuse/`\n\n, `observability/grafana/`\n\n, etc.) and use a directory-type Application source, you need to set `directory.recurse: true`\n\n. Without it, ArgoCD will show \"0 managed resources\" even though your manifests exist in subdirectories. It's a silent failure that'll have you rechecking file paths for twenty minutes before you realize ArgoCD just isn't looking deep enough.\n\n```\napiVersion: argoproj.io/v1alpha1\nkind: Application\nmetadata:\n  name: observability\nspec:\n  source:\n    repoURL: https://git.example.com/infra.git\n    path: observability\n    directory:\n      recurse: true  # without this, subdirectories are invisible\n```\n\nOnce Langfuse is running, the real work begins: instrumenting your agent code so each step shows up as a distinct span in the trace tree. Langfuse's Python SDK makes this fairly clean with decorators.\n\nA minimal example for a custom agent loop:\n\n``` python\nfrom langfuse.decorators import observe, langfuse_context\nfrom openai import OpenAI\n\nclient = OpenAI()\n\n@observe(as_type=\"generation\")\ndef call_llm(prompt: str, model: str = \"gpt-4o\") -> str:\n    response = client.chat.completions.create(\n        model=model,\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return response.choices[0].message.content\n\n@observe()\ndef search_tool(query: str) -> str:\n    # your tool logic here\n    results = do_search(query)\n    return results\n\n@observe()\ndef agent_loop(user_query: str) -> str:\n    plan = call_llm(f\"Plan steps for: {user_query}\")\n\n    for step in parse_steps(plan):\n        if step.tool == \"search\":\n            result = search_tool(step.input)\n        elif step.tool == \"summarize\":\n            result = call_llm(f\"Summarize: {step.input}\")\n        # each iteration creates a child span automatically\n\n    final = call_llm(f\"Final answer given results: {result}\")\n    return final\n```\n\nEvery function decorated with `@observe()`\n\nbecomes a span in Langfuse. Functions marked `as_type=\"generation\"`\n\nget special treatment: Langfuse records token counts, model name, latency, and prompt/completion pairs. Nested calls automatically create a parent-child hierarchy, so when you open a trace in the Langfuse UI, you see the full tree.\n\nFor TypeScript/Node.js backends, the pattern is similar but uses the `Langfuse`\n\nclient class directly:\n\n``` python\nimport Langfuse from \"langfuse\";\n\nconst langfuse = new Langfuse({\n  publicKey: process.env.LANGFUSE_PUBLIC_KEY,\n  secretKey: process.env.LANGFUSE_SECRET_KEY,\n  baseUrl: \"https://langfuse.example.com\",\n});\n\nasync function tracedAgentCall(userQuery: string) {\n  const trace = langfuse.trace({ name: \"agent-request\" });\n  const planSpan = trace.span({ name: \"planning\" });\n\n  const plan = await callLLM(userQuery);\n  planSpan.update({ output: plan });\n  planSpan.end();\n\n  for (const step of parseSteps(plan)) {\n    const toolSpan = trace.span({\n      name: `tool:${step.tool}`,\n      input: step.input,\n    });\n    const result = await executeTool(step);\n    toolSpan.update({ output: result });\n    toolSpan.end();\n  }\n\n  await langfuse.flushAsync();\n}\n```\n\nNotice the explicit `flushAsync()`\n\nat the end. Langfuse batches events for performance. In serverless or short-lived processes, skipping the flush means you lose traces silently. I've seen this bite people running agents in Lambda functions or one-shot scripts.\n\nBefore Langfuse, my evaluation layer was a hand-rolled mess. Zod schemas validated agent outputs, results got persisted to a JSON file or a database table, and \"evaluation\" meant grepping through structured logs. It worked, in the sense that a Rube Goldberg machine works.\n\nLangfuse replaces that with `score`\n\ncalls attached directly to traces:\n\n``` python\n# Before: custom evaluation persisted to database\nfrom zod_validator import AgentOutputSchema\nimport json\n\ndef evaluate_and_persist(output, expected_schema):\n    result = AgentOutputSchema.safeParse(output)\n    with open(\"eval_log.jsonl\", \"a\") as f:\n        json.dump({\n            \"valid\": result.success,\n            \"errors\": result.errors if not result.success else None,\n            \"timestamp\": datetime.now().isoformat()\n        }, f)\n        f.write(\"\\n\")\n# After: scores live in Langfuse alongside traces\nfrom langfuse.decorators import observe, langfuse_context\n\n@observe()\ndef agent_with_eval(user_query: str) -> str:\n    result = agent_loop(user_query)\n\n    # attach a quality score to this trace\n    langfuse_context.score_current_trace(\n        name=\"output_valid\",\n        value=1.0 if validate_output(result) else 0.0,\n    )\n\n    # attach a relevance score\n    langfuse_context.score_current_trace(\n        name=\"relevance\",\n        value=compute_relevance(user_query, result),\n        comment=\"cosine similarity against expected answer\",\n    )\n\n    return result\n```\n\nNow your evaluation data shows up in the same dashboard as your traces. You can filter traces by score, spot regressions over time, and correlate low scores with specific agent steps that failed. No more cross-referencing JSON log files with application logs.\n\nLangfuse's trace/span/generation model maps onto agentic workflows because it mirrors the actual call stack. A trace is a complete user request. Spans are logical operations within that request. Generations are the individual LLM calls.\n\nThis hierarchy means you can answer questions that flat logging can't:\n\nCompare this to what you get with [Grafana dashboards](https://guatulabs.dev/posts/grafana-dashboards-information-density-vs-readability/). Grafana excels at aggregate metrics: request rate, p99 latency, error percentage. It shows you the forest. Langfuse shows you individual trees. You need both, but for debugging agent behavior, the per-trace detail is what saves you.\n\nPrompt management is another underappreciated feature. Langfuse lets you version prompts in its UI, then fetch them at runtime by name and version. This decouples prompt iteration from code deployment. Your prompt engineer (or you, wearing that hat) can tweak prompts and track how each version affects scores, without touching application code or triggering a redeploy.\n\nOne thing to think about early: agent traces often contain sensitive data. Tool inputs might include search queries, user data, or [service account credentials](https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/). Langfuse stores everything you send it.\n\nIf you're self-hosting, this is manageable because the data stays in your cluster. But you should still be intentional about what gets logged. Scrub sensitive fields before they hit the trace:\n\n``` php\n@observe()\ndef safe_tool_call(tool_name: str, params: dict) -> str:\n    sanitized = {k: v for k, v in params.items()\n                 if k not in [\"api_key\", \"token\", \"password\"]}\n\n    langfuse_context.update_current_observation(\n        input=sanitized,  # only safe fields\n    )\n    return execute_tool(tool_name, params)  # full params for execution\n```\n\nFor managed Langfuse (their cloud), check your data handling requirements before shipping traces that contain PII or internal API responses. If you're building [AI agent services](https://guatulabs.com/services) for clients, this is a compliance conversation you want to have before the first trace lands.\n\n**Consolidate early.** Running multiple LLM observability tools feels productive because you're \"evaluating options.\" In practice, it means your traces are fragmented, your resource usage balloons, and you debug slower because you're checking two dashboards for every issue. Pick one tool and commit. If Langfuse doesn't fit your stack, pick something else, but pick *one*.\n\n**Instrument at the span level from day one.** Adding tracing to an existing agent codebase after the fact is painful. Every function needs to be wrapped, and you inevitably miss the one tool call that turns out to be the problem. If you're building a new agent, add `@observe()`\n\ndecorators as you write each function. Retrofitting is always harder.\n\n**Flush your traces.** Langfuse batches events for efficiency, which means traces can be lost if your process exits before the batch ships. Call `langfuse.flush()`\n\n(Python) or `langfuse.flushAsync()`\n\n(TypeScript) at the end of every request handler. In serverless environments, this is not optional.\n\n**Scores are cheap. Use them.** Attaching a `score`\n\ncall adds negligible overhead, but it gives you trend data you can't get any other way. Even a simple binary \"output was valid\" score, aggregated over hundreds of traces, tells you whether your agent is getting better or worse after a prompt change. I score every trace now, even if the scoring logic is basic.\n\n**Self-hosting is worth it for sensitive workloads.** Agent traces contain prompts, tool outputs, and sometimes user data. Keeping that data on your own cluster, behind your own network policies, is worth the operational overhead of managing a Postgres database and a single container. If you're already running Kubernetes with CloudNativePG, the marginal cost is low.\n\nLangfuse isn't the most exciting tool I've deployed. It doesn't generate flashy demos. But it's the tool that made my agent debugging go from \"stare at logs and guess\" to \"open the trace, click the failing span, read the prompt.\" For anything running in production, that difference is everything.", "url": "https://wpnews.pro/news/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing", "canonical_source": "https://dev.to/futhgar/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing-7h3", "published_at": "2026-08-13 22:15:48+00:00", "updated_at": "2026-08-13 22:46:54.053670+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools", "mlops"], "entities": ["Langfuse", "Dify", "Opik", "AnythingLLM", "CloudNativePG", "LangChain", "Zod"], "alternates": {"html": "https://wpnews.pro/news/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing", "markdown": "https://wpnews.pro/news/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing.md", "text": "https://wpnews.pro/news/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing.txt", "jsonld": "https://wpnews.pro/news/langfuse-for-llm-observability-tracing-agent-calls-instead-of-guessing.jsonld"}}