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

EventWhen 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

EventWhen emitted
[:phoenix_ai_store, :message, :add]add_message/3
[:phoenix_ai_store, :message, :get]get_messages/2

Memory Operations

EventWhen emitted
[:phoenix_ai_store, :memory, :apply]apply_memory/3

Guardrail Operations

EventWhen emitted
[:phoenix_ai_store, :guardrails, :check]check_guardrails/3

Cost Operations

EventWhen 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

EventWhen 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

EventWhen 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

EventWhen 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 response

When 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
end

Options:

  • :name (required) — GenServer name
  • :interval — check interval in milliseconds (default: 30_000)
  • :handler_opts — options forwarded to TelemetryHandler.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:

TypeLogged when
:conversation_createdsave_conversation/2 creates a new conversation
:message_sentadd_message/3 persists a new message
:memory_trimmedapply_memory/3 reduces the message list
:policy_violationcheck_guardrails/3 halts with a violation
:cost_recordedrecord_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 with inserted_at >= dt
  • :before — include events with inserted_at <= dt
  • :limit — page size
  • :cursor — opaque cursor from a previous list_events/2 call

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 with recorded_at >= dt
  • :before — include records with recorded_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}
end

Configure 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