You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[FEAT] Cursor integration has no cost/token tracking, unlike Claude Code and OpenCode #2796
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.
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.
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.
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.
Activity
feiiiiii5 commentedon Oct 1, 2026
Area triager pass (comment only, not an approval).
Your mechanism reading checks out.
parsers.py:143callsspec.extract_overrides(payload)for every hook event, and the CursorClientSpecleaves that fieldNone, so nothing reads a transcript andinput_tokens/output_tokensstay unset.One correction to the framing, because it changes what the fix actually delivers: adding an
extract_overrideshere would give Cursor tokens, not cost. No Agent Skills client gets a dollar cost from the hook path today:HookEvent.costis read literally off the payload —parsers.py:274,cost=_number(payload.get("cost")).tracing.py:374-378setsSpanAttributes.COST.COSTonly when that literal is non-zero.src/core/trulens/core/otel/client_hooks/imports no provider and installs noCostComputer, and neither doessrc/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.pyexpects 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_overridesis called for every event, so the transcript read has to be gated. Claude's comparespayload.get("hook_event_name") != "Stop"; Cursor's terminal event is"stop", lowercase, so that comparison copied verbatim never fires.input_tokens/output_tokensgo through_integer(parsers.py:52), which returnsNoneon a non-numeric value instead of raising. A schema mismatch therefore degrades silently to "no tokens" rather than surfacing.payload["transcript_path"], while your issue mentionsCURSOR_TRANSCRIPT_PATHas 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, sotest_generation_span_contains_complete_genai_contractpassinginput_tokens=12shows 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.
avanish-garg commentedon Oct 1, 2026
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)" sectiondocuments
transcript_path: string | nullas 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. Sopayload.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_PATHdoes 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_namecasing forstop: still not 100% nailed down, but stronger evidence thanbefore. The docs' own
workspaceOpenexample shows"hook_event_name": "workspaceOpen"—matching that hook's
hooks.jsonkey exactly, camelCase and all. By that same pattern, thestophook's key (
"stop", lowercase, confirmed throughout every config example on the page) shouldproduce
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_nameitself stays Cursor's native camelCasekey. 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 theshape, 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_pathpayload mechanism, thehook_event_namevalue) vs. assumed-from-third-party-reverse-engineering (the JSONL
type/message.content[]/message.usagetranscript shape itself — Cursor's docs don't document that file's internal formatat all, officially or otherwise), so a maintainer with a real transcript can correct the right part
in one place.
Given the
transcript_pathmechanism is now first-party-confirmed, the only real open question isthe transcript file's internal schema and the
hook_event_namecasing — both already called out asunverified 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.