If you’re building anything beyond a toy with LangChain, you need visibility into token consumption — not just for billing, but for debugging prompt bloat, catching runaway loops, and optimizing model selection. The built-in get_openai_callback works for simple cases, but it falls apart with streaming, non-OpenAI providers, or when you need structured data piped to your observability stack. This guide walks through building a langchain custom callback handler token usage tracker that handles streaming, multiple providers, and emits structured events you can actually use.
Step 1: understand what LangChain callbacks actually give you
LangChain’s callback system fires events at specific lifecycle points: on_llm_start, on_llm_new_token (for streaming), on_llm_end, and on_llm_error. The BaseCallbackHandler class defines all these hooks. Most tutorials stop at printing tokens to stdout. We need to capture usage metadata — prompt tokens, completion tokens, total tokens, model name, and provider — and associate it with a request ID for correlation.
Key insight: token counts only arrive in on_llm_end via the LLMResult object’s llm_output field. For streaming, you get tokens one at a time in on_llm_new_token but no usage metadata until the end. Your handler must buffer state across the request lifecycle.
# callbacks/token_tracker.py
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.outputs import LLMResult
@dataclass
class TokenUsage:
"""Structured token usage for a single LLM call."""
request_id: str
model_name: str
provider: str
prompt_tokens: int
completion_tokens: int
total_tokens: int
latency_ms: int
timestamp: float = field(default_factory=time.time)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"model_name": self.model_name,
"provider": self.provider,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"total_tokens": self.total_tokens,
"latency_ms": self.latency_ms,
"timestamp": self.timestamp,
"metadata": self.metadata,
}
Step 2: build the handler with streaming support
The handler needs to track when a request starts, accumulate streaming tokens if needed, and extract usage on completion. Different providers stuff usage data in different places — OpenAI uses llm_output["token_usage"], Anthropic puts it in generation_info, and some local models return nothing at all. Handle all three.
# callbacks/token_tracker.py (continued)
class TokenUsageCallbackHandler(BaseCallbackHandler):
"""
Callback handler that captures token usage per LLM call.
Emits TokenUsage events via an async callback or stores them
in an internal buffer for batch retrieval.
"""
def __init__(
self,
on_usage: Optional[callable] = None,
capture_streaming_tokens: bool = True,
):
self.on_usage = on_usage
self.capture_streaming_tokens = capture_streaming_tokens
self._pending: Dict[str, Dict[str, Any]] = {}
self._completed: List[TokenUsage] = []
self._streaming_token_counts: Dict[str, int] = {}
def _extract_model_info(self, serialized: Dict[str, Any], kwargs: Dict[str, Any]) -> tuple[str, str]:
"""Extract model name and provider from serialized LLM or invocation kwargs."""
# Try serialized first (LangChain's representation)
if serialized:
kwargs_from_serialized = serialized.get("kwargs", {})
model_name = kwargs_from_serialized.get("model_name") or kwargs_from_serialized.get("model")
if model_name:
provider = self._infer_provider(model_name, serialized.get("id", []))
return model_name, provider
# Fall back to invocation kwargs
model_name = kwargs.get("model_name") or kwargs.get("model") or "unknown"
provider = self._infer_provider(model_name, [])
return model_name, provider
def _infer_provider(self, model_name: str, serialized_id: List[str]) -> str:
"""Infer provider from model name or serialized class path."""
model_lower = model_name.lower()
id_str = ".".join(serialized_id).lower()
if "gpt" in model_lower or "openai" in id_str:
return "openai"
if "claude" in model_lower or "anthropic" in id_str:
return "anthropic"
if "gemini" in model_lower or "google" in id_str:
return "google"
if "llama" in model_lower or "mistral" in model_lower or "local" in id_str:
return "local"
return "unknown"
def _extract_usage(self, response: LLMResult, model_name: str, provider: str) -> Optional[Dict[str, int]]:
"""Extract token usage from LLMResult across different providers."""
# OpenAI / OpenAI-compatible (n4n.ai, etc.)
for generation in response.generations:
for gen in generation:
if gen.generation_info and "token_usage" in gen.generation_info:
usage = gen.generation_info["token_usage"]
return {
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
}
# Check llm_output (OpenAI legacy format)
if response.llm_output and "token_usage" in response.llm_output:
usage = response.llm_output["token_usage"]
return {
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
}
# Anthropic: usage in generation_info per generation
for generation in response.generations:
for gen in generation:
if gen.generation_info:
usage = gen.generation_info.get("usage") or gen.generation_info.get("token_usage")
if usage:
return {
"prompt_tokens": usage.get("input_tokens", usage.get("prompt_tokens", 0)),
"completion_tokens": usage.get("output_tokens", usage.get("completion_tokens", 0)),
"total_tokens": usage.get("total_tokens", usage.get("input_tokens", 0) + usage.get("output_tokens", 0)),
}
# Fallback: estimate from streaming token count if we captured it
request_id = getattr(response, "request_id", None)
if request_id and request_id in self._streaming_token_counts:
completion = self._streaming_token_counts[request_id]
return {
"prompt_tokens": 0, # unknown
"completion_tokens": completion,
"total_tokens": completion,
}
return None
def on_llm_start(
self,
serialized: Dict[str, Any],
prompts: List[str],
*,
run_id: uuid.UUID,
parent_run_id: Optional[uuid.UUID] = None,
tags: Optional[List[str]] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> None:
request_id = str(run_id)
model_name, provider = self._extract_model_info(serialized, kwargs)
self._pending[request_id] = {
"model_name": model_name,
"provider": provider,
"start_time": time.time(),
"prompts": prompts,
"metadata": metadata or {},
"tags": tags or [],
}
if self.capture_streaming_tokens:
self._streaming_token_counts[request_id] = 0
def on_llm_new_token(
self,
token: str,
*,
run_id: uuid.UUID,
parent_run_id: Optional[uuid.UUID] = None,
**kwargs: Any,
) -> None:
if self.capture_streaming_tokens:
request_id = str(run_id)
if request_id in self._streaming_token_counts:
self._streaming_token_counts[request_id] += 1
def on_llm_end(
self,
response: LLMResult,
*,
run_id: uuid.UUID,
parent_run_id: Optional[uuid.UUID] = None,
**kwargs: Any,
) -> None:
request_id = str(run_id)
pending = self._pending.pop(request_id, None)
if not pending:
return
latency_ms = int((time.time() - pending["start_time"]) * 1000)
usage = self._extract_usage(response, pending["model_name"], pending["provider"])
if usage is None:
# No usage data available — log warning but don't crash
usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
token_usage = TokenUsage(
request_id=request_id,
model_name=pending["model_name"],
provider=pending["provider"],
prompt_tokens=usage["prompt_tokens"],
completion_tokens=usage["completion_tokens"],
total_tokens=usage["total_tokens"],
latency_ms=latency_ms,
metadata={
**pending["metadata"],
"tags": pending["tags"],
"prompt_count": len(pending["prompts"]),
},
)
self._completed.append(token_usage)
if self.on_usage:
self.on_usage(token_usage)
# Clean up streaming counter
self._streaming_token_counts.pop(request_id, None)
def on_llm_error(
self,
error: BaseException,
*,
run_id: uuid.UUID,
parent_run_id: Optional[uuid.UUID] = None,
**kwargs: Any,
) -> None:
request_id = str(run_id)
pending = self._pending.pop(request_id, None)
self._streaming_token_counts.pop(request_id, None)
if pending and self.on_usage:
# Emit error event with zero usage for tracking
self.on_usage(TokenUsage(
request_id=request_id,
model_name=pending["model_name"],
provider=pending["provider"],
prompt_tokens=0,
completion_tokens=0,
total_tokens=0,
latency_ms=int((time.time() - pending["start_time"]) * 1000),
metadata={**pending["metadata"], "error": str(error)},
))
def get_usage(self) -> List[TokenUsage]:
"""Return all completed usage records and clear the buffer."""
usage = self._completed.copy()
self._completed.clear()
return usage
def reset(self) -> None:
"""Clear all pending and completed records."""
self._pending.clear()
self._completed.clear()
self._streaming_token_counts.clear()
Step 3: wire it into your chain or agent
You can attach the handler at three levels: per-invocation (via config), per-chain (via callbacks constructor arg), or globally (via LangChainTracer style). Per-invocation gives you the most control for request-scoped correlation IDs.
# main.py
import asyncio
import json
from uuid import uuid4
from langchain_core.callbacks import AsyncCallbackManager
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
from callbacks.token_tracker import TokenUsageCallbackHandler, TokenUsage
async def usage_logger(usage: TokenUsage) -> None:
"""Structured log emission — replace with your observability sink."""
print(json.dumps(usage.to_dict()))
async def main():
# Create handler with async emission callback
handler = TokenUsageCallbackHandler(on_usage=usage_logger)
# Model that supports streaming and returns usage
llm = ChatOpenAI(
model="gpt-4o-mini",
streaming=True,
temperature=0,
)
# Invoke with handler in config — this scopes it to this request
request_id = str(uuid4())
response = await llm.ainvoke(
[HumanMessage(content="Explain callback handlers in 50 words")],
config={
"callbacks": [handler],
"run_name": "token-tracking-demo",
"metadata": {"request_id": request_id, "user_id": "user-123"},
},
)
print(f"Response: {response.content}")
# Also works with streaming via astream
print("\n--- Streaming example ---")
handler.reset()
async for chunk in llm.astream(
[HumanMessage(content="Count to 10")],
config={"callbacks": [handler]},
):
print(chunk.content, end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
Run it and verify you see structured JSON output for each call:
$ python main.py
{"request_id": "a1b2c3d4...", "model_name": "gpt-4o-mini", "provider": "openai", "prompt_tokens": 23, "completion_tokens": 47, "total_tokens": 70, "latency_ms": 1234, "timestamp": 1700000000.123, "metadata": {"request_id": "a1b2c3d4...", "user_id": "user-123", "tags": [], "prompt_count": 1}}
Response: Callback handlers in LangChain intercept lifecycle events...
Step 4: add cost calculation per provider
Token counts are useless without cost context. Add a pricing registry that maps (provider, model) to per-1k-token rates. Keep it configurable — prices change, and you may have enterprise discounts.
# callbacks/pricing.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass(frozen=True)
class ModelPricing:
prompt_per_1k: float
completion_per_1k: float
currency: str = "USD"
# Pricing as of 2024 — update regularly or fetch from provider API
DEFAULT_PRICING: Dict[str, Dict[str, ModelPricing]] = {
"openai": {
"gpt-4o": ModelPricing(2.50, 10.00),
"gpt-4o-mini": ModelPricing(0.15, 0.60),
"gpt-4-turbo": ModelPricing(10.00, 30.00),
"gpt-3.5-turbo": ModelPricing(0.50, 1.50),
},
"anthropic": {
"claude-3-opus": ModelPricing(15.00, 75.00),
"claude-3-sonnet": ModelPricing(3.00, 15.00),
"claude-3-haiku": ModelPricing(0.25, 1.25),
},
"google": {
"gemini-1.5-pro": ModelPricing(1.25, 5.00),
"gemini-1.5-flash": ModelPricing(0.075, 0.30),
},
}
class PricingCalculator:
"""Calculates cost from TokenUsage using configurable pricing."""
def __init__(self, pricing: Optional[Dict[str, Dict[str, ModelPricing]]] = None):
self.pricing = pricing or DEFAULT_PRICING
def calculate_cost(self, usage) -> Optional[float]:
"""Return cost in currency units, or None if pricing unknown."""
provider_pricing = self.pricing.get(usage.provider)
if not provider_pricing:
return None
model_pricing = provider_pricing.get(usage.model_name)
if not model_pricing:
# Try fuzzy match for versioned model names
for key, pricing in provider_pricing.items():
if key in usage.model_name or usage.model_name in key:
model_pricing = pricing
break
if not model_pricing:
return None
prompt_cost = (usage.prompt_tokens / 1000) * model_pricing.prompt_per_1k
completion_cost = (usage.completion_tokens / 1000) * model_pricing.completion_per_1k
return round(prompt_cost + completion_cost, 6)
def enrich_usage(self, usage) -> dict:
"""Return usage dict with cost fields added."""
cost = self.calculate_cost(usage)
data = usage.to_dict()
data["cost"] = cost
data["currency"] = "USD" if cost is not None else None
return data
Update the handler to use it:
# callbacks/token_tracker.py (add to TokenUsageCallbackHandler.__init__)
from callbacks.pricing import PricingCalculator
class TokenUsageCallbackHandler(BaseCallbackHandler):
def __init__(
self,
on_usage: Optional[callable] = None,
capture_streaming_tokens: bool = True,
pricing_calculator: Optional[PricingCalculator] = None,
):
# ... existing init ...
self.pricing = pricing_calculator or PricingCalculator()
# ... in on_llm_end, after creating token_usage ...
enriched = self.pricing.enrich_usage(token_usage)
if self.on_usage:
self.on_usage(enriched) # Pass enriched dict instead of TokenUsage
Step 5: handle batch and async workloads correctly
If you’re processing hundreds of requests, you don’t want a callback per request creating memory pressure. Use a shared handler instance with request-scoped context via run_id. The handler already uses run_id as the key — just ensure your async task grouping doesn’t leak contexts.
# batch_example.py
import asyncio
from langchain_core.callbacks import AsyncCallbackManager
from langchain_openai import ChatOpenAI
from callbacks.token_tracker import TokenUsageCallbackHandler, TokenUsage
from callbacks.pricing import PricingCalculator
async def process_batch(prompts: list[str]) -> list[dict]:
"""Process multiple prompts concurrently with shared handler."""
handler = TokenUsageCallbackHandler(pricing_calculator=PricingCalculator())
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
async def single(prompt: str, idx: int) -> dict:
response = await llm.ainvoke(
[HumanMessage(content=prompt)],
config={"callbacks": [handler], "metadata": {"batch_idx": idx}},
)
# Usage was emitted via callback; pull from handler buffer
usage_records = handler.get_usage()
return {"prompt": prompt, "response": response.content, "usage": usage_records}
results = await asyncio.gather(*[single(p, i) for i, p in enumerate(prompts)])
return results
async def main():
prompts = [
"What is a callback handler?",
"Explain token streaming",
"How does LangChain manage memory?",
]
results = await process_batch(prompts)
total_tokens = 0
total_cost = 0.0
for r in results:
for u in r["usage"]:
total_tokens += u["total_tokens"]
total_cost += u.get("cost", 0)
print(f"[{r['prompt'][:30]}...] tokens={u['total_tokens']} cost=${u.get('cost', 0):.6f}")
print(f"\nBatch total: {total_tokens} tokens, ${total_cost:.6f}")
if __name__ == "__main__":
asyncio.run(main())
Verify success: each request emits exactly one usage record, totals match provider dashboard, and no records leak between concurrent requests.
Step 6: integrate with your observability stack
Structured logs are only useful if they reach your pipeline. Here’s how to emit to common sinks without blocking the LLM call.
# callbacks/sinks.py
import asyncio
import json
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict
from dataclasses import asdict
from callbacks.token_tracker import TokenUsage
class UsageSink(ABC):
@abstractmethod
def emit(self, usage: Dict[str, Any]) -> None:
pass
@abstractmethod
async def aemit(self, usage: Dict[str, Any]) -> None:
pass
class StdoutSink(UsageSink):
def emit(self, usage: Dict[str, Any]) -> None:
print(json.dumps(usage))
async def aemit(self, usage: Dict[str, Any]) -> None:
self.emit(usage)
class LoggingSink(UsageSink):
def __init__(self, logger_name: str = "llm.usage", level: int = logging.INFO):
self.logger = logging.getLogger(logger_name)
self.level = level
def emit(self, usage: Dict[str, Any]) -> None:
self.logger.log(self.level, "llm_usage", extra=usage)
async def aemit(self, usage: Dict[str, Any]) -> None:
self.emit(usage)
class BufferedAsyncSink(UsageSink):
"""Buffers usage events and flushes periodically — non-blocking."""
def __init__(
self,
flush_interval: float = 5.0,
max_buffer: int = 1000,
forward_to: Optional[UsageSink] = None,
):
self.buffer: asyncio.Queue = asyncio.Queue(maxsize=max_buffer)
self.flush_interval = flush_interval
self.forward_to = forward_to or StdoutSink()
self._task: Optional[asyncio.Task] = None
async def start(self) -> None:
self._task = asyncio.create_task(self._flush_loop())
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
await self._flush()
def emit(self, usage: Dict[str, Any]) -> None:
try:
self.buffer.put_nowait(usage)
except asyncio.QueueFull:
# Drop or handle backpressure
pass
async def aemit(self, usage: Dict[str, Any]) -> None:
await self.buffer.put(usage)
async def _flush_loop(self) -> None:
while True:
await asyncio.sleep(self.flush_interval)
await self._flush()
async def _flush(self) -> None:
batch = []
while not self.buffer.empty():
try:
batch.append(self.buffer.get_nowait())
except asyncio.QueueEmpty:
break
if batch and self.forward_to:
for usage in batch:
await self.forward_to.aemit(usage)
Wire it up:
# main_with_sink.py
import asyncio
import logging
from callbacks.token_tracker import TokenUsageCallbackHandler
from callbacks.sinks import BufferedAsyncSink, LoggingSink
logging.basicConfig(level=logging.INFO, format="%(message)s")
async def main():
sink = BufferedAsyncSink(forward_to=LoggingSink())
await sink.start()
handler = TokenUsageCallbackHandler(on_usage=sink.aemit)
# ... your LLM calls here ...
await sink.stop()
if __name__ == "__main__":
asyncio.run(main())
Step 7: test edge cases that bite in production
Write tests for the failure modes you’ll actually hit: missing usage data, provider switches mid-chain, and streaming without final usage.
# tests/test_token_tracker.py
import pytest
from unittest.mock import Mock, patch
from uuid import uuid4
from langchain_core.outputs import LLMResult, Generation, ChatGeneration
from langchain_core.messages import AIMessage
from callbacks.token_tracker import TokenUsageCallbackHandler, TokenUsage
class TestTokenUsageCallbackHandler:
@pytest.fixture
def handler(self):
return TokenUsageCallbackHandler(capture_streaming_tokens=True)
@pytest.fixture
def run_id(self):
return uuid4()
def test_openai_usage_extraction(self, handler, run_id):
"""OpenAI-compatible responses have token_usage in generation_info."""
response = LLMResult(
generations=[[ChatGeneration(
message=AIMessage(content="Hello"),
generation_info={"token_usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
)]],
llm_output={},
)
handler.on_llm_start(
serialized={"kwargs": {"model_name": "gpt-4o-mini"}},
prompts=["Hi"],
run_id=run_id,
)
handler.on_llm_end(response, run_id=run_id)
usage = handler.get_usage()[0]
assert usage.prompt_tokens == 10
assert usage.completion_tokens == 5
assert usage.total_tokens == 15
assert usage.provider == "openai"
def test_anthropic_usage_extraction(self, handler, run_id):
"""Anthropic puts usage in generation_info.usage with input/output tokens."""
response = LLMResult(
generations=[[ChatGeneration(
message=AIMessage(content="Hello"),
generation_info={"usage": {"input_tokens": 12, "output_tokens": 8}}
)]],
llm_output={},
)
handler.on_llm_start(
serialized={"kwargs": {"model": "claude-3-haiku"}},
prompts=["Hi"],
run_id=run_id,
)
handler.on_llm_end(response, run_id=run_id)
usage = handler.get_usage()[0]
assert usage.prompt_tokens == 12
assert usage.completion_tokens == 8
assert usage.total_tokens == 20
assert usage.provider == "anthropic"
def test_streaming_token_fallback(self, handler, run_id):
"""When provider returns no usage, fall back to streaming token count."""
response = LLMResult(
generations=[[ChatGeneration(message=AIMessage(content="Hello world"))]],
llm_output={},
)
handler.on_llm_start(
serialized={"kwargs": {"model_name": "local-llama"}},
prompts=["Hi"],
run_id=run_id,
)
# Simulate streaming tokens
for _ in range(7):
handler.on_llm_new_token("token", run_id=run_id)
handler.on_llm_end(response, run_id=run_id)
usage = handler.get_usage()[0]
assert usage.completion_tokens == 7
assert usage.total_tokens == 7
assert usage.prompt_tokens == 0 # unknown
def test_error_emits_zero_usage(self, handler, run_id):
"""Errors still emit a usage record for observability."""
handler.on_llm_start(
serialized={"kwargs": {"model_name": "gpt-4o"}},
prompts=["Hi"],
run_id=run_id,
)
handler.on_llm_error(ValueError("rate limited"), run_id=run_id)
usage = handler.get_usage()[0]
assert usage.total_tokens == 0
assert "rate limited" in usage.metadata.get("error", "")
def test_concurrent_requests_isolated(self, handler):
"""Two concurrent requests don't share token counts."""
run_id_1 = uuid4()
run_id_2 = uuid4()
handler.on_llm_start({"kwargs": {"model_name": "gpt-4o"}}, ["p1"], run_id=run_id_1)
handler.on_llm_start({"kwargs": {"model_name": "gpt-4o"}}, ["p2"], run_id=run_id_2)
handler.on_llm_new_token("t", run_id=run_id_1)
handler.on_llm_new_token("t", run_id=run_id_1)
handler.on_llm_new_token("t", run_id=run_id_2)
response = LLMResult(generations=[[ChatGeneration(message=AIMessage(content="ok"))]], llm_output={})
handler.on_llm_end(response, run_id=run_id_1)
handler.on_llm_end(response, run_id=run_id_2)
usages = handler.get_usage()
assert len(usages) == 2
counts = {u.request_id: u.completion_tokens for u in usages}
assert counts[str(run_id_1)] == 2
assert counts[str(run_id_2)] == 1
Run with pytest tests/test_token_tracker.py -v. All tests should pass.
Step 8: deploy and monitor
You now have a production-grade langchain custom callback handler token usage tracker. Deploy it by adding the handler to your chain/agent callbacks config. Monitor these signals:
- Usage records per request — should be exactly 1 for non-streaming, 1 for streaming (not per token)
- Zero-token records — indicates provider not returning usage; check
_extract_usagelogic - Cost drift — compare calculated cost against provider billing weekly; update
DEFAULT_PRICING - Latency p99 — track
latency_msper model/provider to detect degradation
The handler is framework-agnostic beyond LangChain’s callback interface. It works with any OpenAI-compatible endpoint, including gateways that route across 240+ models with automatic fallback — the usage shape stays consistent because the provider normalizes the response format before it reaches your callback.
Verification checklist:
- Single invocation emits one
TokenUsagewith correct token counts - Streaming invocation emits one
TokenUsageafter stream completes - Cost calculation matches provider dashboard within rounding
- Concurrent requests produce isolated usage records
- Errors emit zero-usage records with error metadata
- Structured logs appear in your observability backend
- No memory growth over 10k+ requests (handler buffers are bounded)