Track the chat lifecycle and token usage in PostHog

3ad2bc0cd754 · Devin AI · · parent 2fd7e1edb2b0

Track the chat lifecycle and token usage in PostHog

Chat analytics stopped at a sent message and a completed turn, so nobody
could see what a reader received, what a turn cost, or how often turns
failed. This adds the rest of the lifecycle behind one vocabulary module,
OpenAgents.Analytics.Chat, so every surface names an event the same way
and no surface reaches for the capture boundary directly.

Assistant deliveries, queued messages, stream chunks, tool calls, turn
failures, and voice sessions are captured from the surfaces that already
know those transitions: ChatLive for the reader's session, TurnServer for
durable typed turns, and AccountTurns for account runs. Stream chunks are
throttled to about one event per second and carry no content; tool events
carry a name but never arguments.

Token counts are captured once per turn from each terminal path, after the
turn's totals are settled, so tool rounds and a Chat Completions fallback
report one set of counts rather than one per round. The admin analytics
page gains a chat lifecycle card with assistant deliveries, queue depth,
turn failure rate, and token totals by model and provider.

Closes #102

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#102

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/2026-08-21-posthog-integration-runbook.md
  • added lib/openagents/analytics/chat.ex
  • modified lib/openagents/chat/account_turns.ex
  • modified lib/openagents/posthog.ex
  • modified lib/openagents/turns/turn_server.ex
  • modified lib/openagents_web/live/admin_analytics_live.ex
  • modified lib/openagents_web/live/chat_live.ex
  • added test/openagents/analytics/chat_test.exs
  • modified test/openagents/posthog_test.exs
  • modified test/openagents_web/live/admin_analytics_live_test.exs

Diff

10 files changed, +729 -11

docs/2026-08-21-posthog-integration-runbook.md modified +7

@@ -223,6 +223,13 @@ Chat and delegated work:

223 223
| `chat_opened` | `ChatLive.mount` on connected mount | none |
224 224
| `chat_message_sent` | `ChatLive.launch_turn` | `length_bucket` |
225 225
| `chat_turn_completed` | terminal `turn_updated` broadcast in `ChatLive` | `outcome`: `completed`, `failed`, `cancelled`; `duration_ms` from turn timestamps |
226
| `chat_message_queued` | `ChatLive` queues a message behind an active turn | `length_bucket`, `queue_depth`, `conversation_id` |
227
| `chat_message_received` | complete assistant message in `ChatLive`; completed run in `Chat.AccountTurns` | `length_bucket`, `modality`, `conversation_id` |
228
| `chat_stream_chunk` | streaming assistant deltas, throttled to one event per second per stream | `conversation_id`, `modality` |
229
| `chat_tool_called` | `Turns.TurnServer` tool request; `tool_call_started` in `Chat.AccountTurns` | `tool_name`, `turn_id`, `conversation_id`; never arguments |
230
| `chat_tokens_used` | once per turn at terminal state in `Turns.TurnServer` and `Chat.AccountTurns` | `input_tokens`, `output_tokens`, `model`, `provider`, `conversation_id`, `turn_id`, `outcome` |
231
| `chat_turn_failed` | non-completed terminal turn in `ChatLive`; failed or cancelled run in `Chat.AccountTurns` | `reason`, `outcome`, `conversation_id`, `turn_id` |
232
| `chat_voice_started` / `chat_voice_ended` | voice session lifecycle broadcasts in `ChatLive` | `conversation_id`; end adds `outcome`, `duration_ms` |
226 233
| `memory_saved` | `ProfileMemory.remember_explicit` | `disposition`: `stored`, `already_active` |
227 234
| `memory_viewed` | `MemoryLive.mount` on connected mount | none |
228 235
| `computer_paired` | `ComputersController.approve_pairing` success | `tier` |
lib/openagents/analytics/chat.ex added +133

@@ -0,0 +1,133 @@

1
defmodule OpenAgents.Analytics.Chat do
2
  @moduledoc """
3
  The chat lifecycle event vocabulary.
4
5
  Chat instrumentation spans a LiveView, a turn server, and the OpenRouter
6
  provider path, so the event names, the property names, and the token
7
  normalization live here instead of being spelled out at each call site. Every
8
  function returns `:ok` through `OpenAgents.Analytics.capture/3`, which drops
9
  sensitive keys and never fails its caller.
10
11
  Token counts are provider-reported totals for a whole turn. `tokens_used/3`
12
  is the only place a turn reports them, so a turn that ran several provider
13
  rounds or fell back to another API still produces one event.
14
  """
15
16
  alias OpenAgents.Analytics
17
18
  @stream_chunk_interval_ms 1_000
19
20
  @doc "A user message that waited behind an active turn."
21
  @spec message_queued(Analytics.distinct_id(), map()) :: :ok
22
  def message_queued(distinct_id, properties \\ %{}),
23
    do: Analytics.capture("chat_message_queued", distinct_id, properties)
24
25
  @doc "An assistant message that reached the user."
26
  @spec message_received(Analytics.distinct_id(), map()) :: :ok
27
  def message_received(distinct_id, properties \\ %{}),
28
    do: Analytics.capture("chat_message_received", distinct_id, properties)
29
30
  @doc """
31
  The provider-reported token counts for one turn.
32
33
  Accepts either naming the two OpenRouter APIs use (`input_tokens` and
34
  `output_tokens`, or `prompt_tokens` and `completion_tokens`). A usage map
35
  without any readable count captures nothing, because a missing count is not
36
  a zero.
37
  """
38
  @spec tokens_used(Analytics.distinct_id(), map() | nil, map()) :: :ok
39
  def tokens_used(distinct_id, usage, properties \\ %{})
40
41
  def tokens_used(distinct_id, usage, properties) when is_map(usage) do
42
    case token_counts(usage) do
43
      nil -> :ok
44
      counts -> Analytics.capture("chat_tokens_used", distinct_id, Map.merge(properties, counts))
45
    end
46
  end
47
48
  def tokens_used(_distinct_id, _usage, _properties), do: :ok
49
50
  @doc "A turn that ended anywhere other than completed."
51
  @spec turn_failed(Analytics.distinct_id(), map()) :: :ok
52
  def turn_failed(distinct_id, properties \\ %{}),
53
    do: Analytics.capture("chat_turn_failed", distinct_id, properties)
54
55
  @doc """
56
  One assistant stream chunk, rate-limited to one event per second.
57
58
  Callers hold the throttle: pass the value the previous call returned and keep
59
  the returned value. `nil` means nothing has been captured for this turn yet.
60
  """
61
  @spec stream_chunk(Analytics.distinct_id(), integer() | nil, map()) :: integer() | nil
62
  def stream_chunk(distinct_id, last_captured_at, properties \\ %{}) do
63
    now = System.monotonic_time(:millisecond)
64
65
    if throttled?(last_captured_at, now) do
66
      last_captured_at
67
    else
68
      Analytics.capture("chat_stream_chunk", distinct_id, properties)
69
      now
70
    end
71
  end
72
73
  @doc "A tool the assistant invoked during a turn."
74
  @spec tool_called(Analytics.distinct_id(), map()) :: :ok
75
  def tool_called(distinct_id, properties \\ %{}),
76
    do: Analytics.capture("chat_tool_called", distinct_id, properties)
77
78
  @doc "A voice session that began accepting audio."
79
  @spec voice_started(Analytics.distinct_id(), map()) :: :ok
80
  def voice_started(distinct_id, properties \\ %{}),
81
    do: Analytics.capture("chat_voice_started", distinct_id, properties)
82
83
  @doc "A voice session that stopped, with its duration when both ends are known."
84
  @spec voice_ended(Analytics.distinct_id(), map()) :: :ok
85
  def voice_ended(distinct_id, properties \\ %{}),
86
    do: Analytics.capture("chat_voice_ended", distinct_id, properties)
87
88
  @doc """
89
  The coarse size bucket for one message.
90
91
  Message text never enters an event property, so the bucket is what a reader
92
  gets: it separates a one-line ask from a pasted document without recording
93
  either.
94
  """
95
  @spec length_bucket(String.t()) :: String.t()
96
  def length_bucket(content) when is_binary(content) do
97
    cond do
98
      byte_size(content) < 100 -> "under_100"
99
      byte_size(content) < 1_000 -> "under_1k"
100
      byte_size(content) < 8_000 -> "under_8k"
101
      true -> "over_8k"
102
    end
103
  end
104
105
  defp throttled?(nil, _now), do: false
106
107
  defp throttled?(last_captured_at, now) when is_integer(last_captured_at),
108
    do: now - last_captured_at < @stream_chunk_interval_ms
109
110
  defp throttled?(_last_captured_at, _now), do: false
111
112
  defp token_counts(usage) do
113
    counts =
114
      %{
115
        "input_tokens" => count(usage, ["input_tokens", "prompt_tokens"]),
116
        "output_tokens" => count(usage, ["output_tokens", "completion_tokens"]),
117
        "total_tokens" => count(usage, ["total_tokens"])
118
      }
119
      |> Enum.reject(fn {_key, value} -> is_nil(value) end)
120
      |> Map.new()
121
122
    if counts == %{}, do: nil, else: counts
123
  end
124
125
  defp count(usage, keys) do
126
    Enum.find_value(keys, fn key ->
127
      case Map.get(usage, key) do
128
        value when is_integer(value) and value >= 0 -> value
129
        _other -> nil
130
      end
131
    end)
132
  end
133
end
lib/openagents/chat/account_turns.ex modified +82

@@ -3,6 +3,8 @@ defmodule OpenAgents.Chat.AccountTurns do

3 3
4 4
  import Ecto.Query
5 5
  alias OpenAgents.Accounts.User
6
  alias OpenAgents.Analytics
7
  alias OpenAgents.Analytics.Chat, as: ChatAnalytics
6 8
  alias OpenAgents.Chat.{AccountEvent, AccountRun, OpenRouter}
7 9
  alias OpenAgents.Conversations
8 10
  alias OpenAgents.Repo

@@ -34,6 +36,7 @@ defmodule OpenAgents.Chat.AccountTurns do

34 36
    else
35 37
      {:provider_start_failed, run, reason} ->
36 38
        finish_run(run.id, {:error, :turn_start_failed})
39
        capture_run_outcome(run, user, {:error, :turn_start_failed})
37 40
        {:error, reason}
38 41
39 42
      error ->

@@ -54,6 +57,14 @@ defmodule OpenAgents.Chat.AccountTurns do

54 57
    with %{id: conversation_id} <- Conversations.get_conversation_for_user(user),
55 58
         %AccountRun{} = run <- streaming_run(conversation_id) do
56 59
      stop_provider_task(run.id)
60
61
      ChatAnalytics.turn_failed(Analytics.distinct_id(user), %{
62
        "reason" => "cancelled",
63
        "outcome" => "cancelled",
64
        "conversation_id" => conversation_id,
65
        "turn_id" => run.id
66
      })
67
57 68
      cancel_run(run.id)
58 69
    else
59 70
      _no_active_turn -> {:error, :no_active_turn}

@@ -251,6 +262,7 @@ defmodule OpenAgents.Chat.AccountTurns do

251 262
                 request,
252 263
                 fn event ->
253 264
                   persist_provider_event(run.id, event)
265
                   capture_stream_event(run, user, event)
254 266
                   notify(subscriber, {:openrouter_stream_event, run.id, event})
255 267
                 end,
256 268
                 tool_context: %{

@@ -267,6 +279,7 @@ defmodule OpenAgents.Chat.AccountTurns do

267 279
             end
268 280
269 281
           finish_run(run.id, result)
282
           capture_run_outcome(run, user, result)
270 283
           notify(subscriber, {:account_chat_completed, run.id, result})
271 284
         end) do
272 285
      {:ok, pid} -> {:ok, pid}

@@ -288,6 +301,75 @@ defmodule OpenAgents.Chat.AccountTurns do

288 301
  defp persist_provider_event(run_id, {kind, payload}),
289 302
    do: append_event(run_id, Atom.to_string(kind), normalize_payload(payload))
