Skip to content

Users

A user id rides on most production traces, and until now the store could filter by it, erase by it, and nothing else. "Who are my heaviest users", "when did this one last show up", "what does this account cost me" needed a scan of every trace the project holds — the scan the statistics rollup exists to avoid.

So users get a rollup of their own: the same hourly grain, one dimension over. A listing of who is there, a page for each of them, and the same charts and breakdowns the dashboard draws, restricted to one account.

What a user is

Any trace that carries a user id, which the mapper claims from langfuse.user.id or user.id:

tracepad.update_trace(user_id="user-4821")

There is nothing else to configure. A trace that names no user belongs to no user here — there is no "anonymous" pseudo-row, because the trace listing with no user_id filter already is that view.

What is counted

Every number counts traces, not observations — the same rule the session roll-up follows:

Field Means
traces How many traces are attributed to this user
error_count How many of those traces carry at least one failed observation
total_cost Summed over the traces whose client reported a cost; absent when none did, which is not the same as zero
tokens The five token classes summed over this user's traces — input, output, cache read, reasoning, cache write — each absent when nothing reported it (api.md)
sessions How many of this user's sessions have begun
first_seen, last_seen The hours the rollup holds for them, at either end

sessions counts a session where it starts — in the hour of the earliest trace this user filed under that session id. A distinct count does not merge across hours and a start does, so a sum over any range is exact: two hours of sessions added together is the number of sessions begun in those two hours, never a double count of one that spanned both.

Latency is on GET /api/v1/users/{id} only, as latency_ms.p50 / p95. Like every percentile in this API it is histogram-based: accurate to a few percent, stable across the expiry of the rows it came from.

The lag, and where there is none

The listing is answered from the rollup and nothing else. It therefore trails live traffic by up to twice TRACEPAD_ROLLUP_INTERVAL — five minutes by default, so ten in the worst case — exactly as the statistics do (api.md). A user first seen a minute ago is not on it yet.

Sorting and paging two sources on every page view would be the scan this design removes, through a side door. One user is another matter: GET /api/v1/users/{id} merges the live tail — the traces past the rollup's watermark — so it is exact for any id, listed or not, and last_seen is exact to the second when the tail holds them. An id nothing was ever filed under is a 404.

The web interface says the same thing in the place it matters: the empty listing does not claim there are no users, it names the lag and links to the Traces screen, which is live.

On an upgrade

An existing install already has its statistics rolled up to now, so nothing about the history would look "changed" and these two tables would stay empty — or, on the upgrade that taught them the token classes, without tokens (until that walk is through, a user's tokens grow hour by hour as it re-rolls them). The migration therefore asks the aggregator to walk the rolled history once: the first pass after the upgrade re-rolls every hour it holds, which fills the per-user tables and rewrites the identical statistics rows. It is background work, it happens once, and the statistics keep answering from the rollup throughout.

An hour older than the project's retention_days is frozen (retention.md), and the freeze is asked of this table's own rows: an hour it already holds is left alone, and an hour it holds nothing for is rolled from whatever raw rows are still there. Ordinarily there are none — that is what the window means — so such a user's first_seen starts where their retained traces do. Where the raw rows are still there, which is history imported into an install that had already rolled those hours, the pass fills them rather than leaving a permanent gap.

How much it costs

One row per (user, hour, environment, release, model), plus a trace-unit row per (user, hour, environment, release). So the row count is

active user-hours × environments × (models + 1)

and nothing else — an inactive user costs nothing at all, and a user active in three hours of a day costs three hours of rows however many traces they ran in them.

In numbers, for one environment and two models, at 24 rolled hours per row and roughly 130 bytes a row (the five token classes are about 10 of them):

Shape Active user-hours a day Rows a day A month
1,000 users, ~4 active hours each 4,000 12,000 ~47 MB
100,000 users, ~2 active hours each 200,000 600,000 ~2.4 GB
1,000,000 users, ~1 active hour each 1,000,000 3,000,000 ~12 GB

The summary table beside it is one row per user the project has ever seen: about 80 bytes, and about 50 more in each of the five indexes the listing sorts by — for ids of ten characters or so, a million users is ~330 MB, once, not per day.

Two knobs bound all of it. stats_retention_days sweeps these rows on the same schedule as the statistics rollup (retention.md), and a deployment that emits a user id per request rather than per account is paying for exactly that choice, linearly — the same bargain unbounded release strings make in the statistics.

GET /api/v1/system counts both tables (users_hourly, users) so the size is a number rather than a guess.

Retention and erasure

  • Retention. The summary is over what the rollup retains: when stats_retention_days sweeps a user's oldest hours, first_seen moves forward and the totals shrink with it. A user whose last row goes leaves the listing entirely.
  • Erasure. DELETE /api/v1/projects/{id}/users/{user_id}/data deletes the user's rows in both tables outright, with each chunk of their traces the erasure deletes — see admin.md. They are about the user, so they go rather than being recomputed: for an hour past the trace retention window there is nothing left to recompute them from. The project-wide stats_hourly for such a frozen hour goes on counting the traces, which is the archive's documented position (retention.md).

The same user id in two environments is one user: one row, with the split visible in the page's environment breakdown.

From the command line

tracepad users ls                          # by last seen
tracepad users ls --sort cost --limit 20   # the expensive ones
tracepad users ls --sort tokens            # the heavy ones, when nothing reports a cost
tracepad users ls --prefix acme:           # case-sensitive prefix
tracepad users show user-4821
tracepad stats --user user-4821 --group-by day --since 168h

users ls walks with --cursor, --oldest and --newer like every other listing, and --total adds the capped count. --json prints the endpoint's own bytes. Full flags: cli.md.

From an agent

Three MCP tools, each one endpoint:

  • list_users — who is heaviest, most expensive, most error-prone, or here lately.
  • get_user — one account's totals, exact including traffic too recent for the listing.
  • get_stats with user_id — that account over time, or split by model or environment.

See mcp.md.

In the web interface

Users sits between Sessions and Prompts, joining the screens about who: a user is a set of sessions, and their page is the dashboard's charts for one of them. See ui.md.