Skip to content

feat(telemetry): emit skill invocations as cli_skill_invoked events - #2023

Merged
jdx merged 5 commits into
mainfrom
jdx/skill-telemetry
Aug 21, 2026
Merged

feat(telemetry): emit skill invocations as cli_skill_invoked events#2023
jdx merged 5 commits into
mainfrom
jdx/skill-telemetry

Conversation

@jdx

@jdx jdx commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

https://entire.io/gh/entireio/cli/trails/1070

Skill events are already extracted per agent and recorded in session state (SkillEventExtractor, appendEventSkillEventsToState), so internal skill usage is measurable from checkpoint refs — but nothing reached PostHog, leaving external skill adoption invisible while agent-help usage is tracked. That asymmetry biases any skills-vs-agent-help comparison toward the instrumented side.

This PR forwards exactly the newly-appended (deduped) skill events to a detached cli_skill_invoked event: skill name, agent, detection signal, event type.

  • Content-free by construction — no prompt text, arguments, or transcript content, mirroring the plugin-name-only rule in BuildPluginEventPayload.
  • Gated on the same opt-in telemetry setting as command tracking, plus the ENTIRE_TELEMETRY_OPTOUT env opt-out.
  • Hook lifecycle paths never produce cli_command_executed (the hooks tree is hidden), so this is the only telemetry signal that a skill fired.

appendEventSkillEventsToState now returns the appended events instead of a bool so both call sites (TurnStart slash-command path and persistEventMetadataToState) forward exactly what was new.

Tests: payload shape + content-free assertions in telemetry, append/dedup semantics in lifecycle_test. Full suite: 9,170 unit / 492 integration / canary green.

🤖 Generated with Claude Code

Which skill names are sent

Skill names are arbitrary user tokens, so the emitted skill property comes
from a closed vocabulary: a recognized Entire skill name, or one of two fixed
categories (custom, entire:unlisted). Recognition matches the leaf name
after stripping Entire's plugin namespace — not the entire: prefix itself,
which comes from whatever plugin the user installed or authored and so vouches
for nothing.

Two scoping choices, both taking the direction whose error is visible:

  • Unlisted Entire skills still count. The leaf list lives in this repo
    while the skills ship from entireio/skills, so a skill released upstream
    before the list is updated reports as entire:unlisted — a lagging list
    undercounts the per-skill breakdown but never adoption volume.
  • Unnamespaced installs are not matched. Entire's skills are advertised as
    cross-agent and can arrive without a namespace (Pi normalizes
    /skill:<name> to a bare name; a hand-copied SKILL.md has none), and
    those report as custom. Matching bare leaves would fold Claude Code's
    built-in /review and any local /search into Entire's numbers — an
    overcount invisible in the data, where this undercount has a known cause.
    Worth revisiting if cross-agent installs become a meaningful share.

entireio/pr-review-toolkit skills are deliberately out of scope: the CLI's
install hints for it are still placeholders (skilldiscovery/registry.go), so
nothing ships under that namespace yet.

Counting caveats

Which paths actually emit. The condense path (commit and session-end) is
where transcript-extracted events reach telemetry in practice. The turn-end
snapshot around HandleTurnEnd only yields events when the turn had mid-turn
commits — finalizeAllTurnCheckpoints early-returns on an empty
TurnCheckpointIDs — so in an ordinary turn it is a no-op and the condense
path provides the coverage. Two gaps stay open by design: sessions the
exited-owner sweep marks ENDED without condensing (when sweepCondenseBudget
is exhausted) never emit their tool-based events, and claudeSkillEventID
falls back to a transcript-relative id when tool_use_id is absent, which
shadow-vs-live extraction could double-count — pre-existing, and now costing a
telemetry event rather than a duplicate metadata row.

One action could emit twice. A typed /entire:review can produce a
prompt_slash_command event from the hook path and a skill_tool_use event
from the transcript extractor; the ids differ, so both are legitimately new and
both emit. event_type disambiguates, so count by event_type, or count
distinct actions rather than raw cli_skill_invoked volume
— a raw count
would inflate. Deliberately not deduped across signals: the two events are
distinct records the session UI relies on for its collapse behavior, so
collapsing them in telemetry would mean either dropping a recorded event or
adding a correlation identifier to the payload.

Measured rather than assumed: across 72 real Claude Code transcripts on this
machine, 8 contain Skill tool invocations and none pairs one with a literal
/<name> user message, so in practice the tool signal is what appears. The
inflation is a real possibility to guard the insight against, not something
observed.

Copilot AI lite review requested due to automatic review settings August 17, 2026 20:29
@jdx jdx changed the title jdx/skill telemetry feat(telemetry): emit skill invocations as cli_skill_invoked events Aug 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds detached telemetry for “skill invocation” events surfaced by agent hooks, and wires lifecycle session handling to forward newly recorded SkillEvents as privacy-preserving analytics events (skill/agent/signal/event_type only).

Changes:

  • Introduces SkillInvocation, BuildSkillEventPayload, and TrackSkillInvocationsDetached to emit cli_skill_invoked events from a detached subprocess.
  • Updates lifecycle session mutation flow to dedupe/append skill events and forward newly appended events to telemetry (gated by the user’s telemetry opt-in setting).
  • Adds unit tests validating skill payload shape and that appendEventSkillEventsToState returns only newly appended events.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
cmd/entire/cli/telemetry/detached.go Adds skill-invocation telemetry payload + detached tracking loop.
cmd/entire/cli/telemetry/detached_test.go Adds tests for building skill telemetry payloads and privacy constraints.
cmd/entire/cli/lifecycle.go Threads newly appended skill events through session state + forwards them to telemetry behind opt-in gating.
cmd/entire/cli/lifecycle_test.go Adds coverage for “return only newly appended skill events” behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cmd/entire/cli/telemetry/detached.go
Comment thread cmd/entire/cli/telemetry/detached.go
Comment thread cmd/entire/cli/lifecycle.go Outdated
@jdx
jdx marked this pull request as ready for review August 17, 2026 20:37
@jdx
jdx requested a review from a team as a code owner August 17, 2026 20:37
@jdx

jdx commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three review findings in 9c05a09:

  • Lock scope (the substantive one): persistEventMetadataToState now returns the newly-appended skill events, and every MutateSessionState call site forwards them to telemetry only after the mutation returns — settings load and detached spawn no longer run while the session gate is held, and never fire for events that failed to persist.
  • Empty agent defaults to "auto", matching BuildEventPayload.
  • Process burst capped at 10 detached senders per call (maxSkillInvocationsPerTrack); real turns carry at most a few events, the cap guards the pathological case.

Full suite re-run: 9,170 unit / 492 integration / canary green.

@jdx
jdx requested a lite review from Copilot and removed request for Copilot August 17, 2026 21:15
@jdx
jdx requested a balanced review from Copilot August 17, 2026 21:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (2)

