n8n Claude Code Langfuse observability
# Claude Code + n8n + Langfuse v2: Local LLM Full-Stack OTEL Tracing Debugging Field Report
In the first installment, I covered the 5 production pitfalls of getting n8n + Langfuse self-hosted running (502 errors, Redis dependency, 2GB VPS OOM, PosMQ timeout, S3 object naming conflicts). That was about **survival**. This v2 is about **visibility**: once it's running, how do you actually debug token drift, span breakage, and trace loss across multi-step LLM workflows?
Background: Why Full-Stack Observability?
n8n's built-in execution logs show node-level I/O only. When you have a multi-step LLM chain (classify → extract → generate), the node logs alone won't tell you which step consumed 3200 tokens vs 800. Langfuse's span view reconstructs the token cost and latency per LLM call — but only if your traces are correctly chained together.
This article's stack:
- n8n 1.88.x self-hosted (Docker Compose, SQLite)
- Langfuse v3 self-hosted (PostgreSQL 16, OTLP HTTP port 44381)
- Claude 3.5 Sonnet via OpenRouter ($0.008/1K input tokens)
- Python 3.12 + Langfuse SDK 2.18.0
🛠️ Deployment Architecture
n8n and Langfuse communicate over the same Docker Compose network:
# docker-compose.yml (key excerpt)
services:
n8n:
image: n8nio/n8n:1.88.2
environment:
- N8N_METRICS=true
- N8N_TRUSTED_PROXIES=*
volumes:
- ./n8n-data:/home/node/.n8n
langfuse:
image: langfuse/langfuse:v3.9.1
ports:
- "3000:3000"
- "44381:44381" # OTLP HTTP receiver
environment:
- DATABASE_URL=postgresql://langfuse:langfuse_secure_pass@postgres:5432/langfuse
- NEXTAUTH_SECRET=your-secret-here
- SALT=your-salt-here
- OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:44381
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=langfuse
- POSTGRES_USER=langfuse
- POSTGRES_PASSWORD=langfuse_secure_pass
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Langfuse's OTLP HTTP endpoint defaults to port 44381 (per OTLP spec). n8n's HTTP Request node posts directly to this port.
💣 Pitfall Field Notes
Pitfall 1: n8n HTTP Request Node OTLP Header Format Error → All Traces Lost
Symptom: Langfuse dashboard shows zero traces, but Langfuse logs show requests arriving.
Diagnosis:
docker compose logs langfuse 2>&1 | grep -i "otlp\|ingestion\|422\|400"
# Output: requests arriving but returning 400
**Root Cause**: Langfuse v3's OTLP HTTP receiver requires a specific content-type + header combo. n8n's HTTP Request node defaults to Content-Type: application/json, but the OTLP protocol requires either application/x-protobuf (gRPC) or a specific NDJSON format.
Langfuse's HTTP OTLP receiver (/api/public/ingestion) accepts two JSON formats:
1. **NDJSON** (one JSON object per line): {"resourceSpans":[...]}
2. **JSON array**: [{"resourceSpans":[...]}]
But n8n treats the body as plain JSON, causing format mismatch.
Fix: Manually configure the n8n HTTP Request node:
- **Method**: POST
- **URL**: `http://langfuse:44381/api/public/ingestion`
- **Headers**: `Content-Type: application/x-ndjson`
- **Body Content Type**: Raw → JSON (NDJSON)
- **Body**:
{"resourceSpans":[{"scopeSpans":[{"spans":[{"name":"Claude 3.5 Sonnet","attributes":[{"key":"llm.token_usage.prompt","value":{"intValue":"{{ $json.token_usage.prompt_tokens }}}},{"key":"llm.token_usage.completion","value":{"intValue":"{{ $json.token_usage.completion_tokens }}}}]}]}]}]}
The more reliable approach is to bypass manual OTLP construction entirely: use the Python Script node with the Langfuse SDK, which handles all header and serialization automatically.
Pitfall 2: Python Script Node Span Breakage — Independent Traces per Node
Symptom: Multiple Python Script nodes in the same workflow show up as separate traces in Langfuse, not chained under one parent trace.
Root Cause: Langfuse SDK defaults to an in-memory trace ID. If each Python Script node initializes its own Langfuse client independently, they don't share trace IDs.
# ❌ WRONG: each node initializes independently
from langfuse import Langfuse
langfuse = Langfuse(
public_key="...",
secret_key="...",
host="http://langfuse:3000"
)
Fix: Use n8n's execution ID as the parent trace ID across nodes:
# ✅ CORRECT: share trace ID across nodes
import os
# Get current execution ID from n8n environment
execution_id = os.environ.get('N8N_EXECUTION_ID', 'default')
langfuse = Langfuse(
public_key=os.environ.get('LANGFUSE_PUBLIC_KEY'),
secret_key=os.environ.get('LANGFUSE_SECRET_KEY'),
host=os.environ.get('LANGFUSE_HOST', 'http://langfuse:3000'),
tags=['n8n', f'execution-{execution_id}']
)
# Get or create current span
parent_trace = langfuse.trace(
name="n8n-workflow",
metadata={
"execution_id": execution_id,
"workflow_name": os.environ.get('N8N_WORKFLOW_NAME', 'unknown')
}
)
# Create a child span within this node
with parent_trace.span(
name="classify-email",
input={"email": input_data}
) as span:
result = classify_email(input_data)
span.update(
output=result,
metadata={"model": "claude-3-5-sonnet", "cost_usd": 0.001}
)
Each Python Script node in n8n shares the same N8N_EXECUTION_ID environment variable, allowing them to coordinate via that ID as the trace key.
Pitfall 3: Token Drift — Claude API Usage and Langfuse Records Don't Match
Symptom: Langfuse shows a specific LLM call consumed 3200 total_tokens, but OpenRouter billing shows 4800. Where did the extra 1600 go?
**Diagnosis**: Claude API's raw response usage field includes prompt_tokens, completion_tokens, and system_tokens (Claude's system prompt is counted separately):
{
"usage": {
"prompt_tokens": 1500,
"completion_tokens": 1700,
"system_tokens": 800
}
}
If your parsing only reads completion_tokens, and Langfuse SDK's callback auto-records total_tokens = prompt + completion without capturing system_tokens, you get drift.
Fix: Explicitly extract and push the full usage:
# Manually report complete usage to Langfuse
api_response = call_claude_api(messages)
usage = api_response.get('usage', {})
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
system_tokens = usage.get('system_tokens', 0) # Claude-specific
total_tokens = prompt_tokens + completion_tokens + system_tokens
# Manual record to Langfuse (bypasses SDK's potentially incomplete auto-capture)
langfuse.score(
name="token_usage",
value=total_tokens,
trace_id=parent_trace.id,
metadata={
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"system_tokens": system_tokens,
"model": "claude-3-5-sonnet"
}
)
Pitfall 4: Child Workflow Trace Context Not Passed to Parent Workflow
Symptom: Parent workflow calls child workflow via n8n Execute Workflow node. Langfuse shows the parent trace but child workflow's spans are orphaned — not nested under the parent trace.
Root Cause: n8n's Execute Workflow node does not propagate context by default (environment variables, N8N_EXECUTION_ID, etc.). The child workflow doesn't know it's being called by a parent.
Fix: Use the Execute Workflow node's Pass on configuration to explicitly transfer metadata:
1. In parent workflow's Execute Workflow node:
- Mode: Reactive (recommended)
- **Pass on**: ={{ JSON.stringify({ "parent_trace_id": $vars.LANGFUSE_TRACE_ID, "parent_execution_id": $execution.id }) }}
2. In child workflow's first node (Set node), receive the data:
{{ JSON.parse($json.parent_trace_id) }}
Then in the Python Script node, use that parent_trace_id to continue the span:
parent_data = $input.first().json
trace_id = parent_data.get('parent_trace_id')
if trace_id:
# Continue parent trace, don't create a new one
generation = langfuse.generation(
name="child-workflow-step",
trace_id=trace_id, # Explicitly link to parent
input=input_data,
model="claude-3-5-sonnet"
)
Pitfall 5: Langfuse Ingestion Rate Limit Causes Trace Loss During Nightly Batch Runs
Symptom: Daytime testing works perfectly. But during the 3AM daily batch workflow (100+ LLM calls), Langfuse dashboard only shows the first 20 traces; the rest are gone.
**Root Cause**: Langfuse v3 self-hosted's ingestion API has a default rate limit (100 req/min, controlled by INGESTION_RATE_LIMIT env var). The nightly batch fires 100+ requests simultaneously, exceeding the limit and getting dropped.
Diagnosis:
docker compose logs langfuse 2>&1 | grep -i "rate\|limit\|throttle"
# Output: WARN [ingestion] Rate limit exceeded for key: default, dropping 47 spans
Fix:
1. Option A: Increase Langfuse's rate limit (in docker-compose.yml):
langfuse:
environment:
- INGESTION_RATE_LIMIT=1000
- INGESTION_BURST_LIMIT=2000
2. Option B (recommended for production): Add rate limiting on the n8n side using Loop Over Items + Sleep node to control requests per second:
HTTP Request (call LLM) → Sleep (500ms) → Loop continue
3. Option C: Use Langfuse Cloud (native unlimited rate limit), or add a Redis queue buffer in front of the self-hosted Langfuse.
Hands-On: 5 Steps to a Debuggable n8n + Claude Code + Langfuse Workflow
Step 1: Configure Langfuse Connector in n8n (Python Script Node)
n8n's **Python Script node** (Community Node: @n8n/n8n-nodes-python) runs Langfuse SDK directly. Configure once at the workflow start, then subsequent nodes reuse the same client:
import os
import json
# Initialize Langfuse once per workflow
langfuse = Langfuse(
public_key=os.environ.get('LANGFUSE_PUBLIC_KEY'),
secret_key=os.environ.get('LANGFUSE_SECRET_KEY'),
host=os.environ.get('LANGFUSE_HOST', 'http://langfuse:3000'),
flush_at=1, # Flush on every event; increase in production
flush_interval=1, # Flush every 1 second
sdk_metadata={"n8n_version": "1.88"}
)
# Get current execution's trace
trace = langfuse.trace(
name="email-classification-pipeline",
metadata={
"execution_id": os.environ.get('N8N_EXECUTION_ID'),
"trigger": "schedule" # Scheduled trigger
}
)
return [{"json": {"trace_id": trace.id, "langfuse_initialized": True }}]
Step 2: Build the Classify → Extract → Generate Serial Chain
n8n workflow structure:
[Schedule Trigger: 9AM daily]
↓
[Set: Load pending email list]
↓
[Split In Batches: Process one by one]
↓
[Python Script: Classify + Langfuse generation]
↓
[IF: Classification = "technical"]
↓ ↓
[Branch A] [Branch B: non-technical]
[Python Script: Extract key info]
↓
[Python Script: Generate draft reply + Langfuse generation]
↓
[HTTP Request: Send email reply]
↓
[Loop continue]
Step 3: Debug This Workflow in Claude Code
Connect Claude Code to this n8n instance:
# In Claude Code, add n8n MCP
claude mcp add-json n8n-mcp
# Configure endpoint: http://your-n8n:5678/rest/mcp
# Add auth: Bearer token
Now debug with natural language:
Show me traces from yesterday's 9AM email-classification-pipeline run
where total_tokens exceeded 5000. I suspect there's token waste.
Claude Code queries n8n MCP for execution logs and combines with Langfuse span data to give analysis.
Step 4: Verify Trace Completeness
# Query Langfuse API for a specific execution's complete trace
curl -s "http://localhost:3000/api/public/traces?execution_id=N8N_EXECUTION_ID" \
-H "Authorization: Bearer $LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" | \
jq '.data[] | {name, id, observations: (.observations | length)}'
Expected output: each span's parent_id correctly points to its parent, forming a complete tree.
Step 5: Set Token Budget Alerts
In Langfuse dashboard, set soft budgets:
- Single trace `total_tokens` > 3000 → Slack alert
- Single day cumulative token consumption > 50000 → email alert
Summary
The n8n + Langfuse three-piece combo's core value: turns LLM calls from black box to transparent box. v1 solves survivability, v2 solves visibility.
Related pitfall reports:
- If you haven't gotten n8n + Langfuse running yet, start with n8n self-hosted: 5 critical production pitfalls
- Claude Code and n8n integration setup: Claude Code + n8n + Langfuse Three-Piece Combo
Next steps:
👉 Join MiniMax Token Plan: AI coding acceleration for businesses
👉 Join Zhipu Coding Plan: GLM-4.6/GLM-5 coding packages, China-stable, pay-per-token unlimited
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: