Skip to content

feat(analytics): make the Cursor usage CSV a first-class cost source - #541

Closed
gokhanozdemir wants to merge 34 commits into
codemie-ai:mainfrom
gokhanozdemir:feature/cursor-analytics
Closed

feat(analytics): make the Cursor usage CSV a first-class cost source#541
gokhanozdemir wants to merge 34 commits into
codemie-ai:mainfrom
gokhanozdemir:feature/cursor-analytics

Conversation

@gokhanozdemir

@gokhanozdemir gokhanozdemir commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Cursor's imported usage CSV now flows through the whole analytics report as ordinary session data,
instead of sitting in a tab of its own that no headline figure read. Also fixes the bug that made
every Cursor session report a zero duration.

Motivation

The previous cut isolated the import completely — including from the places anyone would actually
look. You could import a file containing 39,952,466 tokens and $25.25 of real spend and have
every headline number in the report ignore it. That reads as broken because functionally it was.

Two further problems made the isolation worse rather than better:

  • A later bolt-on folded the CSV into the Cost tab only, so Overview and Cost showed different
    totals under identical filters, and "Tokens by model" excluded 39.9M tokens from the one chart
    named after tokens.
  • --cursor-usage-csv was handled entirely inside the report branch, so
    codemie analytics --cursor-usage-csv f.csv opened nothing and warned about nothing.

Separately, all 42 Cursor sessions in a real report had durationMs = 0, which made their Activity
and Efficiency figures meaningless.

Changes

Cursor session durations (fix)

  • composerHeaders names its last-update stamp lastUpdatedAt; the reader looked for updatedAt,
    a key no real row carries. Every window collapsed to its creation instant.
  • A header that dates only its creation now closes the open end from the tracking database's
    recorded edits instead of mirroring createdAt.
  • On real data: 17 of 42 sessions gain a genuine window, median 15.4 minutes.

Canonical conversion

  • New cursor-usage-loader.ts, mirroring otel-loader.ts — the existing answer to "a flat
    per-event file that must behave like sessions". It synthesizes RawSessionData plus a canonical
    SessionCostIndex, so the aggregator, formatter, exporter and report client need no special case.
  • Events are matched by time: an event inside exactly one Cursor session's activity window is
    attributed to it. No window, or several overlapping ones, sends it to a cursor-usage:<date>
    daily rollup rather than to a guess — the same refusal 81dbeb1 makes for an ambiguous slug.
  • New ModelCost.costBasis?: 'vendor-billed' marks these as Cursor's own billed figures rather
    than a CodeMie estimate. Optional, so every existing producer keeps today's semantics.

Wiring

  • The flag is resolved right after source.load() and prints what it imported, so a run without any
    report flag is no longer silent.
  • Removed cursorCostContrib() and the Cost-tab fold-in it powered.

Report surface

  • Removed the Cursor Usage CSV view, its nav entry, the data-optional nav mechanism it was the
    sole user of, and the meta.cursorUsage payload field. A per-agent tab is a special case no other
    data source has.
  • The Cost banner states that Cursor rows are Cursor's own billing, shown only when such a row is in
    the filtered view.

Kept from a prior review: the cursor.sqlite.ts extraction the three readers share, and a
parameterised LIKE in the usage fetch.

Docs: Cursor gets its own section in ANALYTICS-REPORT.md, ordered why-first — CodeMie never
launches it, recent builds record no billable tokens on disk, and the local stores were measured
rather than assumed (0 of 469 sessions carried a token signal). Updated CURSOR_INTEGRATION.md and
external-integrations.md, whose "not added to any cost figure elsewhere" claims this makes false.

Testing

  • npx vitest run --project unit4029 passing, run repeatedly.
  • New cursor-usage-loader.test.ts drives the real parser over the real fixtures and asserts
    conservation: however the matcher splits the file, the output sums to 61 events / 39,952,466
    tokens / $25.25 exactly once. Also covers the no-Cost export variant, the ambiguous-window case,
    and per-day rollups.
  • New duration regression tests in native-loader-cursor.test.ts, plus a fallback test for the
    legacy updatedAt spelling. The fixture helper now writes the key Cursor actually writes.
  • New payload-builder tests: costBasis survives onto perModelCost, and Coverage reports Cursor
    as priced with no native log.
  • End-to-end against a real export: Cursor cost $25.25 and 39,952,466 tokens appear once in the
    session set; Coverage shows Cursor priced 9/45; no (cursor)-suffixed model names.