290 303
304
  # One process owns one run's stream, so the chunk throttle lives in that
305
  # process rather than in a shared counter. Tool starts are already one event
306
  # per call and need no throttle.
307
  defp capture_stream_event(run, user, {:text_delta, _delta}) do
308
    captured_at =
309
      ChatAnalytics.stream_chunk(
310
        Analytics.distinct_id(user),
311
        Process.get(:chat_stream_chunk_captured_at),
312
        %{"conversation_id" => run.conversation_id, "turn_id" => run.id, "modality" => "text"}
313
      )
314
315
    Process.put(:chat_stream_chunk_captured_at, captured_at)
316
    :ok
317
  end
318
319
  defp capture_stream_event(run, user, {:tool_call_started, payload}) when is_map(payload) do
320
    ChatAnalytics.tool_called(Analytics.distinct_id(user), %{
321
      "tool_name" => payload["name"] || "tool",
322
      "turn_id" => run.id,
323
      "conversation_id" => run.conversation_id,
324
      "modality" => "text"
325
    })
326
  end
327
328
  defp capture_stream_event(_run, _user, _event), do: :ok
329
330
  # The completion carries the provider's own totals for the whole run,
331
  # including any Chat Completions fallback, so this is the one place a run
332
  # reports tokens.
333
  defp capture_run_outcome(run, user, {:ok, completion}) when is_map(completion) do
334
    distinct_id = Analytics.distinct_id(user)
335
336
    identity = %{
337
      "conversation_id" => run.conversation_id,
338
      "turn_id" => run.id,
339
      "modality" => "text"
340
    }
341
342
    ChatAnalytics.message_received(
343
      distinct_id,
344
      Map.put(
345
        identity,
346
        "length_bucket",
347
        ChatAnalytics.length_bucket(completion["assistant_content"] || "")
348
      )
349
    )
350
351
    ChatAnalytics.tokens_used(
352
      distinct_id,
353
      completion["usage"],
354
      Map.merge(identity, %{
355
        "model" => completion["model"],
356
        "provider" => completion["provider"],
357
        "outcome" => "completed"
358
      })
359
    )
360
  end
361
362
  defp capture_run_outcome(run, user, {:error, reason}) do
363
    ChatAnalytics.turn_failed(Analytics.distinct_id(user), %{
364
      "reason" => error_code(reason),
365
      "outcome" => "failed",
366
      "conversation_id" => run.conversation_id,
367
      "turn_id" => run.id
368
    })
369
  end
370
371
  defp capture_run_outcome(_run, _user, _result), do: :ok
372
291 373
  defp finish_run(run_id, {:ok, completion}) do
292 374
    # Token counts are read before redaction, which blanks every field whose
293 375
    # name contains `token`, and are stored beside the redacted completion.
lib/openagents/posthog.ex modified +74 -1

@@ -39,7 +39,7 @@ defmodule OpenAgents.PostHog do

39 39
  @doc """
40 40
  Everything the operator analytics page shows.
41 41
42
  Returns `{:ok, shaped}` with six bounded projections, or
42
  Returns `{:ok, shaped}` with eight bounded projections, or
43 43
  `{:error, :not_configured | :unavailable}`. Each projection runs as its own
44 44
  HogQL query; a failure of any one fails the whole pull, because partial
45 45
  numbers presented next to each other read as complete.

@@ -50,6 +50,8 @@ defmodule OpenAgents.PostHog do

50 50
      with {:ok, events} <- run(event_counts_sql(), "event_counts"),
51 51
           {:ok, funnel} <- run(funnel_sql(), "funnel"),
52 52
           {:ok, chat} <- run(chat_turns_sql(), "chat_turns"),
53
           {:ok, lifecycle} <- run(chat_lifecycle_sql(), "chat_lifecycle"),
54
           {:ok, tokens} <- run(chat_token_usage_sql(), "chat_token_usage"),
53 55
           {:ok, pages} <- run(top_pages_sql(), "top_pages"),
54 56
           {:ok, triage} <- run(triage_health_sql(), "triage_health"),
55 57
           {:ok, issue_flow} <- run(weekly_issue_flow_sql(), "weekly_issue_flow") do

@@ -59,6 +61,8 @@ defmodule OpenAgents.PostHog do

59 61
           event_counts: shape_rows(events),
60 62
           funnel: shape_rows(funnel) |> List.first(%{}),
61 63
           chat_turns: shape_chat_turns(shape_rows(chat) |> List.first(%{})),
64
           chat_lifecycle: shape_chat_lifecycle(shape_rows(lifecycle) |> List.first(%{})),
65
           chat_token_usage: shape_chat_token_usage(shape_rows(tokens)),
62 66
           top_pages: shape_rows(pages),
63 67
           triage_health: shape_triage_health(shape_rows(triage) |> List.first(%{})),
64 68
           weekly_issue_flow: shape_rows(issue_flow)

@@ -112,6 +116,52 @@ defmodule OpenAgents.PostHog do

112 116
    |> squash()
113 117
  end
114 118
119
  # Assistant deliveries and turn failures in one row, so a failure rate reads
120
  # against the turns that actually ran rather than against a separate pull.
121
  defp chat_lifecycle_sql do
122
    """
123
    SELECT
124
      countIf(event = 'chat_message_received') AS messages_received,
125
      countIf(event = 'chat_message_queued') AS messages_queued,
126
      countIf(event = 'chat_turn_failed') AS turns_failed,
127
      countIf(event = 'chat_turn_completed') AS turns_finished,
128
      if(
129
        turns_finished = 0,
130
        0,
131
        round(turns_failed * 100.0 / turns_finished, 1)
132
      ) AS turn_failure_percent
133
    FROM events
134
    WHERE timestamp >= now() - INTERVAL 1 DAY
135
      AND event IN (
136
        'chat_message_received',
137
        'chat_message_queued',
138
        'chat_turn_failed',
139
        'chat_turn_completed'
140
      )
141
    """
142
    |> squash()
143
  end
144
145
  # Token totals per model and provider. `chat_tokens_used` is captured once per
146
  # turn, so summing it here counts each turn's tokens once.
147
  defp chat_token_usage_sql do
148
    """
