Current section
Files
Jump to
Current section
Files
docs/telemetry_migration_example.md
# Telemetry Migration Example
This document shows how to add telemetry instrumentation to existing ExLLM code.
## Example: Instrumenting the Main ExLLM Module
Here's how to add telemetry to the main `ExLLM.chat/3` function:
### Before (No Telemetry)
```elixir
defmodule ExLLM do
def chat(model_or_config, messages, opts \\ []) do
with {:ok, config} <- build_config(model_or_config, opts),
{:ok, adapter} <- get_adapter(config.provider),
{:ok, formatted_messages} <- format_messages(messages, adapter),
{:ok, response} <- call_with_retry(adapter, config, formatted_messages, opts) do
if Keyword.get(opts, :session) do
update_session(opts[:session], formatted_messages, response)
end
{:ok, response}
end
end
end
```
### After (With Telemetry)
```elixir
defmodule ExLLM do
import ExLLM.Telemetry.Instrumentation
def chat(model_or_config, messages, opts \\ []) do
# Extract metadata for telemetry
metadata = build_telemetry_metadata(model_or_config, opts)
instrument [:ex_llm, :chat, :call], metadata do
with {:ok, config} <- build_config(model_or_config, opts),
{:ok, adapter} <- get_adapter(config.provider),
{:ok, formatted_messages} <- format_messages(messages, adapter),
{:ok, response} <- call_with_retry(adapter, config, formatted_messages, opts) do
# Emit session telemetry if applicable
if session = Keyword.get(opts, :session) do
instrument_session_update(session, formatted_messages, response)
end
# The instrument macro will automatically add usage data to telemetry
{:ok, response}
end
end
end
defp build_telemetry_metadata(model_or_config, opts) do
%{
model: extract_model_name(model_or_config),
provider: extract_provider(model_or_config),
stream: Keyword.get(opts, :stream, false),
session: not is_nil(Keyword.get(opts, :session)),
circuit_breaker: Keyword.get(opts, :circuit_breaker, true)
}
end
defp instrument_session_update(session, messages, response) do
if usage = response[:usage] do
ExLLM.Telemetry.Instrumentation.instrument_session_add_message(
session.id,
List.last(messages),
usage.total_tokens
)
end
end
end
```
## Example: Instrumenting HTTP Client
### Before
```elixir
defmodule ExLLM.Adapters.Shared.HTTPClient do
def post_json(url, json_body, headers, opts \\ []) do
body = Jason.encode!(json_body)
headers = [
{"content-type", "application/json"},
{"accept", "application/json"} | headers
]
request_opts = build_request_opts(opts)
case HTTPoison.post(url, body, headers, request_opts) do
{:ok, %HTTPoison.Response{status_code: status, body: response_body}} when status in 200..299 ->
Jason.decode(response_body, keys: :atoms)
{:ok, %HTTPoison.Response{} = response} ->
handle_error_response(response)
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, {:http_error, reason}}
end
end
end
```
### After
```elixir
defmodule ExLLM.Adapters.Shared.HTTPClient do
import ExLLM.Telemetry.Instrumentation
def post_json(url, json_body, headers, opts \\ []) do
instrument_http :post, url, opts do
body = Jason.encode!(json_body)
headers = [
{"content-type", "application/json"},
{"accept", "application/json"} | headers
]
request_opts = build_request_opts(opts)
case HTTPoison.post(url, body, headers, request_opts) do
{:ok, %HTTPoison.Response{status_code: status, body: response_body} = response} when status in 200..299 ->
{:ok, %{status: status, body: response_body, response: Jason.decode!(response_body, keys: :atoms)}}
{:ok, %HTTPoison.Response{} = response} ->
handle_error_response(response)
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, {:http_error, reason}}
end
end
end
end
```
## Example: Instrumenting Provider Adapters
### Before
```elixir
defmodule ExLLM.Adapters.OpenAI do
def call(model, messages, opts) do
api_key = Keyword.get(opts, :api_key) || get_api_key()
request_body = build_request_body(model, messages, opts)
headers = build_headers(api_key)
case HTTPClient.post_json(@api_url, request_body, headers) do
{:ok, response} ->
format_response(response)
error ->
error
end
end
end
```
### After
```elixir
defmodule ExLLM.Adapters.OpenAI do
import ExLLM.Telemetry.Instrumentation
def call(model, messages, opts) do
instrument_provider :openai, model, opts do
api_key = Keyword.get(opts, :api_key) || get_api_key()
request_body = build_request_body(model, messages, opts)
headers = build_headers(api_key)
case HTTPClient.post_json(@api_url, request_body, headers) do
{:ok, response} ->
format_response(response)
error ->
error
end
end
end
end
```
## Example: Instrumenting Cache Operations
### Before
```elixir
defmodule ExLLM.Cache do
def get(key) do
case :ets.lookup(@table, key) do
[{^key, value, expiry}] ->
if DateTime.compare(DateTime.utc_now(), expiry) == :lt do
{:ok, value}
else
:ets.delete(@table, key)
:error
end
[] ->
:error
end
end
def put(key, value, ttl) do
expiry = DateTime.add(DateTime.utc_now(), ttl, :second)
:ets.insert(@table, {key, value, expiry})
:ok
end
end
```
### After
```elixir
defmodule ExLLM.Cache do
import ExLLM.Telemetry.Instrumentation
def get(key) do
instrument_cache_lookup key, fn ->
case :ets.lookup(@table, key) do
[{^key, value, expiry}] ->
if DateTime.compare(DateTime.utc_now(), expiry) == :lt do
{:ok, value}
else
:ets.delete(@table, key)
:telemetry.execute([:ex_llm, :cache, :ttl, :expired], %{}, %{key: key})
:error
end
[] ->
:error
end
end
end
def put(key, value, ttl) do
instrument_cache_store key, value, ttl, fn ->
expiry = DateTime.add(DateTime.utc_now(), ttl, :second)
:ets.insert(@table, {key, value, expiry})
:ok
end
end
end
```
## Example: Instrumenting Context Management
### Before
```elixir
defmodule ExLLM.Context do
def ensure_fits(messages, model_config, strategy) do
total_tokens = calculate_tokens(messages, model_config)
if total_tokens <= model_config.max_tokens do
{:ok, messages}
else
truncated = apply_truncation_strategy(messages, model_config, strategy)
{:ok, truncated}
end
end
end
```
### After
```elixir
defmodule ExLLM.Context do
import ExLLM.Telemetry.Instrumentation
def ensure_fits(messages, model_config, strategy) do
instrument [:ex_llm, :context, :truncation], %{strategy: strategy} do
total_tokens = calculate_tokens(messages, model_config)
if total_tokens <= model_config.max_tokens do
{:ok, messages}
else
# Emit window exceeded event
:telemetry.execute(
[:ex_llm, :context, :window, :exceeded],
%{},
%{
tokens: total_tokens,
max_tokens: model_config.max_tokens,
exceeded_by: total_tokens - model_config.max_tokens
}
)
messages_before = length(messages)
truncated = apply_truncation_strategy(messages, model_config, strategy)
messages_after = length(truncated)
tokens_after = calculate_tokens(truncated, model_config)
# Emit truncation metrics
instrument_context_truncation(
messages_before,
messages_after,
total_tokens - tokens_after
)
{:ok, truncated}
end
end
end
end
```
## Setting Up Telemetry Handlers
Add to your application startup:
```elixir
defmodule MyApp.Application do
def start(_type, _args) do
# Attach ExLLM telemetry handlers
ExLLM.Telemetry.attach_default_handlers()
# Attach custom handlers
:telemetry.attach_many(
"myapp-llm-handler",
[
[:ex_llm, :chat, :call, :stop],
[:ex_llm, :provider, :request, :stop],
[:ex_llm, :cost, :threshold, :exceeded]
],
&MyApp.Telemetry.handle_llm_event/4,
nil
)
# Start your supervision tree
Supervisor.start_link(children, opts)
end
end
```
## Custom Handler Example
```elixir
defmodule MyApp.Telemetry do
require Logger
def handle_llm_event([:ex_llm, :chat, :call, :stop], measurements, metadata, _config) do
# Log response times
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
Logger.info("LLM chat completed in #{duration_ms}ms for model #{metadata.model}")
# Send to monitoring system
StatsD.timing("llm.chat.duration", duration_ms, tags: ["model:#{metadata.model}"])
# Track token usage
if tokens = metadata[:tokens] do
StatsD.gauge("llm.tokens.used", tokens, tags: ["model:#{metadata.model}"])
end
end
def handle_llm_event([:ex_llm, :cost, :threshold, :exceeded], measurements, metadata, _config) do
# Alert on cost overruns
Logger.warn("LLM cost threshold exceeded! Cost: $#{measurements.cost / 100}")
# Could send alerts via email/Slack/PagerDuty
AlertManager.send_cost_alert(measurements.cost, metadata.threshold)
end
def handle_llm_event([:ex_llm, :provider, :request, :stop], measurements, metadata, _config) do
# Track provider-specific metrics
if metadata.success do
StatsD.increment("llm.provider.success", tags: ["provider:#{metadata.provider}"])
else
StatsD.increment("llm.provider.failure", tags: ["provider:#{metadata.provider}"])
end
end
end
```
## Dashboard Integration
Use the telemetry data for monitoring dashboards:
```elixir
defmodule MyApp.LLMDashboard do
def get_metrics do
# Get aggregated metrics
all_metrics = ExLLM.Telemetry.get_metrics()
# Get specific dashboard data
dashboard = ExLLM.Telemetry.dashboard_data(time_window: 3_600_000) # 1 hour
# Format for your dashboard tool (Grafana, DataDog, etc.)
%{
summary: %{
total_calls: dashboard.components.chat.total_calls,
avg_response_time: dashboard.components.chat.avg_duration_ms,
cache_hit_rate: dashboard.components.cache.hit_rate,
total_cost: dashboard.components.costs.total / 100 # Convert to dollars
},
providers: dashboard.components.providers,
alerts: dashboard.alerts
}
end
end
```
## Benefits of Telemetry
1. **Performance Monitoring**: Track response times, throughput, and bottlenecks
2. **Cost Management**: Monitor API costs and set alerts for budget overruns
3. **Error Tracking**: Identify error patterns and provider-specific issues
4. **Cache Efficiency**: Optimize cache configuration based on hit rates
5. **Capacity Planning**: Understand usage patterns and resource needs
6. **SLA Monitoring**: Track availability and response times against targets
7. **Debugging**: Detailed traces for troubleshooting issues