Known gap

--export still carries no cost or token columns — for any agent, not just Cursor.
SessionAnalytics.tokens/costUSD are declared but never populated, and exporter.ts has no cost
fields at all. Adding them is a general analytics feature that changes --export output for every
user, so it is deliberately out of scope here. The CSV's sessions do reach --export and the
terminal totals; only cost does not.

Breaking Changes

None. ModelCost.costBasis is optional and additive; the removed Cursor Usage CSV view showed
no number that is not now available in the ordinary views.

Checklist

  • Self-reviewed
  • Manual testing performed
  • Documentation updated (if needed)
  • No breaking changes (or clearly documented)

@gokhanozdemir gokhanozdemir changed the title Feature/cursor analytics feat(analytics): make the Cursor usage CSV a first-class cost source Sep 7, 2026
@gokhanozdemir
gokhanozdemir force-pushed the feature/cursor-analytics branch 2 times, most recently from b3fe9bd to 3585cbf Compare September 7, 2026 12:34
Gokhan Ozdemir and others added 27 commits September 7, 2026 15:37
Base scaffolding for the Cursor analytics-only agent (spec #1): CONTEXT.md
glossary plus the Cursor path helper, transcript reader and read-only
AI-tracking database reader.

Refs #1
Add a `cursor` row to the AGENTS.md agent plugin table describing it as an
analytics-only agent whose unmanaged sessions are read from Cursor's local
transcripts, and correct the stale `copilot-cli` row, which is a managed agent
(npmPackage `@github/copilot`), not analytics ingestion only.

Refs #6
Cursor is an IDE CodeMie never installs, launches or updates, so it
registers with `analyticsOnly: true` — which keeps it out of every
management surface and out of the ownership gate that would otherwise
tag all of its sessions `native-external`.

Sessions come from the agent transcripts under `~/.cursor/projects`.
A transcript carries only role-tagged text, tool_use blocks and turn
markers, so this reports exactly that plus the file's own birthtime and
mtime as the activity window, and sets `usageUnavailableReason` so the
report shows tokens and cost as unmeasurable rather than as zero. The
lossy project slug is only de-slugged into a project path when the
result names a directory that really exists.

Model and real edit times live only in Cursor's AI-tracking database;
`CursorSessionAdapter.setTrackingIndex()` is the seam that enrichment
will be injected through.

Refs #3
Join Cursor's read-only AI-tracking database on conversation id to supply what a
transcript cannot: the model, the files the agent wrote, the real edit window and a
trustworthy project path.

The index is read once per run, memoized in the adapter around discoverSessions, so
native-loader.ts stays free of Cursor-specific code. The enrichment lands on the session
descriptor as well as the parsed session because the loader's synthesis reads timing and
cwd from the descriptor when messages carry no timestamps, which Cursor's never do.

The project path is recovered by walking up from the common directory of the database's
absolute file paths until a directory slugifies back to Cursor's own directory slug — a
verification rather than a guess, so lossy slugs (both / and _ become -) resolve exactly.
No match falls back to the previous existence-checked de-slug.

Files are recorded without line counts (Cursor stores hashes, not diffs), and a model
recorded only as the literal default stays unknown. A missing, locked or schema-drifted
database degrades to transcript-only rows, and conversations that exist only in the
database still produce none.

Refs #4
Add a `cursor` entry to the report client's duplicated AGENT_LABELS and
AGENT_COLORS tables so Cursor is labelled and coloured consistently with
the CLI's agent-labels.ts.

Cursor records no token or cost data, so every money/token cell now goes
through usage-availability helpers that render an em dash for sessions
carrying `usageUnavailableReason` instead of a hard "$0.00" / "0".
Aggregates only dash out when nothing in the group was measurable.

Refs #5
Drive loadNativeSessions() against a fixture Cursor home reached through the
CURSOR_HOME override, asserting external behavior only: discovery, the
native-unmanaged tag and default visibility without --include-external;
tracking-database enrichment (model, files touched, edit-derived window) with a
'default' model reported as unknown; fail-soft degradation for a missing,
corrupt or schema-drifted database; zero sessions for an absent or empty Cursor
home; and tokens/cost/lines left blank with an unavailable reason.

Refs #7
…estly

Two defects surfaced by code review against real Cursor data.

Project attribution (story 5): the slug de-slugger split on every "-", but
Cursor slugs "/" and "_" alike and a directory name may contain "-" of its
own, so "Users-ada_lovelace-claude-code-router" became
"/Users/ada/lovelace/claude/code/router" and 57 of 65 local sessions reported
no project. The slug is now walked against the filesystem, descending only
into children whose own slug matches, so each step is verified rather than
guessed. Directory listing follows symlinks, without which the walk died at
macOS's symlinked /var.

Activity window (story 9): Cursor stamps every prompt with a readable
timestamp, and userQueryText discarded it, leaving file birth/mtime as the
only signal — which measures when the transcript was touched, so a resumed
conversation reported a span of days. Stamps now sit between the tracking
database and file times.

Also from review: apply the unavailable-usage helpers to the per-session and
aggregate cost cells they had been missed on, so Cursor rows never render a
confident $0.00; correct a doc comment that inverted the CONTEXT.md external
vs unmanaged distinction; split discoverSessions and parseSessionFile to
respect the 50-line guidance; drop an unused export.

Projects still absent from disk stay Unknown by design — an honest gap beats
a wrong answer.

Refs #1
…uous

Follow-up review of the previous commit found four defects in the new
filesystem-verified slug walk and stamp parser.

The walk stopped at its first match, but "/", "_" and "-" all slugify to "-",
so one slug can describe two directories that both exist (~/work/my_app and
~/work/my-app). It now collects matches, stops at two, and reports no project
when the answer is ambiguous — the same honest-gap rule the walk already
applied when nothing matched.

The slug cache only covered discovery, so every parsed session re-walked from
the root; it is now an adapter field shared by both paths.

The activity window consulted prompt stamps only when the tracking database
had no first edit, so a row recording a first edit but no last fell through to
file mtime — reintroducing the inflated span the stamps exist to prevent.
Either open end now reaches for the stamps.

The stamp regex matched a bare date shape anywhere in the transcript, so a date
quoted in pasted logs or model output could date the session; it now requires
the <timestamp> wrapper Cursor writes.

Refs #1
Cursor's AI-tracking database writes a model of `default` when the user let
Cursor choose. That was dropped, so most Cursor sessions showed no model at
all and read as though CodeMie had failed to read one.

Cursor's own usage export names those same conversations `auto` in its Model
column, so "Auto" is Cursor's word rather than an invention, and it says the
right thing: the user delegated the choice. Locally the split is Auto 79.5%
/ grok-4.6 20.5%, against auto 90% / grok 10% in the export for the same
account — the same shape at a different grain.

What is still never done is stamping a session with whichever model Cursor
happens to default to today; that remains unknowable from local data.

Refs #1
Cursor sessions were shown by default on the reasoning that an analytics-only
agent has no managed variant, so its sessions are unmanaged rather than
external. That drew the line in the wrong place: what --include-external is
really about is whether CodeMie launched the session, and CodeMie did not
launch these.

The ownership gate now applies to every agent alike — no ownership marker
means native-external, opt-in behind the flag. Set Cursor up through CodeMie
and its sessions carry a marker, keeping the plain 'native' tag and showing
with no flag; that path is covered by a new test even though nothing produces
such a marker today.

Removes the native-unmanaged provider tag and its formatter branch, and
updates the CONTEXT.md glossary and the AGENTS.md plugin row, which both
documented the old rule.

Deviates from spec #1 user story 2 ("visible by default without any extra
flag") at the user's explicit direction.

Refs #1
Switch Cursor session discovery to state.vscdb's composerHeaders table
as the primary source, per ADR 0001. agent-transcripts/*.jsonl scanning
found only ~65 real sessions on a sample machine; composerHeaders has
~463, keyed by composerId (the same id used by agent-transcripts and
ai_code_hashes.conversationId).

- New cursor.state-db.ts: fail-soft composerHeaders reader, tolerant of
  the undocumented flat-column or key/value row shape. Draft sessions
  (isDraft: true) never enter the returned index.
- cursor.paths.ts: getCursorStateDbPath(), with platform-specific
  resolution (macOS/Linux/Windows) for state.vscdb, independent of
  ~/.cursor; CURSOR_HOME still relocates it for tests.
- cursor.session.ts: discovery unions composerId sets from
  composerHeaders and agent-transcripts. A header's own
  workspaceIdentifier.uri.fsPath, activeBranch.branchName /
  createdOnBranch, and totalLinesAdded/totalLinesRemoved/
  filesChangedCount are now authoritative; the old slug-walk project
  guess and content-hash-derived file list only run as a fallback for
  a session with a transcript but no header row.
- Threaded an optional filesChangedCount through ParsedSession.metrics,
  MetricDelta and the aggregator (additive, defaults to today's
  path-derived count when unset) so a composerHeaders-only session's
  real files-changed total can surface without fabricating per-file
  entries.
- native-loader.ts: synthesizeRawSession's default branch resolution
  now falls back to parsed.metadata.branch when no message carries a
  gitBranch to vote over — needed because a composerHeaders-only
  session (no transcript) has no messages to stamp a branch onto at
  all, which is the majority shape of session this ADR surfaces.

--include-external gating and analyticsOnly behavior are unchanged.

Refs #10
Per ADR 0001 / issue #11: replace assumed-success tool-call counting
and always-unpriced Cursor sessions with real per-turn signal from
state.vscdb's cursorDiskKV table (bubbleId:<composerId>:<bubbleId>
rows, one per turn/message, joined by the same composerId used for
composerHeaders discovery).

- New cursor.bubbles.ts: fail-soft cursorDiskKV reader, scoped per
  composerId via a parameterized LIKE query (never scans the whole
  table). Sums toolFormerData.status into per-tool success/failure
  counts ('completed' -> success, 'error'/'cancelled' -> failure,
  'loading' -> not counted), and totals tokenCount.inputTokens/
  outputTokens across bubbles, flagging hasTokenSignal when any
  bubble carried a nonzero count.
- cursor.session.ts: parseSessionFile now sets metrics.toolStatus
  from bubble tool outcomes (independent of transcript presence, so
  header-only sessions — the majority — get real tool stats too) and
  resolves usageMeta via resolveUsageMeta: a session with any token
  signal gets usagePartial: true + tokensByModel (summed tokens
  attributed to the conversation's single recorded model, or
  'unknown'); a session with none keeps the existing
  usageUnavailableReason path, never a fabricated $0.00.
- BaseSessionAdapter.ts: added usageMeta.tokensByModel — a
  session-level token total for an adapter with no per-message usage
  a standard usage-readers.ts reader can walk.
- cost-enricher.ts: enrichCosts() falls back to tokensByModel
  (routed through the same pricing table as every other agent) only
  when the per-message/per-agent readers produced nothing, so an
  agent with a working reader is never overridden.

aggregator.ts and native-loader.ts needed no changes: toolStatus was
already threaded end-to-end from a prior change, and the report/cost
UI already renders usagePartial generically.

Refs #11
…lytics API note

Add `docs/CURSOR_INTEGRATION.md`: overview of the analytics-only Cursor
agent, the four local data sources and how they join on `composerId`, the
`CURSOR_HOME` override and per-OS `state.vscdb` paths, schema/versioning
stance, a troubleshooting section, and developer guidance on fixtures,
read-only connection safety and schema evolution.

Add `docs/adr/0001-cursor-session-discovery-from-state-vscdb.md`, which six
source files already cite but which did not exist, recording why an
undocumented store is read and the constraints that come with it.

Record Cursor's Enterprise Team Analytics API in the external-integrations
guide as a known, deferred capability: enterprise-admin-scoped, no token or
cost fields at any tier, a future integration gated on both a token and an
explicit opt-in flag, scoped to the requesting user only, and blocked on
having no join key to a local composerId-keyed session.

Cross-reference the new guide from AGENTS.md, docs/ANALYTICS-REPORT.md and
the external-integrations guide.

Closes #9
Closes #12
…emetry

The report labelled sessions with no local token signal "Included" — Cursor's
own word for plan-covered usage — and the session modal called their cost
"covered by subscription". Both state a billing fact we cannot know: the cost
column is an API-equivalent estimate, not a bill, and missing telemetry is not
evidence the usage was free. Every unmeasurable cost and token cell now shows an
em dash; mixed groups still show the sum of whatever was measured.

Overview's Est. cost and token KPIs also keyed off the summed totals, so a
Cursor-only view (the shape you get after deselecting the measured agents)
collapsed to bare dashes that read as a broken agent-chip filter. They now key
off session provenance, and both Overview and Cost append a note saying local
token telemetry is absent for the sessions in view. The Cost banner no longer
blames rotated transcripts for every unpriced session, which contradicted that
note for analytics-only agents.

Agent chips still filter by agent name only, and tool-call tables are untouched.
Cursor delegates model choice and records `default` (displayed as Auto), which
matches no pricing row, so sessions with genuinely recovered tokens priced out
at $0. A blank is less honest than a labelled floor when the token counts
themselves are real: an unpriced model with tokens is now costed at the
published Claude Sonnet API rate, marked `estimated` on the per-model row, and
forced to `usagePartial` so the report badges it as a floor rather than a bill.

Real attribution still wins — a model the table can price never touches the
stand-in — and an unpriced model with zero tokens stays at $0 rather than
becoming an invented estimate of nothing. The session keeps its own model label
(Auto is not renamed to Sonnet, per ADR 0001) and coverage diagnostics still
list it under unpriced models.

The session-modal partial badge and the Cost banner now describe both ways a
figure can be a floor: sparse token counts, or borrowed rates.
…unts

The Cursor docs described `tokenCount` as sparse, which understated the gap:
recent builds write zero or omit the field entirely while `toolFormerData` keeps
working, so tool enrichment is reliable and token enrichment is usually empty.
An operator reading a Cursor-only report needs to know that the resulting dashes
are absent data rather than a broken agent-chip filter or a free session.

Records the field evidence (469 sessions, 0 with tokens, 24 with tool calls;
nonzero-token composers only 354-408 days back and gone from composerHeaders),
and states the two non-fixes explicitly: widening discovery max-age to harvest
year-old bubbles, and inferring tokens from context fill, transcript length, or
tool-call counts. Also aligns the cost wording with the report — em dash, never
"Included" or "covered by subscription" — and documents the Sonnet stand-in rate
for recovered tokens under an unpriceable model.
Adds the only network call in the analytics path, gated so it cannot happen by
accident: it requires BOTH --cursor-team-analytics at invocation AND
CURSOR_TEAM_ANALYTICS_API_KEY. A configured credential alone is deliberately not
enough — reading the local machine is a promise CodeMie already makes, calling a
remote service is not, so it stays an explicit act.

Scope is the report owner alone: only `by-user` endpoints (agent-edits, tabs,
models, commands), filtered to their own email. No team-level endpoint and no
leaderboard, so a colleague's activity can never reach a personal report.

The results render in their own "Cursor Team API" view, hidden unless a pull
happened, and are never merged into the session table. That separation is forced
by the data: the API returns per-user/per-date aggregates with no composerId to
join on and no token or cost fields to join with, so a merge would have to invent
both a key and a figure. Nothing in the section contributes to any cost or token
total elsewhere in the report.

Fail-soft throughout — missing key, HTTP error, DNS failure, or schema drift
degrades to an omitted or explicitly-partial section rather than taking down the
local report, which is the part that always works.
…PI pull

The report gained two behaviours a reader cannot infer from the UI alone: cost
and token cells that dash out when nothing is measurable, and an opt-in remote
section. Both were undocumented, which is how a dash gets misread as free usage
and an all-dash Cursor-only view gets misread as a broken agent chip.

Adds a "when cost and tokens show —" reference, an analytics-only-agents section
setting the expectation that recent Cursor builds record no billable tokens at
all, and a Cursor Team Analytics section stating what the pull requires (both a
flag and a key), what it returns (edit and activity counters), what it does not
return (tokens or cost), and why it renders apart from the session table.

Also drops the Cost view's subscription framing, which said the reader does not
pay per token — a billing fact the local logs do not record.
Closes #21. Cursor's local stores stopped recording billable tokens and the Team
Analytics API never had them, but the dashboard's Usage export does. A real
2026-09-05 export carries 39,952,466 tokens and $25.25 across 61 events — while
every one of those rows is Kind=Included.

That is the trap this parser exists to avoid. "Included" is Cursor's billing
category, meaning covered by your plan, not a statement that the usage was free
or unmeasured. Reading it as "no cost" would discard the only accurate Cursor
figures available, so Kind is recorded and never used to zero anything out.

Two export shapes are in the wild and both parse: most end with a Cost column,
while at least one variant ships Requests instead and has no cost at all. Tokens
are the durable part, cost is optional, and the section says so when it is
missing. Cost cells also carry words such as "Free", which contribute zero
rather than poisoning the total with NaN.

Rows are per-event with no composerId, so they render as their own labelled
section rather than being joined to sessions or added to any cost figure
elsewhere — the report shows Cursor's own numbers beside CodeMie's, not summed
into them.

The User column is filtered to the report owner by default, overridable with
--cursor-usage-user, because the Cursor account's address is often not the
configured CodeMie one. A filter that matches nothing warns and names the emails
actually present instead of yielding a silently empty section.
…mber path

Closes #23. The feature is kept and its gate is unchanged, but every surface
that described it was written as though any team member could use it to answer
"what did Cursor cost?". Two things make that false: the endpoints need an
admin-scoped key an ordinary member cannot obtain, and they return no token or
cost fields at any tier even for an admin.

So a member following the old copy would chase a credential they cannot get,
for data that would not answer their question, and land on a 401 that told them
to go set CURSOR_TEAM_ANALYTICS_API_KEY — the exact dead end this fixes.

CLI help, the failure message, and the report's empty state and section header
now say enterprise-team-admins-only, disclaim tokens and cost, and point at
--cursor-usage-csv instead. The audience wording lives in two exported
constants so the CLI and the report cannot drift apart, and a test asserts the
member hint never names the API key env var.
Closes #24. The docs presented Team Analytics as the way to close the Cursor
token gap. It is not, for two independent reasons: it needs an admin-scoped key
an ordinary member cannot obtain, and it returns no token or cost field at any
tier even for an admin.

ANALYTICS-REPORT.md now opens that topic with an audience table — anyone gets
real tokens and cost from the usage CSV with no credential and no network call;
enterprise admins additionally get edit/activity aggregates from the API — and
says plainly that if the question is "what did Cursor cost?", the API cannot
answer it for anyone.

Documents the two facts the real exports forced: Kind=Included is a billing
category rather than zero usage (a verified export had all 61 rows Included and
still carried 39,952,466 tokens and $25.25), and two export shapes exist, one
without a Cost column at all. The analytics-only-agents section no longer dead-
ends on "no local store has the numbers" — it now points at the export that
does. CURSOR_INTEGRATION.md and the external-integrations guide follow suit.
An export with **no `User` column** imported nothing. The filter keyed off the
caller's option rather than the column's presence, so a personal export that
omits `User` compared every row against `''`, dropped all of them, and reported
"matched no rows" for a perfectly valid file. Filtering now happens only when
the export actually identifies users — with no `User` column there is no one
else's data present to exclude.

A thousands-separated `Cost` cell under-reported by 1000x. `money()` matched a
number out of `1,234.50` without stripping separators first and got `1` — worse
than the NaN it was written to avoid, because it is silently plausible. It now
strips separators the way `num()` already did.

Day buckets used a UTC slice of the timestamp while every other day-grouped view
in the report buckets locally, so one report had two definitions of "day" and an
evening event landed on tomorrow here and today elsewhere. Days are now derived
in local time.

Also collapses CursorUsageGroup/CursorUsageDay onto one CursorUsageBucket and
the two identical report tables onto one builder, drops a parsed-but-unrendered
maxMode field, and repairs a doc comment in cursor.team-analytics.ts where an
old sentence had been welded onto new prose without an antecedent.

Adds the full 61-event export as a fixture so issue #21's stated verification
target is executed rather than described: 61 events, $25.25, 39,952,466 tokens,
including the two `Free` cost cells the real file contains. A reviewer also
flagged a BOM-prefixed export as fatal; a test proves otherwise — the header's
own .trim() already strips U+FEFF — and it is kept as a regression guard.
…ping branch

Reopens the substance of #19/#20. Nobody on the team has an enterprise-admin
Cursor account, so the Team Analytics success path was never exercised: every
probe against the live API returned 401 Invalid Team API Key. Shipping code that
makes network calls on a path no one has run is the risk being removed here —
this is not a judgement that the implementation is wrong.

The trade is easy because the feature never answered the question people
actually have about Cursor. The API returns no token or cost field at any tier,
and its key is unobtainable by an ordinary team member. The path that does work,
for everyone, is the dashboard usage export via --cursor-usage-csv, which needs
no credential and makes no network call.

Removed: the fetcher and its tests, the --cursor-team-analytics flag, the
payload field, the report view and its nav entry, and the report-side separation
tests. Analytics is local-only again apart from that opt-in file import.

The full implementation — gate, by-user scoping, admin-only framing, fail-soft
handling and 10 passing unit tests — is preserved on branch
feature/cursor-team-analytics-untested, and the docs now point there rather than
pretending the capability does not exist.
Closes #22. Downloads the same CSV --cursor-usage-csv reads from disk, and feeds
it through the same parser, so a fetched export and a hand-saved one can never
be interpreted differently.

Three things must all be present before any request leaves the machine: the
--cursor-usage-fetch flag, CURSOR_USAGE_EXPORT_URL, and a session cookie. Any
one missing means no call at all.

CodeMie ships no endpoint URL. Cursor's dashboard export is undocumented and can
change or vanish without notice; baking such a URL into the product means
quietly breaking later, so the operator supplies it and knows exactly what is
being called.

On the credential: it is a browser cookie for cursor.com, which on a signed-in
machine lives in Cursor's Chromium jar encrypted against the OS keychain.
CodeMie does not decrypt that — prying a credential out of another application's
protected store is not something an analytics command should do. It makes a
cheap read-only check of Cursor's own plaintext state database, and otherwise
expects CURSOR_SESSION_TOKEN, keeping the handover a deliberate act. Supplied
values are shape-checked as <userId>::<jwt> so an admin crsr_ key — a different
credential for a different API, rejected by this endpoint — cannot be sent by
mistake.

The token never reaches a log line: failures name the status code and the
endpoint host only, and a test asserts no log output contains it on either the
success or the failure path. 401/403/500, a sign-in redirect returning HTML, a
bad URL, or a transport error all omit the section and leave the local report
intact.

Verified end-to-end against a local stub endpoint: correct Cookie header, no
Authorization header, date parameters taken from the report window, and the
response parsed to the real export's 61 events / 39,952,466 tokens / $25.25.
The Cursor work added behaviour a reader cannot confirm by looking: dashes that
mean "unmeasurable" rather than zero, a sidebar section that only exists once an
export is imported, and a fetch gated on three separate things. Without a way to
check, "it shows dashes" is indistinguishable from "it is broken".

Six ordered checks, each stating what to run, what to expect, and what a
different result means. They build up: a report exists, external sessions
appear, the Cursor-only view goes honestly blank, the usage export fills it in,
the optional fetch does the same without a file, and the test suites for anyone
changing the code.

Every command was executed against the real product and real exports before
being written down, including the failure paths — the fetch gate with a missing
cookie, and the "matched no rows" email-mismatch case.

The totals cross-check is Python rather than the obvious awk one-liner because
the obvious one is wrong: the export ships CRLF endings and two column layouts,
so summing the last column silently adds up Requests on the no-Cost variant and
prints a plausible, entirely wrong dollar figure. That trap is called out in the
text so nobody reinstates it.
…p the Cost fold-in

Keeps the code-review changes that stand on their own: the `cursor.sqlite.ts`
extraction the three readers now share, the parameterised `LIKE` in the usage
fetch, and the orphaned JSDoc left behind by the Team Analytics removal.

Removes `cursorCostContrib()` and its Cost-tab call sites. Folding the imported
CSV into one tab by special case left Overview and Cost disagreeing, kept 39.9M
tokens out of "Tokens by model", and vanished entirely when no local Cursor
session existed. The replacement converts the CSV into ordinary sessions so it
flows everywhere by construction.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
Claude-Session: https://claude.ai/code/session_01KQwQ1VNMjpMrxk1EB9eoyF
`composerHeaders` names its last-update stamp `lastUpdatedAt`. The reader looked
for `updatedAt`, a key no real row carries, so every window collapsed to its
creation instant: all 42 Cursor sessions in a real report showed a zero
duration, making their Activity and Efficiency figures meaningless and leaving
nothing for the usage-CSV matcher to match a timestamp against.

Reads `lastUpdatedAt` first and keeps `updatedAt` as a fallback. A header that
dates only its creation — about half of a real table — now closes the open end
from the tracking database's recorded edits instead of mirroring `createdAt`.
On real data: 17 of 42 sessions gain a genuine window, median 15.4 minutes.

The fixture helper now writes what Cursor writes, with the legacy spelling
behind an explicit flag so the fallback stays covered.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
Claude-Session: https://claude.ai/code/session_01KQwQ1VNMjpMrxk1EB9eoyF
Gokhan Ozdemir and others added 5 commits September 7, 2026 15:38
The import used to be a panel of its own. A run could pull in 39,952,466 real
tokens and $25.25 of real cost and still have every headline figure in the
report ignore them.

`cursor-usage-loader.ts` follows the OTEL precedent — the existing answer to "a
flat per-event file that must behave like sessions" — and converts the export
into `RawSessionData` plus a canonical `SessionCostIndex`. The aggregator,
formatter, exporter and report client then treat it like anything else, so no
consumer needs a special case and none can forget one.

Each event is matched to the Cursor session whose activity window contains its
timestamp. Several containing windows means the data cannot say which session
spent the tokens, so the event goes to a per-day rollup rather than a guess —
the same refusal 81dbeb1 makes for an ambiguous Cursor slug. Restoring session
durations first is what makes this work at all: on the real export, 20 of 61
events now land on a session, the rest in 3 daily rollups, and the totals still
sum to the file's own exactly once.

Every line the loader produces carries a new `ModelCost.costBasis:
'vendor-billed'` — Cursor billed that amount, CodeMie only recorded it. Absent
elsewhere, so every existing producer keeps today's estimate semantics.

Also fixes, by construction rather than by patch:
- Overview and Cost read one number, not two that disagree.
- "Tokens by model" and "Cost by model" show the same models, unsuffixed.
- Coverage by agent reports Cursor as priced for the sessions the export reached.
- `--cursor-usage-csv` without a report flag is no longer silently discarded: it
  is resolved right after `source.load()` and prints what it imported, so the
  terminal totals and `--export` see it too.
- A CSV-only run no longer needs a local Cursor session to show anything.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
Claude-Session: https://claude.ai/code/session_01KQwQ1VNMjpMrxk1EB9eoyF
… import

Standards axis:
- Use the `@/` alias instead of `../../../` deep relative imports (AGENTS.md,
  Common Pitfalls), which the new files had inconsistently mixed with `@/`.
- Extract `pushInto()` and `modelOf()`, each of which was duplicated across two
  call sites in the loader.
- `buildCursorUsageSessions` now narrows to Cursor's own sessions itself instead
  of making the caller filter on a bare `'cursor'` literal, so the agent name
  lives in one place and the caller stops reaching two levels into
  `RawSessionData`.

Spec axis: the Cost view had lost the line stating that Cursor rows are Cursor's
own billing rather than a CodeMie estimate — the distinction the whole import
exists to make. It is back, driven off `costBasis` on the filtered sessions, so
it appears exactly when such a row is on screen.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
Claude-Session: https://claude.ai/code/session_01KQwQ1VNMjpMrxk1EB9eoyF
…or on its own

The tab was a leftover from when the import was isolated. Now that every event
is counted once as an ordinary session, a per-agent tab in the sidebar is a
special case no other data source has — and a second place to read a number the
rest of the report already shows.

Removes the view, its nav entry, the `data-optional` nav mechanism it was the
sole user of, and the `meta.cursorUsage` payload field none of them need now.

Docs: Cursor gets its own section, ordered why-first — CodeMie never launches
it, recent builds record no billable tokens on disk, the local stores were
measured rather than assumed (0 of 469 sessions carried a token signal), and the
export is therefore the only honest source. The old "Analytics-only agents"
subsection shrinks to a pointer instead of restating it.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
Claude-Session: https://claude.ai/code/session_01KQwQ1VNMjpMrxk1EB9eoyF
- Added `.scratch/` to `.gitignore` to exclude temporary files.
- Deleted `CONTEXT.md` and `0001-cursor-session-discovery-from-state-vscdb.md` as they are no longer relevant to the project.
.gitignore already excludes .scratch/; drop the previously committed files from the tree so they stay local only.

Co-authored-by: Cursor <cursoragent@cursor.com>
@gokhanozdemir
gokhanozdemir force-pushed the feature/cursor-analytics branch from 3585cbf to 50fbc10 Compare September 7, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant