feat(analytics): make the Cursor usage CSV a first-class cost source - #541
Closed
gokhanozdemir wants to merge 34 commits into
Closed
feat(analytics): make the Cursor usage CSV a first-class cost source#541gokhanozdemir wants to merge 34 commits into
gokhanozdemir wants to merge 34 commits into
Conversation
gokhanozdemir
force-pushed
the
feature/cursor-analytics
branch
2 times, most recently
from
September 7, 2026 12:34
b3fe9bd to
3585cbf
Compare
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
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
force-pushed
the
feature/cursor-analytics
branch
from
September 7, 2026 12:39
3585cbf to
50fbc10
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
totals under identical filters, and "Tokens by model" excluded 39.9M tokens from the one chart
named after tokens.
--cursor-usage-csvwas handled entirely inside the report branch, socodemie analytics --cursor-usage-csv f.csvopened nothing and warned about nothing.Separately, all 42 Cursor sessions in a real report had
durationMs = 0, which made their Activityand Efficiency figures meaningless.
Changes
Cursor session durations (
fix)composerHeadersnames its last-update stamplastUpdatedAt; the reader looked forupdatedAt,a key no real row carries. Every window collapsed to its creation instant.
recorded edits instead of mirroring
createdAt.Canonical conversion
cursor-usage-loader.ts, mirroringotel-loader.ts— the existing answer to "a flatper-event file that must behave like sessions". It synthesizes
RawSessionDataplus a canonicalSessionCostIndex, so the aggregator, formatter, exporter and report client need no special case.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
81dbeb1makes for an ambiguous slug.ModelCost.costBasis?: 'vendor-billed'marks these as Cursor's own billed figures ratherthan a CodeMie estimate. Optional, so every existing producer keeps today's semantics.
Wiring
source.load()and prints what it imported, so a run without anyreport flag is no longer silent.
cursorCostContrib()and the Cost-tab fold-in it powered.Report surface
data-optionalnav mechanism it was thesole user of, and the
meta.cursorUsagepayload field. A per-agent tab is a special case no otherdata source has.
the filtered view.
Kept from a prior review: the
cursor.sqlite.tsextraction the three readers share, and aparameterised
LIKEin the usage fetch.Docs: Cursor gets its own section in
ANALYTICS-REPORT.md, ordered why-first — CodeMie neverlaunches 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.mdandexternal-integrations.md, whose "not added to any cost figure elsewhere" claims this makes false.Testing
npx vitest run --project unit— 4029 passing, run repeatedly.cursor-usage-loader.test.tsdrives the real parser over the real fixtures and assertsconservation: however the matcher splits the file, the output sums to 61 events / 39,952,466
tokens / $25.25 exactly once. Also covers the no-
Costexport variant, the ambiguous-window case,and per-day rollups.
native-loader-cursor.test.ts, plus a fallback test for thelegacy
updatedAtspelling. The fixture helper now writes the key Cursor actually writes.payload-buildertests:costBasissurvives ontoperModelCost, and Coverage reports Cursoras priced with no native log.
$25.25and 39,952,466 tokens appear once in thesession set; Coverage shows Cursor priced 9/45; no
(cursor)-suffixed model names.Known gap
--exportstill carries no cost or token columns — for any agent, not just Cursor.SessionAnalytics.tokens/costUSDare declared but never populated, andexporter.tshas no costfields at all. Adding them is a general analytics feature that changes
--exportoutput for everyuser, so it is deliberately out of scope here. The CSV's sessions do reach
--exportand theterminal totals; only cost does not.
Breaking Changes
None.
ModelCost.costBasisis optional and additive; the removed Cursor Usage CSV view showedno number that is not now available in the ordinary views.
Checklist