149
    SELECT
150
      properties.model AS model,
151
      properties.provider AS provider,
152
      count() AS turns,
153
      sum(properties.input_tokens) AS input_tokens,
154
      sum(properties.output_tokens) AS output_tokens,
155
      sum(properties.input_tokens) + sum(properties.output_tokens) AS total_tokens
156
    FROM events
157
    WHERE timestamp >= now() - INTERVAL 1 DAY AND event = 'chat_tokens_used'
158
    GROUP BY model, provider
159
    ORDER BY total_tokens DESC
160
    LIMIT 10
161
    """
162
    |> squash()
163
  end
164
115 165
  defp top_pages_sql do
116 166
    """
117 167
    SELECT properties.$current_url AS url, count() AS views

@@ -230,6 +280,29 @@ defmodule OpenAgents.PostHog do

230 280
    }
231 281
  end
232 282
283
  defp shape_chat_lifecycle(row) do
284
    %{
285
      "messages_received" => count_value(row["messages_received"]),
286
      "messages_queued" => count_value(row["messages_queued"]),
287
      "turns_failed" => count_value(row["turns_failed"]),
288
      "turns_finished" => count_value(row["turns_finished"]),
289
      "turn_failure_percent" => numeric_value(row["turn_failure_percent"])
290
    }
291
  end
292
293
  defp shape_chat_token_usage(rows) do
294
    Enum.map(rows, fn row ->
295
      %{
296
        "model" => row["model"] || "unknown",
297
        "provider" => row["provider"] || "unknown",
298
        "turns" => count_value(row["turns"]),
299
        "input_tokens" => count_value(row["input_tokens"]),
300
        "output_tokens" => count_value(row["output_tokens"]),
301
        "total_tokens" => count_value(row["total_tokens"])
302
      }
303
    end)
304
  end
305
233 306
  defp shape_triage_health(row) do
234 307
    %{
235 308
      "median_first_maintainer_response_hours" =>
lib/openagents/turns/turn_server.ex modified +38

@@ -4,6 +4,7 @@ defmodule OpenAgents.Turns.TurnServer do

4 4
  use GenServer, restart: :temporary
5 5
6 6
  alias OpenAgents.{
7
    Analytics,
7 8
    Blueprint,
8 9
    Context.Composer,
9 10
    Conversations,

@@ -18,6 +19,7 @@ defmodule OpenAgents.Turns.TurnServer do

18 19
    ShadowPrograms
19 20
  }
20 21
22
  alias OpenAgents.Analytics.Chat, as: ChatAnalytics
21 23
  alias OpenAgents.Providers.{ProviderEvent, Request, ToolOutput}
22 24
  alias OpenAgents.Tools.{ConversationExecutionContext, Registry, Runner}
23 25

@@ -180,6 +182,7 @@ defmodule OpenAgents.Turns.TurnServer do

180 182
    :atomics.put(state.cancellation, 1, 1)
181 183
    cancel_task(state.task)
182 184
    _timer_result = Process.cancel_timer(state.timeout_reference)
185
    capture_tokens_used(state, total_usage(state), "cancelled")
183 186
    result = Conversations.cancel_turn(state.turn, total_usage(state))
184 187
    {:stop, :normal, result, state}
185 188
  end

@@ -313,6 +316,8 @@ defmodule OpenAgents.Turns.TurnServer do

313 316
             state.receipt,
314 317
             Map.put(attributes, :routing_receipt_id, routing_receipt.id)
315 318
           ) do
319
      capture_tool_called(state, call)
320
316 321
      {:noreply,
317 322
       %{
318 323
         state

@@ -330,6 +335,36 @@ defmodule OpenAgents.Turns.TurnServer do

330 335
    end
331 336
  end
332 337
338
  # Token totals are reported from the turn's terminal path, which every turn
339
  # reaches exactly once. Tool rounds and report continuations have already been
340
  # merged into the total by then, so a turn that called several tools still
341
  # reports one set of counts.
342
  defp capture_tokens_used(state, usage, outcome) do
343
    ChatAnalytics.tokens_used(owner_distinct_id(state.owner), usage, %{
344
      "model" => state.base_request.model_id,
345
      "provider" => state.provider.id(),
346
      "conversation_id" => state.turn.conversation_id,
347
      "turn_id" => state.turn.id,
348
      "outcome" => outcome,
349
      "modality" => "text"
350
    })
351
  end
352
353
  defp capture_tool_called(state, call) do
354
    ChatAnalytics.tool_called(owner_distinct_id(state.owner), %{
355
      "tool_name" => call.name,
356
      "turn_id" => state.turn.id,
357
      "conversation_id" => state.turn.conversation_id,
358
      "modality" => "text"
359
    })
360
  end
361
362
  defp owner_distinct_id(%{user_id: user_id}) when is_binary(user_id),
363
    do: Analytics.distinct_id(user_id)
364
365
  defp owner_distinct_id(%{id: id}) when is_binary(id),
366
    do: Analytics.distinct_id("visitor_#{id}")
367
333 368
  defp route_tool_call(nil, state) do
334 369
    Router.route(state.tool_snapshot, state.routing_policy, %{
335 370
      intent_digest: state.receipt.input_digest,

@@ -386,6 +421,7 @@ defmodule OpenAgents.Turns.TurnServer do

386 421
    do: stop_failed(state, reason)
387 422
388 423
  defp finalize_provider_result(:ok, %{terminal_event: :cancelled} = state) do
424
    capture_tokens_used(state, total_usage(state), "cancelled")
389 425
    _cancel_result = Conversations.cancel_turn(state.turn, total_usage(state))
390 426
    {:stop, :normal, state}
391 427
  end

@@ -399,6 +435,7 @@ defmodule OpenAgents.Turns.TurnServer do

399 435
  defp finalize_text_response(response_id, state) do
400 436
    case Conversations.complete_turn(state.turn, response_id, state.usage) do
401 437
      {:ok, _turn} ->
438
        capture_tokens_used(state, state.usage, "completed")
402 439
        _timer_result = Process.cancel_timer(state.timeout_reference)
403 440
        {:stop, :normal, state}
404 441

@@ -609,6 +646,7 @@ defmodule OpenAgents.Turns.TurnServer do

609 646
    :atomics.put(state.cancellation, 1, 1)
610 647
    cancel_task(state.task)
611 648
    _timer_result = Process.cancel_timer(state.timeout_reference)
649
    capture_tokens_used(state, total_usage(state), "failed")
612 650
    _failure_result = Conversations.fail_turn(state.turn, reason, total_usage(state))
613 651
    _incident = report_incident(state, reason)
614 652
    {:stop, :normal, state}
lib/openagents_web/live/admin_analytics_live.ex modified +54

@@ -221,6 +221,57 @@ defmodule OpenAgentsWeb.AdminAnalyticsLive do

221 221
              </.card>
222 222
            </section>
223 223
224
            <section aria-labelledby="lifecycle-heading">
225
              <.card id="analytics-chat-lifecycle">
226
                <div class="space-y-1">
227
                  <h2 id="lifecycle-heading" class="card-title">Chat lifecycle and tokens</h2>
228
                  <p class="text-muted-foreground">
229
                    Assistant deliveries, queued messages, and turn failures over the trailing
230
                    twenty-four hours. Token totals are provider-reported and counted once per turn.
231
                  </p>
232
                </div>
233
                <% lifecycle = @overview.chat_lifecycle %>
234
                <div class="grid gap-4 py-5 md:grid-cols-3">
235
                  <div class="rounded-md border border-border p-4">
236
                    <p class="text-sm text-muted-foreground">Assistant messages received</p>
237
                    <p id="lifecycle-messages-received" class="mt-2 text-2xl font-semibold">
238
                      {lifecycle["messages_received"]}
239
                    </p>
240
                    <p class="mt-1 text-sm text-muted-foreground">
241
                      {lifecycle["messages_queued"]} messages waited behind an active turn.
242
                    </p>
243
                  </div>
244
                  <div class="rounded-md border border-border p-4">
245
                    <p class="text-sm text-muted-foreground">Turn failure rate</p>
246
                    <p id="lifecycle-failure-rate" class="mt-2 text-2xl font-semibold">
247
                      {format_percent(lifecycle["turn_failure_percent"])}
248
                    </p>
249
                    <p class="mt-1 text-sm text-muted-foreground">
250
                      {lifecycle["turns_failed"]} of {lifecycle["turns_finished"]} finished turns
251
                      ended in failure or cancellation.
252
                    </p>
253
                  </div>
254
                  <div class="rounded-md border border-border p-4">
255
                    <p class="text-sm text-muted-foreground">Tokens used</p>
256
                    <p id="lifecycle-total-tokens" class="mt-2 text-2xl font-semibold">
257
                      {total_tokens(@overview.chat_token_usage)}
258
                    </p>
259
                    <p class="mt-1 text-sm text-muted-foreground">
260
                      Input plus output across every model.
261
                    </p>
262
                  </div>
263
                </div>
264
                <.table id="analytics-chat-tokens-table" rows={@overview.chat_token_usage}>
265
                  <:col :let={row} label="Model">{row["model"]}</:col>
266
                  <:col :let={row} label="Provider">{row["provider"]}</:col>
267
                  <:col :let={row} label="Turns">{row["turns"]}</:col>
268
                  <:col :let={row} label="Input">{row["input_tokens"]}</:col>
269
                  <:col :let={row} label="Output">{row["output_tokens"]}</:col>
270
                  <:col :let={row} label="Total">{row["total_tokens"]}</:col>
271
                </.table>
272
              </.card>
273
            </section>
274
224 275
            <section aria-labelledby="events-heading">
225 276
              <.card id="analytics-event-volume">
226 277
                <h2 id="events-heading" class="card-title">Event volume</h2>

@@ -256,6 +307,9 @@ defmodule OpenAgentsWeb.AdminAnalyticsLive do

256 307
257 308
  defp format_duration(ms) when is_integer(ms), do: "#{ms}ms"
258 309
310
  defp total_tokens(rows) when is_list(rows),
311
    do: Enum.reduce(rows, 0, fn row, total -> total + (row["total_tokens"] || 0) end)
312
259 313
  defp format_hours(nil), do: "No data"
260 314
  defp format_hours(hours) when is_integer(hours), do: "#{hours}h"
261 315
lib/openagents_web/live/chat_live.ex modified +104 -5

@@ -11,6 +11,7 @@ defmodule OpenAgentsWeb.ChatLive do

11 11
    VoiceSessions
12 12
  }
13 13
14
  alias OpenAgents.Analytics.Chat, as: ChatAnalytics
14 15
  alias OpenAgents.ComputerActivity
15 16
  alias OpenAgents.Conversations.Message
16 17
  alias OpenAgents.Voice.Config, as: VoiceConfig

@@ -96,6 +97,7 @@ defmodule OpenAgentsWeb.ChatLive do

96 97
      |> assign(:oldest_message_id, first_id(messages))
97 98
      |> assign(:active_turn, active_turn)
98 99
      |> assign(:message_queue, [])
100
      |> assign(:stream_chunk_captured_at, nil)
99 101
      |> assign(:voice_enabled?, voice_config.enabled?)
100 102
      |> assign(:recording_config, Recordings.config())
101 103
      |> assign(:voice_session, voice_session)

@@ -193,6 +195,7 @@ defmodule OpenAgentsWeb.ChatLive do

193 195
  def handle_info({:message_updated, message}, socket) do
194 196
    {:noreply,
195 197
     socket
198
     |> capture_assistant_message(message)
196 199
     |> clear_live_voice_item(message.provider_item_id)
197 200
     |> refresh_job_rollup(message)
198 201
     |> stream_insert(:messages, message)}

@@ -220,6 +223,7 @@ defmodule OpenAgentsWeb.ChatLive do

220 223
  def handle_info({:turn_updated, turn}, socket) do
221 224
    if turn.status in ["completed", "failed", "cancelled"] do
222 225
      capture_turn_completed(turn, socket)
226
      capture_turn_failed(turn, socket)
223 227
224 228
      # The active turn ended: clear it, surface any error, and immediately start
225 229
      # the next queued message so a stacked run continues without the owner

@@ -228,6 +232,7 @@ defmodule OpenAgentsWeb.ChatLive do

228 232
      |> assign(:active_turn, nil)
229 233
      |> assign(:tool_activity, [])
230 234
      |> assign(:composer_error, turn.error_message)
235
      |> assign(:stream_chunk_captured_at, nil)
231 236
      |> push_event("composer:focus", %{})
232 237
      |> advance_queue()
233 238
    else

@@ -281,6 +286,8 @@ defmodule OpenAgentsWeb.ChatLive do

281 286
        do: clear_all_live_voice_items(socket),
282 287
        else: socket
283 288
289
    capture_voice_lifecycle(socket, voice_session)
290
284 291
    {:noreply,
285 292
     socket
286 293
     |> assign(:voice_session, voice_session)

@@ -481,6 +488,15 @@ defmodule OpenAgentsWeb.ChatLive do

481 488
      true ->
482 489
        item = %{id: System.unique_integer([:positive, :monotonic]), content: trimmed}
483 490
491
        ChatAnalytics.message_queued(
492
          Analytics.distinct_id(socket.assigns.current_user),
493
          %{
494
            "length_bucket" => length_bucket(trimmed),
495
            "queue_depth" => length(socket.assigns.message_queue) + 1,
496
            "conversation_id" => socket.assigns.conversation.id
497
          }
498
        )
499
484 500
        {:noreply,
485 501
         socket
486 502
         |> assign(:message_queue, socket.assigns.message_queue ++ [item])

@@ -582,15 +598,98 @@ defmodule OpenAgentsWeb.ChatLive do

582 598
    )
583 599
  end
584 600
585
  defp length_bucket(content) when is_binary(content) do
601
  # A failed or cancelled turn is reported beside the completion event so a
602
  # failure rate can be read from one event instead of a property filter.
603
  defp capture_turn_failed(%{status: "completed"}, _socket), do: :ok
604
605
  defp capture_turn_failed(turn, socket) do
606
    ChatAnalytics.turn_failed(
607
      Analytics.distinct_id(socket.assigns.current_user),
608
      %{
609
        "reason" => turn.error_code || turn.status,
610
        "outcome" => turn.status,
611
        "conversation_id" => turn.conversation_id,
612
        "turn_id" => turn.id
613
      }
614
    )
615
  end
616
617
  # Every assistant delta re-broadcasts the message, which makes this both the
618
  # stream-chunk signal and, at the terminal status, the one place an assistant
619
  # message is known to have reached the reader. Chunks are throttled inside
620
  # `OpenAgents.Analytics.Chat`; the throttle rides in an assign so it resets
621
  # with each turn.
622
  defp capture_assistant_message(
623
         socket,
624
         %Message{role: "assistant", status: "streaming"} = message
625
       ) do
626
    captured_at =
627
      ChatAnalytics.stream_chunk(
628
        Analytics.distinct_id(socket.assigns.current_user),
629
        socket.assigns.stream_chunk_captured_at,
630
        %{
631
          "conversation_id" => message.conversation_id,
632
          "modality" => message.modality
633
        }
634
      )
635
636
    assign(socket, :stream_chunk_captured_at, captured_at)
637
  end
638
639
  defp capture_assistant_message(
640
         socket,
641
         %Message{role: "assistant", status: "complete"} = message
642
       ) do
643
    ChatAnalytics.message_received(
644
      Analytics.distinct_id(socket.assigns.current_user),
645
      %{
646
        "length_bucket" => length_bucket(message.content || ""),
647
        "modality" => message.modality,
648
        "conversation_id" => message.conversation_id
649
      }
650
    )
651
652
    socket
653
  end
654
655
  defp capture_assistant_message(socket, _message), do: socket
656
657
  # Status broadcasts repeat through a call's life, so the transitions are read
658
  # against the session already in the assign: absent to live starts a call,
659
  # live to terminal ends one.
660
  defp capture_voice_lifecycle(socket, voice_session) do
661
    previous = socket.assigns.voice_session
662
    distinct_id = Analytics.distinct_id(socket.assigns.current_user)
663
    terminal? = voice_session.status in ~w(ended failed)
664
586 665
    cond do
587
      byte_size(content) < 100 -> "under_100"
588
      byte_size(content) < 1_000 -> "under_1k"
589
      byte_size(content) < 8_000 -> "under_8k"
590
      true -> "over_8k"
666
      is_nil(previous) and not terminal? ->
667
        ChatAnalytics.voice_started(distinct_id, %{
668
          "conversation_id" => voice_session.conversation_id
669
        })
670
671
      not is_nil(previous) and previous.status not in ~w(ended failed) and terminal? ->
672
        ChatAnalytics.voice_ended(distinct_id, %{
673
          "conversation_id" => voice_session.conversation_id,
674
          "outcome" => voice_session.status,
675
          "duration_ms" => voice_duration_ms(voice_session)
676
        })
677
678
      true ->
679
        :ok
591 680
    end
592 681
  end
593 682
683
  defp voice_duration_ms(%{
684
         started_at: %DateTime{} = started_at,
685
         ended_at: %DateTime{} = ended_at
686
       }),
687
       do: DateTime.diff(ended_at, started_at, :millisecond)
688
689
  defp voice_duration_ms(_voice_session), do: nil
690
691
  defp length_bucket(content), do: ChatAnalytics.length_bucket(content)
692
594 693
  defp tool_activity(nil, nil), do: []
595 694
596 695
  defp tool_activity(turn, _voice_session) when not is_nil(turn),
test/openagents/analytics/chat_test.exs added +138

@@ -0,0 +1,138 @@

1
defmodule OpenAgents.Analytics.ChatTest do
2
  @moduledoc """
3
  The chat lifecycle vocabulary is the only place chat instrumentation names an
4
  event or normalizes a token count, so these assertions pin the two decisions
5
  that operator dashboards depend on: which counts survive normalization, and
6
  that a stream reports at most one chunk event per second.
7
  """
8
9
  use ExUnit.Case, async: false
10
11
  alias OpenAgents.Analytics.Chat, as: ChatAnalytics
12
13
  defmodule TestSink do
14
    def capture(event, distinct_id, properties) do
15
      send(:chat_analytics_test_process, {:captured, event, distinct_id, properties})
16
      :ok
17
    end
18
  end
19
20
  setup do
21
    Process.register(self(), :chat_analytics_test_process)
22
23
    original_token = Application.get_env(:openagents, :posthog_project_token)
24
    original_sink = Application.get_env(:openagents, :analytics_sink)
25
26
    Application.put_env(:openagents, :posthog_project_token, "phc_test_token")
27
    Application.put_env(:openagents, :analytics_sink, TestSink)
28
29
    on_exit(fn ->
30
      restore_env(:posthog_project_token, original_token)
31
      restore_env(:analytics_sink, original_sink)
32
    end)
33
34
    :ok
35
  end
36
37
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
38
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
39
40
  defp captured do
41
    assert_receive {:captured, event, distinct_id, properties}, 500
42
    %{event: event, distinct_id: distinct_id, properties: properties}
43
  end
44
45
  describe "tokens_used/3" do
46
    test "provider token counts reach PostHog with the turn's identity" do
47
      :ok =
48
        ChatAnalytics.tokens_used(
49
          "user_abc",
50
          %{"input_tokens" => 1_200, "output_tokens" => 300, "total_tokens" => 1_500},
51
          %{"model" => "anthropic/claude-sonnet-4.5", "provider" => "openrouter"}
52
        )
53
54
      captured = captured()
55
56
      assert captured.event == "chat_tokens_used"
57
      assert captured.distinct_id == "user_abc"
58
      assert captured.properties["input_tokens"] == 1_200
59
      assert captured.properties["output_tokens"] == 300
60
      assert captured.properties["total_tokens"] == 1_500
61
      assert captured.properties["model"] == "anthropic/claude-sonnet-4.5"
62
      assert captured.properties["provider"] == "openrouter"
63
    end
64
65
    test "the Chat Completions spelling of the same counts normalizes" do
66
      :ok =
67
        ChatAnalytics.tokens_used("user_abc", %{
68
          "prompt_tokens" => 40,
69
          "completion_tokens" => 9
70
        })
71
72
      properties = captured().properties
73
74
      assert properties["input_tokens"] == 40
75
      assert properties["output_tokens"] == 9
76
    end
77
78
    test "a turn the provider never counted reports nothing" do
79
      assert :ok == ChatAnalytics.tokens_used("user_abc", nil)
80
      assert :ok == ChatAnalytics.tokens_used("user_abc", %{})
81
      assert :ok == ChatAnalytics.tokens_used("user_abc", %{"input_tokens" => "many"})
82
83
      refute_receive {:captured, _event, _distinct_id, _properties}, 100
84
    end
85
  end
86
87
  describe "stream_chunk/3" do
88
    test "the first chunk reports and the next chunk within the window does not" do
89
      captured_at = ChatAnalytics.stream_chunk("user_abc", nil, %{"modality" => "text"})
90
91
      assert is_integer(captured_at)
92
      assert captured().event == "chat_stream_chunk"
93
94
      assert captured_at == ChatAnalytics.stream_chunk("user_abc", captured_at, %{})
95
      refute_receive {:captured, _event, _distinct_id, _properties}, 100
96
    end
97
98
    test "a chunk past the window reports again" do
99
      stale = System.monotonic_time(:millisecond) - 2_000
100
101
      captured_at = ChatAnalytics.stream_chunk("user_abc", stale, %{})
102
103
      assert captured_at > stale
104
      assert captured().event == "chat_stream_chunk"
105
    end
106
  end
107
108
  describe "the remaining lifecycle events" do
109
    test "each helper names its own event" do
110
      :ok = ChatAnalytics.message_queued("user_abc", %{"queue_depth" => 2})
111
      assert captured().event == "chat_message_queued"
112
113
      :ok = ChatAnalytics.message_received("user_abc")
114
      assert captured().event == "chat_message_received"
115
116
      :ok = ChatAnalytics.turn_failed("user_abc", %{"reason" => "provider_unavailable"})
117
      assert captured().event == "chat_turn_failed"
118
119
      :ok = ChatAnalytics.tool_called("user_abc", %{"tool_name" => "read_file"})
120
      assert captured().event == "chat_tool_called"
121
122
      :ok = ChatAnalytics.voice_started("user_abc")
123
      assert captured().event == "chat_voice_started"
124
125
      :ok = ChatAnalytics.voice_ended("user_abc", %{"duration_ms" => 4_000})
126
      assert captured().event == "chat_voice_ended"
127
    end
128
  end
129
130
  describe "length_bucket/1" do
131
    test "message size reads as a bucket rather than as content" do
132
      assert ChatAnalytics.length_bucket("hello") == "under_100"
133
      assert ChatAnalytics.length_bucket(String.duplicate("a", 500)) == "under_1k"
134
      assert ChatAnalytics.length_bucket(String.duplicate("a", 5_000)) == "under_8k"
135
      assert ChatAnalytics.length_bucket(String.duplicate("a", 9_000)) == "over_8k"
136
    end
137
  end
138
end
test/openagents/posthog_test.exs modified +66 -1

@@ -55,7 +55,7 @@ defmodule OpenAgents.PostHogTest do

55 55
      assert {:error, :not_configured} = PostHog.overview()
56 56
    end
57 57
58
    test "shapes the six projections from one pull" do
58
    test "shapes the eight projections from one pull" do
59 59
      configure()
60 60
61 61
      Req.Test.expect(__MODULE__, fn conn ->

@@ -103,6 +103,44 @@ defmodule OpenAgents.PostHogTest do

103 103
        })
104 104
      end)
105 105
106
      Req.Test.expect(__MODULE__, fn conn ->
107
        {:ok, body, conn} = Plug.Conn.read_body(conn)
108
        assert body =~ "'chat_message_received'"
109
        assert body =~ "turn_failure_percent"
110
111
        Req.Test.json(conn, %{
112
          "columns" => [
113
            "messages_received",
114
            "messages_queued",
115
            "turns_failed",
116
            "turns_finished",
117
            "turn_failure_percent"
118
          ],
119
          "results" => [[24, 2, 1, 24, 4.2]]
120
        })
121
      end)
122
123
      Req.Test.expect(__MODULE__, fn conn ->
124
        {:ok, body, conn} = Plug.Conn.read_body(conn)
125
        assert body =~ "'chat_tokens_used'"
126
        assert body =~ "GROUP BY model, provider"
127
128
        Req.Test.json(conn, %{
129
          "columns" => [
130
            "model",
131
            "provider",
132
            "turns",
133
            "input_tokens",
134
            "output_tokens",
135
            "total_tokens"
136
          ],
137
          "results" => [
138
            ["anthropic/claude-sonnet-4.5", "anthropic", 6, 4200, 1800, 6000],
139
            [nil, nil, 1, 10, 5.0, 15.0]
140
          ]
141
        })
142
      end)
143
106 144
      Req.Test.expect(__MODULE__, fn conn ->
107 145
        {:ok, body, conn} = Plug.Conn.read_body(conn)
108 146
        assert body =~ "'$pageview'"

@@ -158,6 +196,33 @@ defmodule OpenAgents.PostHogTest do

158 196
               "max_duration_ms" => 7414
159 197
             }
160 198
199
      assert overview.chat_lifecycle == %{
200
               "messages_received" => 24,
201
               "messages_queued" => 2,
202
               "turns_failed" => 1,
203
               "turns_finished" => 24,
204
               "turn_failure_percent" => 4.2
205
             }
206
207
      assert overview.chat_token_usage == [
208
               %{
209
                 "model" => "anthropic/claude-sonnet-4.5",
210
                 "provider" => "anthropic",
211
                 "turns" => 6,
212
                 "input_tokens" => 4200,
213
                 "output_tokens" => 1800,
214
                 "total_tokens" => 6000
215
               },
216
               %{
217
                 "model" => "unknown",
218
                 "provider" => "unknown",
219
                 "turns" => 1,
220
                 "input_tokens" => 10,
221
                 "output_tokens" => 5,
222
                 "total_tokens" => 15
223
               }
224
             ]
225
161 226
      assert [%{"url" => "https://openagents.com/", "views" => 20} | _] = overview.top_pages
162 227
163 228
      assert overview.triage_health == %{
test/openagents_web/live/admin_analytics_live_test.exs modified +33 -4

@@ -86,8 +86,8 @@ defmodule OpenAgentsWeb.AdminAnalyticsLiveTest do

86 86
        request_options: [plug: {Req.Test, __MODULE__}]
87 87
      )
88 88
89
      # One pull is six questions.
90
      Req.Test.expect(__MODULE__, 6, fn conn -> respond_by_query(conn) end)
89
      # One pull is eight questions.
90
      Req.Test.expect(__MODULE__, 8, fn conn -> respond_by_query(conn) end)
91 91
92 92
      conn = log_in_admin_user(conn, "analytics-loaded")
93 93

@@ -99,14 +99,18 @@ defmodule OpenAgentsWeb.AdminAnalyticsLiveTest do

99 99
      assert has_element?(view, "#analytics-weekly-issue-flow")
100 100
      assert has_element?(view, "#analytics-funnel")
101 101
      assert has_element?(view, "#analytics-chat-turns")
102
      assert has_element?(view, "#analytics-chat-lifecycle")
103
      assert has_element?(view, "#analytics-chat-tokens-table")
102 104
      assert html = render(view)
105
      assert html =~ "anthropic/claude-sonnet-4.5"
106
      assert html =~ "4.2%"
103 107
      assert html =~ "12.5h"
104 108
      assert html =~ "15.0%"
105 109
      assert html =~ "$pageview"
106 110
      assert html =~ "https://openagents.com/"
107 111
108 112
      # A second full pull backs the refresh click.
109
      Req.Test.expect(__MODULE__, 6, fn conn -> respond_by_query(conn) end)
113
      Req.Test.expect(__MODULE__, 8, fn conn -> respond_by_query(conn) end)
110 114
111 115
      assert view |> element("#analytics-refresh") |> render_click() =~ "LIVE POSTHOG"
112 116
      render_async(view)

@@ -125,7 +129,7 @@ defmodule OpenAgentsWeb.AdminAnalyticsLiveTest do

125 129
    Req.Test.expect(__MODULE__, handler)
126 130
  end
127 131
128
  # The client asks six questions in a fixed order; each stub answers by
132
  # The client asks eight questions in a fixed order; each stub answers by
129 133
  # matching the HogQL in the request body rather than relying on call order.
130 134
  defp respond_by_query(conn) do
131 135
    {:ok, body, conn} = Plug.Conn.read_body(conn)

@@ -149,6 +153,31 @@ defmodule OpenAgentsWeb.AdminAnalyticsLiveTest do

149 153
          "results" => [[3, 1, 2, 1, 6]]
150 154
        })
151 155
156
      body =~ "turn_failure_percent" ->
157
        Req.Test.json(conn, %{
158
          "columns" => [
159
            "messages_received",
160
            "messages_queued",
161
            "turns_failed",
162
            "turns_finished",
163
            "turn_failure_percent"
164
          ],
165
          "results" => [[24, 2, 1, 24, 4.2]]
166
        })
167
168
      body =~ "'chat_tokens_used'" ->
169
        Req.Test.json(conn, %{
170
          "columns" => [
171
            "model",
172
            "provider",
173
            "turns",
174
            "input_tokens",
175
            "output_tokens",
176
            "total_tokens"
177
          ],
178
          "results" => [["anthropic/claude-sonnet-4.5", "anthropic", 6, 4200, 1800, 6000]]
179
        })
180
152 181
      body =~ "'chat_turn_completed'" ->
153 182
        Req.Test.json(conn, %{
154 183
          "columns" => [

This page updates live while a promote is in flight · changelog