# How the Claude Code Analytics Dashboard Detects Active Conversations in Real-Time

> Discover how the Claude Code Analytics Dashboard detects active conversations in real-time using a lightweight telemetry pipeline and Supabase Realtime.

- Repository: [Daniel Avila/claude-code-templates](https://github.com/davila7/claude-code-templates)
- Tags: how-to-guide
- Published: 2026-04-26

---

**The Claude Code Analytics Dashboard tracks live conversations through a lightweight telemetry pipeline that sends 30-second heartbeats from the CLI to a Supabase table, then streams changes to the UI via Supabase-Realtime subscriptions.**

According to the `davila7/claude-code-templates` repository, the dashboard displays a real-time count of active Claude Code sessions by combining client-side instrumentation, database upserts, and WebSocket-based subscriptions. This architecture allows the Analytics Dashboard to reflect new sessions and drop-offs with sub-second latency.

## The Telemetry Pipeline: CLI Heartbeats

Every interaction in Claude Code triggers a telemetry event that powers the live dashboard. When a user sends a request in the CLI, the `trackWebsiteEvent` function in [`api/track-website-events.js`](https://github.com/davila7/claude-code-templates/blob/main/api/track-website-events.js) dispatches a heartbeat payload to the backend.

### Session Identification and Payload Structure

The heartbeat function constructs a JSON payload containing a unique `session_id` (generated once per CLI start), an event type of `"conversation_heartbeat"`, and a client-side timestamp. As implemented in [`api/track-website-events.js`](https://github.com/davila7/claude-code-templates/blob/main/api/track-website-events.js):

```javascript
export async function trackWebsiteEvent(event, sessionId) {
  const body = {
    session_id: sessionId,
    event,
    timestamp: Date.now(),
    // optional user meta …
  };
  await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/active_sessions`, {
    method: "POST",
    headers: {
      apikey: process.env.SUPABASE_SERVICE_ROLE_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
}

```

If the user is authenticated, the payload also includes a hashed `user_id` for attribution without exposing personal data.

### The Heartbeat Timeout Mechanism

The CLI enforces a **30-second activity window**. The `trackWebsiteEvent` function fires on every user request (such as pressing **Enter**), but if a session remains idle for more than 30 seconds, the CLI stops transmitting heartbeats. This timeout threshold synchronizes with the dashboard’s filtering logic to ensure disconnected or inactive sessions drop from the live count precisely when they become stale.

## Data Persistence with Supabase

The active session data resides in a dedicated Supabase table optimized for high-frequency updates and real-time broadcasting.

### Table Schema and Security

The migration file [`supabase/migrations/2024-xx-xx_create_active_sessions.sql`](https://github.com/davila7/claude-code-templates/blob/main/supabase/migrations/2024-xx-xx_create_active_sessions.sql) defines the storage structure:

```sql
create table public.active_sessions (
  id uuid primary key default uuid_generate_v4(),
  session_id uuid not null,
  last_seen bigint not null,
  user_hash text,
  created_at timestamp with time zone default now()
);

```

A **Row Level Security (RLS)** policy restricts write operations to the service role key used by the CLI, while allowing the dashboard's public API to read the data for display purposes.

### Upsert Strategy for Live Updates

Rather than inserting duplicate rows for every heartbeat, the endpoint uses an **UPSERT** pattern. The code in [`api/track-website-events.js`](https://github.com/davila7/claude-code-templates/blob/main/api/track-website-events.js) updates the `last_seen` timestamp if the `session_id` already exists:

```javascript
await supabase
  .from("active_sessions")
  .upsert({
    session_id,
    last_seen: Date.now(),
    user_hash: hashUserId(userId),
  })
  .eq("session_id", sessionId);

```

This guarantees that the `active_sessions` table maintains exactly one row per active Claude Code session, with the `last_seen` column continuously refreshed to the current timestamp.

## Real-Time Subscription in the Dashboard

The front-end detects changes instantly through Supabase-Realtime. The utility file [`dashboard/src/lib/realtime.ts`](https://github.com/davila7/claude-code-templates/blob/main/dashboard/src/lib/realtime.ts) establishes a WebSocket connection to the database and listens for all changes on the `active_sessions` table:

```typescript
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

export const subscribeActiveConversations = (onChange) => {
  return supabase
    .channel("public:active_sessions")
    .on(
      "postgres_changes",
      { event: "*", schema: "public", table: "active_sessions" },
      (payload) => {
        // payload.new contains the freshest row
        onChange(payload);
      }
    )
    .subscribe();
};

```

The subscription captures **INSERT**, **UPDATE**, and **DELETE** events as they occur. When a new heartbeat upserts a row, the Realtime channel pushes that change to every connected dashboard client within milliseconds.

## Calculating the Live Count

The React component [`dashboard/src/components/ActiveConversations.tsx`](https://github.com/davila7/claude-code-templates/blob/main/dashboard/src/components/ActiveConversations.tsx) manages local state and derives the final metric by filtering for recent timestamps. It stores session data in a `Map<session_id, last_seen>` and reapplies the 30-second threshold:

```typescript
const ACTIVE_TIMEOUT_MS = 30_000;

export const ActiveConversations = () => {
  const [sessions, setSessions] = useState<Map<string, number>>(new Map());

  useEffect(() => {
    const sub = subscribeActiveConversations((payload) => {
      const { session_id, last_seen } = payload.new;
      setSessions((prev) => {
        const copy = new Map(prev);
        copy.set(session_id, last_seen);
        return copy;
      });
    });
    return () => supabase.removeChannel(sub);
  }, []);

  const now = Date.now();
  const activeCount = Array.from(sessions.values()).filter(
    (ts) => now - ts <= ACTIVE_TIMEOUT_MS
  ).length;

  return <span>{activeCount} active conversation{activeCount !== 1 && "s"}</span>;
};

```

Because the data arrives via the Realtime subscription rather than polling, the UI updates instantaneously as soon as a heartbeat lands in the database or a session times out.

## Maintenance and Cleanup

To prevent the `active_sessions` table from growing indefinitely, a serverless cron job handles garbage collection. The script in [`api/maintenance/cleanup-inactive-sessions.ts`](https://github.com/davila7/claude-code-templates/blob/main/api/maintenance/cleanup-inactive-sessions.ts) executes on a one-minute interval via Vercel cron, deleting rows older than the 30-second threshold:

```typescript
const THRESHOLD = Date.now() - 30_000; // 30 seconds
await supabase
  .from("active_sessions")
  .delete()
  .lte("last_seen", THRESHOLD);

```

This pruning ensures that stale sessions are removed from storage while the dashboard’s local filtering ensures they disappear from the UI immediately upon expiration.

## Summary

- **CLI Instrumentation**: The `trackWebsiteEvent` function in [`api/track-website-events.js`](https://github.com/davila7/claude-code-templates/blob/main/api/track-website-events.js) transmits a `"conversation_heartbeat"` payload containing `session_id` and `timestamp` on every user request.
- **Database Schema**: The `active_sessions` table stores the latest heartbeat per session with RLS policies securing write access.
- **Upsert Pattern**: Each heartbeat updates the `last_seen` column via UPSERT, maintaining a single canonical row per active session.
- **Real-Time Streaming**: The dashboard subscribes to Supabase-Realtime in [`dashboard/src/lib/realtime.ts`](https://github.com/davila7/claude-code-templates/blob/main/dashboard/src/lib/realtime.ts), receiving immediate notifications of database changes.
- **Client-Side Filtering**: The `ActiveConversations` component counts only sessions with timestamps newer than 30 seconds to derive the live metric.
- **Background Cleanup**: A cron job in [`api/maintenance/cleanup-inactive-sessions.ts`](https://github.com/davila7/claude-code-templates/blob/main/api/maintenance/cleanup-inactive-sessions.ts) purges expired rows to keep the dataset lightweight.

## Frequently Asked Questions

### How does the dashboard know when a conversation becomes inactive?

The dashboard filters the local session cache using the same 30-second timeout defined in the CLI. If the difference between `Date.now()` and a session's `last_seen` value exceeds 30,000 milliseconds, the component excludes that session from the active count, even if the database row persists temporarily before the cleanup cron job removes it.

### Why does the system use Supabase-Realtime instead of polling?

Supabase-Realtime provides **sub-second latency** by pushing database changes over WebSockets rather than requiring the client to request updates periodically. This architecture reduces network overhead and allows the Analytics Dashboard to reflect new heartbeats instantly as they are upserted into the `active_sessions` table.

### What prevents unauthorized clients from writing to the active sessions table?

The `active_sessions` table implements **Row Level Security (RLS)** policies that restrict insert and update operations to requests bearing the `SUPABASE_SERVICE_ROLE_KEY`. The dashboard client uses an anonymous public key with read-only permissions, ensuring only the CLI backend can modify session data.

### How does the system handle clock drift between the CLI and server?

The heartbeat mechanism relies on **client-generated timestamps** (`Date.now()` in the CLI) stored as `last_seen` in the database. The dashboard applies the 30-second filter using its own local time reference. While minor drift exists, the 30-second window is sufficiently large to accommodate typical client-server time discrepancies without affecting accuracy.