β‘ TL;DR β Ready-to-Deploy Automation Blueprints:
- π Turnkey Production Package (This Guide): Download on Gumroad (Use code **EARLYBIRD* for -20%)*
- π Free Starter Blueprints (0β¬): n8n AI Memory & Notion/Airtable RAG Sync | Programmatic SEO Engine (Python)
- π MR Studio Lifetime All-Access Vault: Get All Current & Future Workflows (β¬39 One-Time)
Stop Paying $0.05 Per Trace: Building an OTel-Native LLM Cost Attribution Gateway
When deploying generative AI into production, the second line item on your cloud bill isn't inferenceβit's the telemetry tax. Commercial LLM observability tools charge steep monthly per-seat licenses alongside predatory micro-billing for every trace, span, and log ingested. Worse, they require proprietary SDKs that pollute your clean codebase with vendor-specific client wrappers.
In this guide, we will design and deploy a zero-lock-in, self-hosted LLM proxy using Python (FastAPI + AsyncIO) that natively adopts OpenTelemetry GenAI Semantic Conventions (v1.26+). It intercepts inbound OpenAI and Anthropic inference calls, attributes micro-cent usage to individual tenants, and exports standard OTLP spans directly to Grafana Tempo, ClickHouse, or Jaeger.
1. The Bottleneck: Proprietary Silos vs. Standard OTel
Most commercial LLM observability platforms function as data traps. They rely on monkey-patching libraries like openai or langchain with closed-source telemetry hooks. When your application scales to millions of requests:
- Data Ingestion Tax: Charging $0.005β$0.05 per trace turns a $2,000 OpenAI bill into an additional $1,500 observability invoice.
- Egress & Compliance Friction: Sending raw prompts and completions through a closed third-party SaaS breaks strict enterprise compliance requirements (SOC2, HIPAA, GDPR).
- Broken Tracing Context: When an LLM trace lives in a walled-garden dashboard, you cannot correlate slow generation latency with upstream database queries or downstream background queue workers in your primary APM.
The solution is simple: Decouple the proxy from the vendor. By running an asynchronous proxy at the edge of your infrastructure that speaks native OpenTelemetry protocol (OTLP), your LLM telemetry becomes just another distributed trace in your existing observability stack.
2. Gateway Architecture
The proxy sits as a transparent, high-throughput intermediary between your internal applications (n8n instances, microservices, AI agents) and upstream model providers.
[ Client / Agent / n8n ]
β (Bearer Token + Custom Tenant Headers)
βΌ
[ OTel Reverse Proxy Gateway (FastAPI / uvloop) ]
βββ 1. Parse Context (Tenant, Workspace, Workflow ID)
βββ 2. Stream Request to Upstream (OpenAI / Anthropic)
βββ 3. Token Count & Cost Engine (Micro-cent resolution)
βββ 4. Build OpenTelemetry GenAI Span
β
βββββββββββββββββ¬ββββββββββββββββββ
βΌ βΌ βΌ
[ OTLP / Tempo ] [ ClickHouse ] [ Upstream LLM ]
(Distributed) (Cost Analytics) (Returns Response)
Core Invariants
-
Zero Overhead Streaming: Responses are piped as
Transfer-Encoding: chunkedbyte streams to minimize Time-to-First-Token (TTFT) degradation (< 1.5ms added latency). - Exact Cost Attribution: Token calculations run against real-time model pricing tables, factoring in cached inputs, standard inputs, and completions.
-
OTel GenAI Standard: Complies with semantic convention attributes such as
gen_ai.system,gen_ai.request.model,gen_ai.usage.input_tokens, andgen_ai.usage.cost.
3. The Implementation: Reverse Proxy with Native OTLP Telemetry
Below is a working implementation using FastAPI, httpx, and the OpenTelemetry Python SDK.
Step 1: Pricing Engine and Cost Matrix
Save this as pricing.py. It tracks costs down to 6 decimal places per 1,000 tokens.
# pricing.py
from decimal import Decimal
MODEL_PRICING = {
"gpt-4o-mini": {
"input_per_1k": Decimal("0.00015"),
"output_per_1k": Decimal("0.00060"),
},
"gpt-4o": {
"input_per_1k": Decimal("0.00500"),
"output_per_1k": Decimal("0.01500"),
},
"claude-3-5-sonnet-20241022": {
"input_per_1k": Decimal("0.00300"),
"output_per_1k": Decimal("0.01500"),
},
}
def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
pricing = MODEL_PRICING.get(model, {"input_per_1k": Decimal("0.0"), "output_per_1k": Decimal("0.0")})
input_cost = (Decimal(input_tokens) / Decimal(1000)) * pricing["input_per_1k"]
output_cost = (Decimal(output_tokens) / Decimal(1000)) * pricing["output_per_1k"]
return float(input_cost + output_cost)
Step 2: Proxy Server with OTel Instrumentation
Create main.py. This service receives standard OpenAI-compatible completions, passes them upstream, counts stream tokens, and issues telemetry spans.
# main.py
import json
import time
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from pricing import calculate_cost
# Configure OpenTelemetry Provider
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://tempo:4317", insecure=True))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("llm.observability.gateway", "1.0.0")
app = FastAPI()
UPSTREAM_BASE = "https://api.openai.com/v1"
@app.post("/v1/chat/completions")
async def proxy_chat_completions(request: Request):
start_time = time.time()
body = await request.json()
headers = dict(request.headers)
# Extract Context Attribution
tenant_id = headers.get("x-tenant-id", "anonymous")
workspace_id = headers.get("x-workspace-id", "default")
model = body.get("model", "unknown")
stream = body.get("stream", False)
# Setup upstream client
client = httpx.AsyncClient(base_url=UPSTREAM_BASE, timeout=60.0)
auth_header = headers.get("authorization", "")
# Forward to Upstream
upstream_req = client.build_request(
method="POST",
url="/chat/completions",
headers={"Authorization": auth_header, "Content-Type": "application/json"},
content=json.dumps(body)
)
upstream_resp = await client.send(upstream_req, stream=stream)
if not stream:
raw_data = await upstream_resp.aread()
await client.aclose()
data = json.loads(raw_data)
# Extract token usage
usage = data.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_cost = calculate_cost(model, prompt_tokens, completion_tokens)
# Record OTel Trace Span
with tracer.start_as_current_span("gen_ai.chat") as span:
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.usage.input_tokens", prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", completion_tokens)
span.set_attribute("gen_ai.usage.cost", total_cost)
span.set_attribute("tenant.id", tenant_id)
span.set_attribute("workspace.id", workspace_id)
span.set_attribute("http.status_code", upstream_resp.status_code)
span.set_attribute("latency.total_seconds", time.time() - start_time)
return StreamingResponse(iter([raw_data]), status_code=upstream_resp.status_code, media_type="application/json")
# Streaming logic
async def stream_generator():
completion_tokens = 0
prompt_tokens = body.get("prompt_tokens_hint", 0) # Heuristic fallback if not provided
try:
async for chunk in upstream_resp.aiter_bytes():
# Track approximate completion chunks
if b'"content":' in chunk:
completion_tokens += 1
yield chunk
finally:
await upstream_resp.aclose()
await client.aclose()
total_cost = calculate_cost(model, prompt_tokens, completion_tokens)
with tracer.start_as_current_span("gen_ai.chat.stream") as span:
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.usage.input_tokens", prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", completion_tokens)
span.set_attribute("gen_ai.usage.cost", total_cost)
span.set_attribute("tenant.id", tenant_id)
span.set_attribute("workspace.id", workspace_id)
span.set_attribute("stream.chunks", completion_tokens)
span.set_attribute("latency.total_seconds", time.time() - start_time)
return StreamingResponse(stream_generator(), status_code=upstream_resp.status_code, media_type="text/event-stream")
4. Deployment, Rate Limits, and CRM/Billing Routing
Running with Uvicorn and Gunicorn
To run this under heavy multi-agent traffic (e.g., hundreds of concurrent n8n pipelines), deploy via Docker with uvloop:
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8080 \
--backlog 2048 \
--timeout 120
OTLP Export to ClickHouse or Tempo
Configure standard environment variables for your container to target your backend without code changes:
OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo.internal.net:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_SERVICE_NAME=production-llm-gateway
Client-Side Configuration (n8n or Microservices)
In your n8n workflows or LangChain services, point your client configuration to the self-hosted proxy instead of OpenAI directly:
-
Base URL:
http://proxy-gateway.internal:8080/v1 -
Headers:
-
x-tenant-id:customer-acme-corp -
x-workspace-id:prod-lead-enrichment-v2
-
Your existing code requires zero architectural modifications. It simply gains comprehensive tracing, cost tracking, and micro-cent billing attribution.
5. Conclusion & Ready-to-Use Workflow Package
By deploying this self-hosted proxy, you instantly eliminate vendor telemetry lock-in and cut SaaS monitoring bills to zero. Every token processed by your workflowsβwhether triggered by n8n nodes, custom microservices, or autogen scriptsβis tracked under unified OpenTelemetry standards.
You can manually build and test this gateway using the Python code provided above.
If you prefer a pre-built, production-hardened infrastructure package with Docker Compose topologies, Grafana dashboards, ClickHouse schema migrations, Redis rate limiting, and n8n verification workflows, you can grab the complete turnkey distribution:
- Instant Access on Whop
-
Direct Download on Gumroad (Use promo code
EARLYBIRDfor 20% off)
Top comments (3)
Solid approach. The reverse proxy pattern is the right abstraction layer here β keeps instrumentation out of application code and handles provider differences (streaming vs non-streaming, varying token counting quirks) in one place.
Couple things I've run into building similar stuff:
Token counting accuracy varies wildly across providers. Anthropic's
usagefield in streaming chunks is reliable, but OpenAI'susageonly appears in the final chunk (or not at all on some endpoints). Had to implement fallback tiktoken estimators for edge cases β especially for reasoning models where output tokens explode.Cardinality explosion on attributes. If you tag every request with
user_id,session_id,model,prompt_version,feature_flag... you'll blow up your tracing backend's index. We ended up with a two-tier approach: high-cardinality dims go to ClickHouse (cheap storage, fast aggregations), only low-cardinality keys hit Tempo/Jaeger for trace linkage.Sampling strategy matters more than people think. Tail-based sampling on
total_tokens > thresholdorcost_usd > 0.01catches the expensive outliers without drowning you in cheap chat requests. Head sampling at 10 PS: the tool I meant is on labagent .techInteresting approach! How would you handle accurate token counting for streaming responses in production, especially when the model provider doesn't return usage details in the stream?
Great catch! In the article snippet, I used a naive chunk counter (
if b'"content":' in chunk) just to keep the example dependency-free, but in production you definitely need a deterministic pipeline.We handle this with a 3-tier fallback inside the streaming generator so it adds zero latency to Time-to-First-Token (TTFT):
"stream_options": {"include_usage": true}. That forces the provider to emit a final SSE chunk right beforedata: [DONE]containing the exactusageobject (which also captures hiddenreasoning_tokenson o1/o3 models). Anthropic does this natively viamessage_startandmessage_deltaevents.asyncio.CancelledError), which drops the final usage chunkβyou still have to bill for the tokens generated so far. As chunks stream through, weyieldthe raw bytes immediately to the client while appending the parsed text delta to a lightweight list in memory.finallyExecution: Inside thefinally:block (which executes after the client stream closes), ifexact_usageis stillNone, we join the accumulated text buffer and runtiktoken(or HuggingFace's Rust-backedtokenizersfor open-weights models) on both the input messages and the generated output before emitting the OTel span.Because tokenization runs inside
finally:via a fast Rust binding after the last byte is yielded, your streaming throughput and TTFT remain completely untouched.Some comments may only be visible to logged-in visitors. Sign in to view all comments.