cmd/entire/cli/lifecycle.go:1595

  • This only forwards hook-provided events that passed through appendEventSkillEventsToState. Claude Code's SkillEventExtractor runs later during condensation (strategy/manual_commit_condensation.go:1116,1162) and its results are merged directly into checkpoint metadata at line 396, so skill_tool_use / tool_invocation events never reach this loop or PostHog. Wire newly merged extractor events into the telemetry path (while deduping against state), otherwise the new event undercounts the main tool-based detection path.
	for _, ev := range appended {
		invocations = append(invocations, telemetry.SkillInvocation{
			Skill:     ev.Skill.Name,
			Agent:     ev.Source.Agent,
			Signal:    ev.Source.Signal,

cmd/entire/cli/telemetry/detached.go:205

  • Raw skill names are not content-free. The prompt extractor accepts any leading token matching /<token> (agent/skill_events_prompt.go:12,48-61), and Claude's extractor copies the arbitrary Skill input verbatim (agent/claudecode/transcript.go:328-341). A custom command such as /customer-acme-incident-123 would therefore be sent to PostHog. This differs from plugin telemetry, which explicitly allowlists official names before sending (plugin.go:71-85). Restrict raw names to a known allowlist or map unknown/custom names to a fixed category before adding this property.
		"skill":           inv.Skill,

@jdx
jdx force-pushed the jdx/skill-telemetry branch from 9c05a09 to 5d52152 Compare August 18, 2026 17:17
jdx added a commit that referenced this pull request Aug 19, 2026
Address Copilot review on #2023:

- persistEventMetadataToState now returns the newly-appended skill events and
  all four MutateSessionState call sites (plus the TurnStart closure) forward
  them to trackSkillInvocations only after the mutation returns — the settings
  load and detached spawn no longer run while the session lock is held, and
  never fire for events that failed to persist.
- BuildSkillEventPayload defaults an empty agent to "auto", matching
  BuildEventPayload.
- TrackSkillInvocationsDetached caps detached senders at 10 per call to guard
  against a pathological transcript producing a process storm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M08QAE03CF9G1R5KE77EGR72
@jdx
jdx force-pushed the jdx/skill-telemetry branch from 5d52152 to 4015a35 Compare August 19, 2026 18:04
@jdx

jdx commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the two remaining review findings in 4015a35, and rebased the stack on main:

  • Raw skill names are not content-free (the substantive one): names now pass through to PostHog only when on an official allowlist (telemetry/skill_official.go, seeded with entire, the agent-help skill — the skills equivalent of IsOfficialPlugin). Everything else is reported as the fixed custom category, so a /customer-acme-incident-123 never leaves the machine but adoption volume stays measurable. Enforced inside BuildSkillEventPayload so every caller is covered, with a leak-regression test.
  • Condensation-time extractor events never reached telemetry: Claude Code's skill_tool_use events only surface via ExtractSkillEvents on the condensation/finalize paths, so the main tool-based detection path was invisible. Those paths now persist newly extracted events into SessionState.SkillEvents — the durable dedup that keeps reporting exactly-once across repeated full-transcript (offset-0) extraction — and forward exactly the new ones: CondenseSession returns them via CondenseResult.NewSkillEvents, its three callers emit after their MutateSessionState saves, and the turn-end finalize is picked up by a state-growth snapshot around HandleTurnEnd. The emitter moved to strategy.EmitSkillInvocationTelemetry (single implementation; lifecycle.go delegates), preserving the outside-the-gate and only-after-successful-save contracts from the previous round.

Full suite re-run: 9,316 unit / 514 integration / canary green.

jdx added a commit that referenced this pull request Aug 19, 2026
Address Copilot review on #2023:

- persistEventMetadataToState now returns the newly-appended skill events and
  all four MutateSessionState call sites (plus the TurnStart closure) forward
  them to trackSkillInvocations only after the mutation returns — the settings
  load and detached spawn no longer run while the session lock is held, and
  never fire for events that failed to persist.
- BuildSkillEventPayload defaults an empty agent to "auto", matching
  BuildEventPayload.
- TrackSkillInvocationsDetached caps detached senders at 10 per call to guard
  against a pathological transcript producing a process storm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M08QAE03CF9G1R5KE77EGR72
@jdx
jdx force-pushed the jdx/skill-telemetry branch from 4015a35 to 9f5e2e2 Compare August 19, 2026 21:34
@jdx

jdx commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Removed a stray scripts/entire-dev symlink (an absolute path to my machine's local-dev hook launcher) that had been accidentally swept into an earlier commit — history rewritten so it never appears, no code changes. #2024 rebased accordingly.

pfleidi
pfleidi previously approved these changes Aug 19, 2026
Comment thread cmd/entire/cli/telemetry/skill_official.go Outdated
evisdren
evisdren previously approved these changes Aug 19, 2026
@jdx
jdx dismissed stale reviews from evisdren and pfleidi via e4ab5fd August 20, 2026 01:27
evisdren
evisdren previously approved these changes Aug 20, 2026
jdx and others added 2 commits August 21, 2026 13:21
Skill events are already extracted per agent and recorded in session state
(SkillEventExtractor, appendEventSkillEventsToState), so internal usage is
measurable from checkpoint refs — but nothing reached PostHog, leaving
external skill adoption invisible while agent-help usage is tracked. That
asymmetry biases any skills-vs-agent-help comparison toward the instrumented
side.

Forward exactly the newly-appended (deduped) skill events to a detached
cli_skill_invoked event: skill name, agent, detection signal, event type.
Content-free by construction — no prompt text, arguments, or transcript
content — mirroring the plugin-name-only rule. Gated on the same opt-in
telemetry setting as command tracking, plus the ENTIRE_TELEMETRY_OPTOUT env
opt-out; hook lifecycle paths never produce cli_command_executed (the hooks
tree is hidden), so this is the only signal that a skill fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Address Copilot review on #2023:

- persistEventMetadataToState now returns the newly-appended skill events and
  all four MutateSessionState call sites (plus the TurnStart closure) forward
  them to trackSkillInvocations only after the mutation returns — the settings
  load and detached spawn no longer run while the session lock is held, and
  never fire for events that failed to persist.
- BuildSkillEventPayload defaults an empty agent to "auto", matching
  BuildEventPayload.
- TrackSkillInvocationsDetached caps detached senders at 10 per call to guard
  against a pathological transcript producing a process storm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M08QAE03CF9G1R5KE77EGR72
jdx and others added 3 commits August 21, 2026 13:22
Two review findings:

Raw skill names are user content. The prompt extractor accepts any leading
/<token> and Claude's extractor copies the Skill input verbatim, so a custom
command like /customer-acme-incident-123 would have reached PostHog. Names now
pass through only when on the official allowlist (seeded with "entire", the
agent-help skill); everything else is reported as the fixed "custom" category,
keeping adoption volume measurable without the identifier — the skills
equivalent of IsOfficialPlugin.

Claude Code's skill_tool_use events never reached telemetry. They only surface
via ExtractSkillEvents on the condensation and turn-end finalize paths, which
merged them straight into checkpoint metadata — so the main tool-based
detection path was invisible to the new event. Those paths now persist newly
extracted events into SessionState.SkillEvents (the durable dedup that keeps
reporting exactly-once across repeated full-transcript extraction) and forward
exactly the new ones: CondenseSession returns them via
CondenseResult.NewSkillEvents, its three callers emit after their
MutateSessionState saves, and the finalize path is picked up by a
state-growth snapshot around HandleTurnEnd in the lifecycle turn-end handler.
The emitter moved to strategy (EmitSkillInvocationTelemetry); lifecycle's
trackSkillInvocations delegates to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ffolded one

The allowlist held only "entire" (the scaffolded agent-help skill), so every
skill Entire itself ships reported as "custom", indistinguishable from a
third-party one — undercutting the official-adoption-vs-agent-help comparison
this event exists to serve. entireio/skills publishes its 12 skills as a
Claude Code plugin named "entire", and Claude Code invokes a plugin skill as
"<plugin>:<skill>", so they arrive namespaced ("entire:search").

Matched by leaf name after stripping the namespace, rather than by an
"entire:" prefix: the namespace comes from whatever plugin the user installed
or authored locally, so everything after the colon is arbitrary user text —
prefix-matching would reopen the leak the allowlist exists to close. The
emitted vocabulary stays closed, with a test asserting no input escapes it.

Two judgment calls, both taking the safe direction:

- A skill released upstream before this cross-repo list is updated reports as
  "entire:unlisted" rather than "custom", so a lagging list undercounts the
  per-skill breakdown but never adoption volume. Without this the drift
  silently recreates the bias against skills that motivated the event.
- Bare leaf names are deliberately NOT matched, so cross-agent installs
  without a namespace (Pi normalizes "/skill:<name>" to a bare name; a
  hand-copied SKILL.md has none) report as "custom". Matching them would fold
  Claude Code's built-in /review and any local /search into Entire's numbers
  — an overcount invisible in the data, where this undercount has a known
  cause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tracts

Addresses trail 1070's findings.

Batching (high): the 10-event cap silently dropped invocations. Its rationale
("real turns carry at most a few skill events") stopped holding at 9f5e2e2,
when condensation began extracting from transcript offset 0 — the first
condensation of a session drains its whole backlog in one call, and a dropped
event is never re-reported because session state has already recorded it.
TrackSkillInvocationsDetached now builds every payload and hands them to one
detached child; the cap is gone. Oversized batches split across children
rather than truncate, so nothing is lost. __send_analytics (SendEvents)
accepts an array or a single object — the child re-executes os.Executable(),
so a self-update between spawn and exec can hand a payload to a different
build — and resolves the git version once per send instead of once per event.
This also answers the open batching thread on the PR.

Dedupe (medium): skillEventExists and skillEventKey both guarded
state.SkillEvents with different notions of duplicate, and exactly-once
telemetry now rests on that answer. The weaker one is gone (it compared IDs
only, and its field-wise fallback was unreachable for two ID-bearing events);
strategy.AppendNewSkillEvents is now the single dedupe for hook-provided and
transcript-extracted events alike.

Save ordering (medium): mutErr == nil was never proof of a save —
ErrMutationSkip returns nil and skips SaveSessionState. Every site was correct
only by ordering. MutateSessionStateSaved now reports whether the state was
persisted, and all eight emit sites gate on that instead.

Tests (medium): the previous rounds' contracts had no coverage. Added the
opt-in gate (including that an absent key does not send), the batching and
no-drop behavior, payload decoding both shapes, and the two contracts that
could regress silently — emission outside the session gate, and skip reported
as unsaved. The gate test discriminates on durability rather than visibility,
since a nested call shares the outer's state pointer; verified it fails when
the emit is moved back inside the closure.

Minors: len() guard on the turn-end snapshot slice, inlined the one-line
trackSkillInvocations pass-through at its call sites, and moved the
thrice-copied telemetry opt-in check into settings.IsTelemetryEnabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the jdx/skill-telemetry branch from c6d2c44 to d5c69a3 Compare August 21, 2026 13:29
@jdx
jdx merged commit 6bf587d into main Aug 21, 2026
13 checks passed
@jdx
jdx deleted the jdx/skill-telemetry branch August 21, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

5 participants