Skip to content

[FEAT] Cursor integration has no cost/token tracking, unlike Claude Code and OpenCode #2796

Open

Description

@avanish-garg

Feature Description

src/apps/cursor/trulens/apps/cursor/client.py's client_spec has no extract_overrides,
unlike both sibling "Agent Skills" clients — claude (src/apps/claude/.../client.py) and
opencode (src/apps/opencode/.../client.py) both have one. extract_overrides is the
mechanism these clients use to backfill data the live hook payload doesn't carry, by
reading a local transcript/session file referenced in the hook payload.

I checked Cursor's own hooks documentation (https://cursor.com/docs/agent/hooks) directly
rather than assuming parity is needed:

  • Cursor's hook payloads do include model directly, and afterAgentResponse includes
    the response text ("text" field) directly — correctly explaining why Cursor doesn't
    need an override for those two fields, unlike Claude Code, whose Stop hook doesn't
    reliably carry them.
  • Token usage (input/output tokens) is confirmed absent from every Cursor hook
    payload
    — the docs explicitly note it's "deliberately excluded."
  • Cursor's hook payload does include a transcript_path field (CURSOR_TRANSCRIPT_PATH
    env var), structurally identical in purpose to the field Claude Code's
    _transcript_overrides already reads to recover token counts. So the same fix pattern
    looks directly applicable here.

Reason

Cursor's own hooks documentation isn't specific about the transcript file's internal
schema. Cross-referencing several independent third-party tools that already parse Cursor
transcripts for their own purposes, the consistent claim is that Cursor uses the same
JSONL format as Claude Code, with the same role/message.content[] shape — consistent
with Cursor and Claude Code sharing the same underlying /v1/sessions hook contract. I
don't have a live Cursor installation to verify this against a real transcript file
myself, so I'm not confident enough in the exact schema to submit a PR yet — flagging the
gap and the fix's shape here first, in case a maintainer or another contributor with
Cursor access can confirm the transcript format (or has already reverse-engineered it for
something else).

Importance of Feature

Without this, Cursor users of TruLens's Agent Skills integration get zero cost/token
tracking today, while Claude Code and OpenCode users of the same integration surface get
real cost capture — an inconsistency in a tool whose core value proposition is cost/usage
observability. Happy to build the fix (reusing Claude Code's already-proven
_transcript_overrides parsing logic) once the transcript format is confirmed, or to try
it myself against a synthetic fixture with the caveat disclosed if that's preferred.

