lib/openagents_web/live/admin_analytics_live.ex

main at 436d37facc96 · 13 KB

defmodule OpenAgentsWeb.AdminAnalyticsLive do
  @moduledoc """
  Operator view of product analytics, pulled server-side from PostHog.

  Every number here is computed by PostHog at request time through
  `OpenAgents.PostHog`, so the surface adds no second aggregation authority:
  what an operator sees matches what the PostHog app would answer for the same
  window. Read-only, operator-gated, and honest about its three non-happy
  states — unconfigured credentials, a failed pull, and loading.

  It shows operational facts about accounts as aggregates. It never renders
  conversation content, memory claims, or anything a person wrote.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Accounts
  alias OpenAgents.PostHog

  @impl true
  def mount(_params, _session, socket) do
    if Accounts.admin?(socket.assigns.current_user) do
      socket =
        socket
        |> assign(:page_title, "Operator · Analytics")
        |> assign(:status, :loading)
        |> assign(:overview, nil)

      if connected?(socket) do
        send(self(), :load)
      end

      {:ok, socket}
    else
      {:ok, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_event("refresh", _params, socket) do
    # Re-checked per event, not only at mount: a long-lived socket outlives the
    # decision that opened it.
    if Accounts.admin?(socket.assigns.current_user) do
      {:noreply,
       socket
       |> assign(:status, :loading)
       |> load()}
    else
      {:noreply, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_info(:load, socket), do: {:noreply, load(socket)}

  defp load(%{assigns: %{status: :loading}} = socket) do
    case PostHog.overview() do
      {:ok, overview} ->
        assign(socket, status: :loaded, overview: overview)

      {:error, reason} when reason in [:not_configured, :unavailable] ->
        assign(socket, status: reason, overview: nil)
    end
  end

  # A refresh while a load is already resolving must not clobber the newer
  # state with an older response.
  defp load(socket), do: socket

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="PostHog analytics"
    >
      <main id="admin-analytics-page" class="app-shell admin-shell">
        <section class="admin space-y-8" aria-labelledby="analytics-heading">
          <header class="admin-heading">
            <h1 id="analytics-heading">Product analytics</h1>
            <p>
              Computed live from PostHog. Usage covers the trailing twenty-four hours,
              and triage health uses the rolling windows shown below. These are aggregate
              operational facts; no conversation or memory content is readable from this page.
            </p>
            <div class="admin-totals">
              <.badge variant={:info}>LIVE POSTHOG</.badge>
              <.badge
                :if={@status == :loaded && @overview}
                variant={:dim}
                id="analytics-generated-at"
              >
                GENERATED {Calendar.strftime(@overview.generated_at, "%Y-%m-%d %H:%M UTC")}
              </.badge>
              <.text_button id="analytics-refresh" phx-click="refresh" disabled={@status == :loading}>
                {if(@status == :loading, do: "REFRESHING…", else: "REFRESH")}
              </.text_button>
            </div>
          </header>

          <.alert
            :if={@status == :not_configured}
            id="analytics-not-configured"
            appearance={:notice}
            variant={:warning}
          >
            This deployment has no PostHog read credentials configured. Set
            <.kbd>OPENAGENTS_POSTHOG_PERSONAL_API_KEY</.kbd>
            and
            <.kbd>OPENAGENTS_POSTHOG_PROJECT_ID</.kbd>
            to enable this surface.
          </.alert>

          <.alert
            :if={@status == :unavailable}
            id="analytics-unavailable"
            appearance={:notice}
            variant={:danger}
          >
            <div class="space-y-3">
              <p>PostHog did not answer the last query. Nothing on this page is stale data.</p>
              <.button id="analytics-retry" variant={:secondary} phx-click="refresh">
                TRY AGAIN
              </.button>
            </div>
          </.alert>

          <.alert :if={@status == :loading} id="analytics-loading" appearance={:row}>
            Querying PostHog…
          </.alert>

          <div :if={@status == :loaded && @overview} class="space-y-8">
            <section aria-labelledby="triage-health-heading">
              <.card id="analytics-triage-health">
                <div class="space-y-1">
                  <h2 id="triage-health-heading" class="card-title">Triage health</h2>
                  <p class="text-muted-foreground">
                    Response and label health use issues created in the trailing 90 days.
                    Issue flow covers the trailing eight weeks.
                  </p>
                </div>
                <% triage = @overview.triage_health %>
                <div class="grid gap-4 py-5 md:grid-cols-3">
                  <div class="rounded-md border border-border p-4">
                    <p class="text-sm text-muted-foreground">Median first maintainer response</p>
                    <p id="triage-median-response" class="mt-2 text-2xl font-semibold">
                      {format_hours(triage["median_first_maintainer_response_hours"])}
                    </p>
                  </div>
                  <div class="rounded-md border border-border p-4">
                    <p class="text-sm text-muted-foreground">Unlabeled after 24 hours</p>
                    <p id="triage-unlabeled-share" class="mt-2 text-2xl font-semibold">
                      {format_percent(triage["unlabeled_after_24h_percent"])}
                    </p>
                  </div>
                  <div class="rounded-md border border-border p-4">
                    <p class="text-sm text-muted-foreground">Eligible open issues</p>
                    <p id="triage-eligible-issues" class="mt-2 text-2xl font-semibold">
                      {triage["eligible_issues"]}
                    </p>
                    <p class="mt-1 text-sm text-muted-foreground">
                      {triage["unlabeled_issues"]} currently have no labels.
                    </p>
                  </div>
                </div>
                <.table id="analytics-weekly-issue-flow" rows={@overview.weekly_issue_flow}>
                  <:col :let={row} label="Week of">{row["week"]}</:col>
                  <:col :let={row} label="Created">{row["created"]}</:col>
                  <:col :let={row} label="Closed">{row["closed"]}</:col>
                </.table>
              </.card>
            </section>

            <section aria-labelledby="funnel-heading">
              <.card id="analytics-funnel">
                <h2 id="funnel-heading" class="card-title">Activation funnel</h2>
                <ul class="divide-y divide-border">
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>GitHub authorization started</span>
                    <span class="font-semibold">{@overview.funnel["auth_started"]}</span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Accounts created</span>
                    <span class="font-semibold">{@overview.funnel["user_signed_up"]}</span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>Returning sign-ins</span>
                    <span class="font-semibold">{@overview.funnel["user_signed_in"]}</span>
                  </li>
                  <li class="flex items-center justify-between gap-4 py-3">
                    <span>First chat messages sent</span>
                    <span class="font-semibold">{@overview.funnel["chat_message_sent"]}</span>
                  </li>
                </ul>
              </.card>
            </section>

            <section aria-labelledby="turns-heading">
              <.card id="analytics-chat-turns">
                <h2 id="turns-heading" class="card-title">Chat turns</h2>
                <% turns = @overview.chat_turns %>
                <div class="flex flex-wrap items-center gap-3 pb-3">
                  <.badge>{turns["turns"]} TURNS</.badge>
                  <.badge :if={turns["completed"] > 0} variant={:success}>
                    {turns["completed"]} COMPLETED
                  </.badge>
                  <.badge :if={turns["failed"] > 0} variant={:danger}>
                    {turns["failed"]} FAILED
                  </.badge>
                  <.badge :if={turns["cancelled"] > 0} variant={:warning}>
                    {turns["cancelled"]} CANCELLED
                  </.badge>
                </div>
                <p class="text-muted-foreground">
                  Average turn {format_duration(turns["avg_duration_ms"])}; longest {format_duration(
                    turns["max_duration_ms"]
                  )}.
                </p>
              </.card>
            </section>

            <section aria-labelledby="lifecycle-heading">
              <.card id="analytics-chat-lifecycle">
                <div class="space-y-1">
                  <h2 id="lifecycle-heading" class="card-title">Chat lifecycle and tokens</h2>
                  <p class="text-muted-foreground">
                    Assistant deliveries, queued messages, and turn failures over the trailing
                    twenty-four hours. Token totals are provider-reported and counted once per turn.
                  </p>
                </div>
                <% lifecycle = @overview.chat_lifecycle %>
                <div class="grid gap-4 py-5 md:grid-cols-3">
                  <div class="rounded-md border border-border p-4">
                    <p class="text-sm text-muted-foreground">Assistant messages received</p>
                    <p id="lifecycle-messages-received" class="mt-2 text-2xl font-semibold">
                      {lifecycle["messages_received"]}
                    </p>
                    <p class="mt-1 text-sm text-muted-foreground">
                      {lifecycle["messages_queued"]} messages waited behind an active turn.
                    </p>
                  </div>
                  <div class="rounded-md border border-border p-4">
                    <p class="text-sm text-muted-foreground">Turn failure rate</p>
                    <p id="lifecycle-failure-rate" class="mt-2 text-2xl font-semibold">
                      {format_percent(lifecycle["turn_failure_percent"])}
                    </p>
                    <p class="mt-1 text-sm text-muted-foreground">
                      {lifecycle["turns_failed"]} of {lifecycle["turns_finished"]} finished turns
                      ended in failure or cancellation.
                    </p>
                  </div>
                  <div class="rounded-md border border-border p-4">
                    <p class="text-sm text-muted-foreground">Tokens used</p>
                    <p id="lifecycle-total-tokens" class="mt-2 text-2xl font-semibold">
                      {total_tokens(@overview.chat_token_usage)}
                    </p>
                    <p class="mt-1 text-sm text-muted-foreground">
                      Input plus output across every model.
                    </p>
                  </div>
                </div>
                <.table id="analytics-chat-tokens-table" rows={@overview.chat_token_usage}>
                  <:col :let={row} label="Model">{row["model"]}</:col>
                  <:col :let={row} label="Provider">{row["provider"]}</:col>
                  <:col :let={row} label="Turns">{row["turns"]}</:col>
                  <:col :let={row} label="Input">{row["input_tokens"]}</:col>
                  <:col :let={row} label="Output">{row["output_tokens"]}</:col>
                  <:col :let={row} label="Total">{row["total_tokens"]}</:col>
                </.table>
              </.card>
            </section>

            <section aria-labelledby="events-heading">
              <.card id="analytics-event-volume">
                <h2 id="events-heading" class="card-title">Event volume</h2>
                <.table id="analytics-events-table" rows={@overview.event_counts}>
                  <:col :let={row} label="Event">{row["event"]}</:col>
                  <:col :let={row} label="Count">{row["count"]}</:col>
                  <:col :let={row} label="People">{row["people"]}</:col>
                </.table>
              </.card>
            </section>

            <section aria-labelledby="pages-heading">
              <.card id="analytics-top-pages">
                <h2 id="pages-heading" class="card-title">Top pages</h2>
                <.table id="analytics-pages-table" rows={@overview.top_pages}>
                  <:col :let={row} label="URL">{row["url"]}</:col>
                  <:col :let={row} label="Views">{row["views"]}</:col>
                </.table>
              </.card>
            </section>
          </div>
        </section>
      </main>
    </Layouts.app>
    """
  end

  defp format_duration(nil), do: "n/a"

  defp format_duration(ms) when is_number(ms) and ms >= 1_000 do
    "#{:erlang.float_to_binary(ms / 1_000, decimals: 1)}s"
  end

  defp format_duration(ms) when is_integer(ms), do: "#{ms}ms"

  defp total_tokens(rows) when is_list(rows),
    do: Enum.reduce(rows, 0, fn row, total -> total + (row["total_tokens"] || 0) end)

  defp format_hours(nil), do: "No data"
  defp format_hours(hours) when is_integer(hours), do: "#{hours}h"

  defp format_hours(hours) when is_float(hours),
    do: "#{:erlang.float_to_binary(hours, decimals: 1)}h"

  defp format_percent(value) when is_integer(value), do: "#{value}%"

  defp format_percent(value) when is_float(value),
    do: "#{:erlang.float_to_binary(value, decimals: 1)}%"
end