PhoenixAI.Store emits telemetry spans for every operation and provides an append-only event log for auditing AI conversations.
Telemetry Events
All events follow the [:phoenix_ai_store, :action, :start | :stop | :exception]
span convention from :telemetry.span/3.
Conversation Operations
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :conversation, :save] | save_conversation/2 |
[:phoenix_ai_store, :conversation, :load] | load_conversation/2 |
[:phoenix_ai_store, :conversation, :list] | list_conversations/2 |
[:phoenix_ai_store, :conversation, :delete] | delete_conversation/2 |
[:phoenix_ai_store, :conversation, :count] | count_conversations/2 |
[:phoenix_ai_store, :conversation, :exists] | conversation_exists?/2 |
Message Operations
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :message, :add] | add_message/3 |
[:phoenix_ai_store, :message, :get] | get_messages/2 |
Memory Operations
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :memory, :apply] | apply_memory/3 |
Guardrail Operations
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :guardrails, :check] | check_guardrails/3 |
Cost Operations
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :cost, :record] | record_cost/3 |
[:phoenix_ai_store, :cost, :get_records] | get_cost_records/2 |
[:phoenix_ai_store, :cost, :sum] | sum_cost/2 |
[:phoenix_ai_store, :cost, :recorded] | Inside CostTracking.record/3 after a successful persist |
Event Log Operations
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :event, :log] | Inside EventLog.log/3 |
[:phoenix_ai_store, :event, :log_event] | log_event/2 |
[:phoenix_ai_store, :event, :list] | list_events/2 |
[:phoenix_ai_store, :event, :count] | count_events/2 |
Long-Term Memory Operations
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :fact, :save] | save_fact/2 |
[:phoenix_ai_store, :fact, :get] | get_facts/2 |
[:phoenix_ai_store, :fact, :delete] | delete_fact/3 |
[:phoenix_ai_store, :extract_facts] | extract_facts/2 |
[:phoenix_ai_store, :profile, :save] | save_profile/2 |
[:phoenix_ai_store, :profile, :get] | get_profile/2 |
[:phoenix_ai_store, :profile, :delete] | delete_profile/2 |
[:phoenix_ai_store, :profile, :update] | update_profile/2 |
Top-Level Converse Span
| Event | When emitted |
|---|---|
[:phoenix_ai_store, :converse] | converse/3 — wraps the entire turn |
Attaching Custom Handlers
:telemetry.attach(
"my-app-store-handler",
[:phoenix_ai_store, :converse, :stop],
fn event, measurements, metadata, _config ->
Logger.info("Converse completed in #{measurements.duration}ns")
end,
nil
)All :stop events include a :duration measurement (in native time units). Use
System.convert_time_unit(measurements.duration, :native, :millisecond) to convert.
TelemetryHandler
PhoenixAI.Store.TelemetryHandler listens to PhoenixAI's upstream telemetry events
([:phoenix_ai, :chat, :stop] and [:phoenix_ai, :tool_call, :stop]) and
asynchronously records cost and logs events through the Store.
This is the automatic integration mode: attach the handler once and every AI.chat/2
call made within a conversation context is automatically tracked.
Attaching and Detaching
# Attach (idempotent — safe to call multiple times)
PhoenixAI.Store.TelemetryHandler.attach()
# Detach
PhoenixAI.Store.TelemetryHandler.detach()Context Propagation via Logger Metadata
The handler reads Logger.metadata()[:phoenix_ai_store] to attribute events to the
correct conversation. Set this metadata before calling AI.chat/2:
Logger.metadata(phoenix_ai_store: %{
conversation_id: conv.id,
user_id: "user-123",
store: :my_store
})
{:ok, response} = AI.chat(messages, provider: :openai, model: "gpt-4o")
# TelemetryHandler sees the chat:stop event and records cost + logs the responseWhen using Store.converse/3, context is set automatically — you do not need to set
Logger metadata manually.
HandlerGuardian
PhoenixAI.Store.HandlerGuardian is a supervised GenServer that ensures the telemetry
handler stays attached across node events, hot code reloads, and crashes.
On init it calls TelemetryHandler.attach/1. It then polls at a configurable interval
and reattaches the handler if it has been detached.
Supervision Tree Setup
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyApp.Repo,
{PhoenixAI.Store,
name: :my_store,
adapter: PhoenixAI.Store.Adapters.Ecto,
repo: MyApp.Repo},
# Keep the telemetry handler alive
{PhoenixAI.Store.HandlerGuardian,
name: :my_store_guardian,
interval: 30_000} # check every 30 seconds (default)
]
Supervisor.start_link(children, strategy: :one_for_one)
end
endOptions:
:name(required) — GenServer name:interval— check interval in milliseconds (default:30_000):handler_opts— options forwarded toTelemetryHandler.attach/1(default:[])
Store.track/1 — Explicit Event Capture
Store.track/1 records a custom event through the event log using a plain map:
PhoenixAI.Store.track(%{
type: :user_feedback,
data: %{rating: 5, comment: "Very helpful!"},
conversation_id: conv.id,
user_id: "user-123",
store: :my_store
})Required keys:
:type— event type atom
Optional keys:
:data— event data map (default:%{}):conversation_id— conversation to associate the event with:user_id— user to associate the event with:store— store instance name (default::phoenix_ai_store_default)
Event Log
The event log is an append-only audit trail. Events are never updated or deleted, even when their associated conversation is deleted. This makes it suitable for compliance, debugging, and cost attribution.
Built-in Event Types
The Store automatically logs these events when the event log is enabled:
| Type | Logged when |
|---|---|
:conversation_created | save_conversation/2 creates a new conversation |
:message_sent | add_message/3 persists a new message |
:memory_trimmed | apply_memory/3 reduces the message list |
:policy_violation | check_guardrails/3 halts with a violation |
:cost_recorded | record_cost/3 persists a cost record |
Enabling the Event Log
{PhoenixAI.Store,
name: :my_store,
adapter: PhoenixAI.Store.Adapters.Ecto,
repo: MyApp.Repo,
event_log: [
enabled: true,
redact_fn: nil # optional (Event.t() -> Event.t())
]}Requires the events migration:
mix phoenix_ai_store.gen.migration --events
mix ecto.migrate
Logging Custom Events with log_event/2
Build an %Event{} and log it directly:
alias PhoenixAI.Store
alias PhoenixAI.Store.EventLog.Event
event = %Event{
type: :agent_handoff,
data: %{from_agent: "triage", to_agent: "billing"},
conversation_id: conv.id,
user_id: "user-123"
}
{:ok, logged_event} = Store.log_event(event, store: :my_store)Listing Events
Store.list_events/2 returns a cursor-paginated result:
{:ok, %{events: events, next_cursor: cursor}} =
Store.list_events([conversation_id: conv.id, limit: 25], store: :my_store)
# Fetch the next page
{:ok, %{events: more_events, next_cursor: next}} =
Store.list_events([conversation_id: conv.id, limit: 25, cursor: cursor], store: :my_store)Supported filters:
:conversation_id— filter by conversation:user_id— filter by user:type— filter by event type atom:after— include events withinserted_at >= dt:before— include events withinserted_at <= dt:limit— page size:cursor— opaque cursor from a previouslist_events/2call
When next_cursor is nil, you are on the last page.
Counting Events
{:ok, count} =
Store.count_events([user_id: "user-123", type: :policy_violation], store: :my_store)Redaction
Provide a :redact_fn to strip or mask sensitive data before persistence:
defmodule MyApp.EventRedactor do
def redact(%PhoenixAI.Store.EventLog.Event{type: :message_sent} = event) do
%{event | data: Map.delete(event.data, :content)}
end
def redact(event), do: event
end
{PhoenixAI.Store,
name: :my_store,
adapter: PhoenixAI.Store.Adapters.Ecto,
repo: MyApp.Repo,
event_log: [
enabled: true,
redact_fn: &MyApp.EventRedactor.redact/1
]}The redact_fn is called inside EventLog.log/3 before the event is handed to the
adapter. The redacted form is what gets persisted.
Cost Tracking
Store.record_cost/3 records the cost of a single AI provider call. It resolves
pricing via a pricing provider module, computes costs with exact Decimal arithmetic,
and persists a %CostRecord{} through the adapter.
Basic Usage
{:ok, response} = AI.chat(messages, provider: :openai, model: "gpt-4o")
{:ok, cost_record} =
PhoenixAI.Store.record_cost(conv.id, response,
store: :my_store,
user_id: "user-123"
)
IO.puts("Cost: $#{Decimal.to_string(cost_record.total_cost)}")record_cost/3 requires response.usage to be a normalized %PhoenixAI.Usage{}
struct. PhoenixAI normalizes usage automatically as of version 0.2.
Enabling Cost Tracking
{PhoenixAI.Store,
name: :my_store,
adapter: PhoenixAI.Store.Adapters.Ecto,
repo: MyApp.Repo,
cost_tracking: [
enabled: true,
pricing_provider: PhoenixAI.Store.CostTracking.PricingProvider.Static
]}Requires the cost migration:
mix phoenix_ai_store.gen.migration --cost
mix ecto.migrate
When cost_tracking: [enabled: true], Store.converse/3 calls record_cost/3
automatically after each successful AI response.
Querying Costs
# All cost records for a conversation
{:ok, records} = Store.get_cost_records(conv.id, store: :my_store)
# Aggregate cost by user this month
{:ok, total} =
Store.sum_cost(
[user_id: "user-123", after: ~U[2026-04-01 00:00:00Z]],
store: :my_store
)
IO.puts("Monthly spend: $#{Decimal.to_string(total)}")Filters supported by sum_cost/2:
:user_id— filter by user:conversation_id— filter by conversation:provider— filter by provider atom (e.g.:openai):model— filter by model string (e.g."gpt-4o"):after— include records withrecorded_at >= dt:before— include records withrecorded_at <= dt
Pricing Providers
The default pricing provider is PhoenixAI.Store.CostTracking.PricingProvider.Static,
which has a built-in table of per-token prices for common models.
Implement the PricingProvider behaviour to use custom pricing:
defmodule MyApp.CustomPricing do
@behaviour PhoenixAI.Store.CostTracking.PricingProvider
@impl true
def price_for(:openai, "gpt-4o") do
# Returns {input_price_per_token, output_price_per_token} as Decimal
{:ok, {Decimal.new("0.000005"), Decimal.new("0.000015")}}
end
def price_for(_provider, _model), do: {:error, :unknown_model}
endConfigure the custom provider:
{PhoenixAI.Store,
name: :my_store,
cost_tracking: [
enabled: true,
pricing_provider: MyApp.CustomPricing
]}Or override per-call:
{:ok, record} =
Store.record_cost(conv.id, response,
store: :my_store,
pricing_provider: MyApp.CustomPricing
)See Also
- Getting Started — initial setup
- Adapters — which adapters support the event log and cost store
- Memory & Guardrails — policy violations appear in the event log