Activity

  1. feiiiiii5 commented on Oct 1, 2026

    @feiiiiii5
    Collaborator

    Area triager pass (comment only, not an approval).

    Your mechanism reading checks out. parsers.py:143 calls spec.extract_overrides(payload) for every hook event, and the Cursor ClientSpec leaves that field None, so nothing reads a transcript and input_tokens/output_tokens stay unset.

    One correction to the framing, because it changes what the fix actually delivers: adding an extract_overrides here would give Cursor tokens, not cost. No Agent Skills client gets a dollar cost from the hook path today:

    • HookEvent.cost is read literally off the payload — parsers.py:274, cost=_number(payload.get("cost")).
    • tracing.py:374-378 sets SpanAttributes.COST.COST only when that literal is non-zero.
    • src/core/trulens/core/otel/client_hooks/ imports no provider and installs no CostComputer, and neither does src/apps/. The only token-to-dollar conversions in the tree live inside provider endpoints that see a live API response (providers/anthropic/endpoint.py:57, providers/google/endpoint.py:154).

    So Claude Code and OpenCode get real token capture through their overrides, and no cost. Worth splitting this into the token half and the cost half, or restating the goal as tokens, so a merged PR doesn't read as "cost tracking now works for Cursor" while the cost column stays empty.

    If you do build the token half, the contract parsers.py expects is narrow — it reads only these keys out of the returned mapping: event_name, conversation_id, turn_id, operation_id, tool_name, client_version, model, input_tokens, output_tokens, prompt, response, tool_input, tool_output (parsers.py:147-280). Three traps:

    • extract_overrides is called for every event, so the transcript read has to be gated. Claude's compares payload.get("hook_event_name") != "Stop"; Cursor's terminal event is "stop", lowercase, so that comparison copied verbatim never fires.
    • input_tokens/output_tokens go through _integer (parsers.py:52), which returns None on a non-numeric value instead of raising. A schema mismatch therefore degrades silently to "no tokens" rather than surfacing.
    • Claude reads the path from payload["transcript_path"], while your issue mentions CURSOR_TRANSCRIPT_PATH as an environment variable. If Cursor only exposes the path that way, the override has to read the environment rather than the payload — a substantive difference from Claude's implementation instead of a reuse of it. Worth settling before writing the parser.

    I can't confirm the transcript schema either — I have no Cursor install to read a real file against, so I can't close the gap you flagged.

    A caution about testing it

    The existing Cursor hook tests are synthetic. _cursor() (tests/unit/test_otel_client_hooks.py:43) just merges kwargs into a dict, so test_generation_span_contains_complete_genai_contract passing input_tokens=12 shows the parser handles tokens, not that Cursor ever sends them. A fixture built from an assumed schema would look like verification of that schema while only exercising the parser.

    Whatever lands, name the assumed schema in the test docstring, and keep the version/role/content-shape expectations in one place so a maintainer with a real transcript can correct them in a single edit.

    Happy to review a PR against this when the format is settled.

  2. avanish-garg commented on Oct 1, 2026

    @avanish-garg
    ContributorAuthor

    Thanks for the detailed pass — going through each point against Cursor's current docs
    (https://cursor.com/docs/hooks, checked just now) rather than the third-party tooling I'd
    cross-referenced originally:

    transcript_path: payload field, not env-var-only. The "Common schema (all hooks)" section
    documents transcript_path: string | null as part of the base payload every hook receives
    ("Path to the main conversation transcript file (null if transcripts disabled)"), alongside
    hook_event_name, conversation_id, etc. So payload.get("transcript_path") is the documented,
    first-party-confirmed mechanism — not something I was inferring from the issue's own slightly
    sloppy phrasing. CURSOR_TRANSCRIPT_PATH does also exist, but as a separate environment variable
    ("Environment Variables" section) for hook scripts (command-based hooks spawned as subprocesses),
    not the payload-reading path this client uses. Good catch that the issue conflated the two without
    saying so explicitly — the code itself reads the payload field, which checks out.

    hook_event_name casing for stop: still not 100% nailed down, but stronger evidence than
    before.
    The docs' own workspaceOpen example shows "hook_event_name": "workspaceOpen" —
    matching that hook's hooks.json key exactly, camelCase and all. By that same pattern, the stop
    hook's key ("stop", lowercase, confirmed throughout every config example on the page) should
    produce hook_event_name: "stop", matching what the code checks. The "Available matchers by hook"
    table says stop: Matched against the value Stop (capital S) — but that's a separate label system:
    afterFileEdit's matcher value is "Write", beforeSubmitPrompt's is "UserPromptSubmit",
    neither matching those hooks' own camelCase names either. Reads like Cursor aligned the matcher
    vocabulary to Claude Code's own hook-type names for cross-compatibility (the page explicitly
    supports loading Claude Code hooks), while hook_event_name itself stays Cursor's native camelCase
    key. I'm fairly confident in that reading but, like you, have no live Cursor install to confirm the
    literal field value — flagging the same way you flagged the transcript schema.

    Tokens vs. cost — agreed, this needs restating. You're right that nothing in this path produces
    a dollar figure; I'll update the issue title/description and PR to say "token tracking," not
    "cost/token tracking," so a merged PR doesn't imply the cost column fills in too.

    Test caution — will name the assumed parts explicitly. Fair point that
    test_cursor_stop_recovers_usage_from_transcript's synthetic fixture proves the parser handles the
    shape, not that Cursor emits it. I'll add an explicit note in the test docstring naming exactly
    which parts are assumed-from-docs (the transcript_path payload mechanism, the hook_event_name
    value) vs. assumed-from-third-party-reverse-engineering (the JSONL type/message.content[]/
    message.usage transcript shape itself — Cursor's docs don't document that file's internal format
    at all, officially or otherwise), so a maintainer with a real transcript can correct the right part
    in one place.

    Given the transcript_path mechanism is now first-party-confirmed, the only real open question is
    the transcript file's internal schema and the hook_event_name casing — both already called out as
    unverified in the original PR description, not new information, just better-sourced now. Happy to
    update the PR with the retitled framing and the more explicit test docstring once you'd like to see
    that, or to hold if you'd rather settle the schema question first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions