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(framework): a readable sink, since no file exporter exists #647
Exported telemetry lands somewhere the CLI and the skills can read back, on a developer machine, without asking anyone to operate an observability stack.
Scope
This issue exists because of a measured fact: Claude Code supports no file exporter for metrics or logs. Its exporters are console, otlp, prometheus and none. Console output dies with the process, the Prometheus endpoint is served by that same process and dies with it. Only otlp survives the session, and it needs something listening.
Every other issue in this milestone assumes a readable export. Nothing provides one. Without this, the diagnostic has nothing to check and the report has nothing to read.
Includes: a minimal OTLP/HTTP receiver, shipped with the framework, that appends what it receives to a documented path under the user's home.
Includes: a documented on-disk format the reader depends on, versioned, so the reader is not coupled to the receiver's implementation.
Includes: bounded retention, so a long-running install does not fill a disk.
Includes: attribute redaction at ingest, dropping Bash commands and tool inputs. This is what makes per-step cost usable without exporting what was typed.
Excludes: aggregation, dashboards, querying, or any hosted component.
Excludes: supporting an existing collector. A project that already runs one points at it instead, and this receiver is simply not used.
Why redaction lives here and not upstream
Per-step attribution requires OTEL_LOG_TOOL_DETAILS=1, and that flag is not selective: it also emits Bash commands and tool inputs. There is no upstream setting that separates them. Dropping those attributes at ingest is the only place the separation can happen, which turns this component from a convenience into a privacy requirement.
Done When
A session exports, the process exits, and the data is still readable afterwards.
The reader depends only on the documented format, verified by reading a fixture the receiver never produced.
Bash commands and tool inputs never reach disk, asserted on a session run with OTEL_LOG_TOOL_DETAILS=1.
Retention is enforced, and exceeding it drops the oldest data rather than failing.
The receiver failing or being absent never blocks or slows a session.
A session that emits nothing is distinguishable at read time from a session that was never journaled.
Completion Evidence
A recorded session whose figures survive a machine restart, and a stored payload showing the redacted attributes are absent.
This issue and #655 both claimed the attribute allowlist, and #655 declared depends_on #647 — a loop, since #647 cannot satisfy its own "Bash commands never reach disk" criterion without the allowlist #655 was defining.
Settled: #647 owns the allowlist, because it owns ingest and ingest is where the separation can physically happen. #655 is re-scoped to the upload path only, which is a different boundary with a different threat model.
Consequence for #646: it must not set OTEL_LOG_TOOL_DETAILS unless this component reports the allowlist active.
Allowlist decision, 2026-08-16 — identities are replaced, not stored
Measured: Claude Code's events carry user.email, user.account_uuid, user.account_id, user.id and organization.id. Without a rule they land on disk from the first session of the week, and the anonymity question gets answered by accident.
Rule: vendor identity attributes are dropped at ingest and replaced by one stable label.
The label is a salted hash. Two properties, both load-bearing:
Stable — the same person yields the same label across sessions and across tools, otherwise nothing can be counted.
Non-reversible — without the salt, no one recovers the address. The salt is not optional: in an organisation of thirty people with a predictable address format, an unsalted hash is trivially reversed by trying all thirty.
The one real design choice: where the salt lives
Per machine, and the same person on a laptop and a desktop becomes two people. It must therefore be shared at project or organisation scope, which means it is a value the framework has to place somewhere and protect — this issue owns that.
Copilot already ships this shape: it emits enduser.pseudo.id and no address at all. We are matching an existing vendor practice rather than inventing one.
What it does not foreclose
Named reporting stays reachable. A mapping from label to person, held separately and access-controlled, turns every already-collected record into a named one without migrating anything (#661). And if that mapping is never built, no name was ever written.
No vendor identity attribute reaches disk, asserted on a stored payload.
The same person produces the same label across two tools and two machines.
The salt is not recoverable from stored data, and its scope is documented.
Measured, and it makes this issue's redaction guard load-bearing from its first line rather than a later hardening.
Claude Code's export carries personal identity, on metrics as well as logs:
user.email on claude_code.cost.usage, claude_code.session.count, and log records
user.id on claude_code.active_time.total
user.account_uuid user.account_id organization.id
So the moment a sink exists and points at it, real email addresses land in it — including on the cost metric, which is the one every report will read.
This is what makes the design hold together rather than a contradiction: the run journal carries no identity, ever, and that is deliberate because it may end up in git. Identity arrives only through the export, where the sink drops the vendor attributes at ingest and replaces them with one stable salted label. Per-person reporting is therefore possible without any name ever being written to a file the framework owns.
The consequence for this issue: dropping-and-replacing is not a feature to add once reporting needs it. It has to be in the first version that receives a single datapoint, because the alternative is a collector that holds addresses nobody decided to collect.
Measured on a real session: ingest the logs stream, and treat metrics as optional.
Every claude_code.cost.usage datapoint matched a claude_code.api_request log record float-for-float — and only because turns happened to fall 3–7 s apart against a 1 s export tick. At the 60 s production default, or on faster back-to-back turns, several turns merge into one datapoint irrecoverably. The metrics stream carries no information the logs lack, and loses information under realistic settings.
Two details for whoever builds this:
Sums are aggregationTemporality: 1 — delta, not cumulative. No differencing.
unit: "USD" lives only on the metric descriptor, never on a datapoint. A consumer reading datapoints alone has no currency at all. The log side is self-describing: the field is literally cost_usd.
query_sourcedisagrees across streams for the same API calls: "main" on the metric, "sdk" on the log. Not usable as a join key.
The cost numbers themselves are trustworthy: summed, they reconciled to the vendor’s own total_cost_usd to the fifth decimal.
One line in this issue is now measured false, and the requirement it justifies survives for a different reason.
Per-step attribution requires OTEL_LOG_TOOL_DETAILS=1, and that flag is not selective… Dropping those attributes at ingest is the only place the separation can happen.
Per-step attribution does not require the flag. Measured on a paid session: api_request log records carry an individually-timestamped cost_usd, and partitioning them by skill boundaries reconciled to the vendor total to the fifth decimal — with the flag unset. The flag was only ever needed for the skill name, and the hook reads that in the clear from the tool call. So we never set it, and Bash commands never leave the process in the first place.
Redaction at ingest is still mandatory, and now for a stronger reason: the export carries personal identity whether or not the flag is set.
user.email on claude_code.cost.usage ← the metric every report reads
user.id user.account_uuid user.account_id organization.id
So this component still cannot be a plain receiver. It has to drop vendor identity attributes at ingest and replace them with one stable salted label — otherwise a local file quietly accumulates addresses nobody decided to collect. That is the requirement; the flag was the wrong justification for it.
Two other measured facts for whoever builds this:
Ingest the logs stream; treat metrics as optional. Every cost.usage datapoint matched an api_request record float-for-float, and only because turns fell 3–7 s apart against a 1 s export tick. At the 60 s default, several turns merge into one datapoint irrecoverably.
Sums are delta, not cumulative, and unit: "USD" lives only on the metric descriptor. The log side is self-describing: the field is cost_usd.
Still open, and a design decision rather than an unknown: what starts the receiver. The scope says it ships with the framework and appends to a documented path, but not whether it is a foreground command, something the CLI supervises, or a process the user is told to run. The done-when "the receiver failing or being absent never blocks or slows a session" constrains the answer without choosing it.
aidd telemetry receive écoute, caviarde à l'entrée, et écrit une ligne par requête facturée dans AIDD_USER_CONFIG_DIR ?? ~/.config/aidd puis telemetry/.
Trois corrections à ce que ce ticket affirmait, chacune mesurée :
Le caviardage n'a pas la justification écrite ici. Le ticket dit que l'attribution par étape exige OTEL_LOG_TOOL_DETAILS=1. Faux : l'enregistrement de coût porte prompt.id et la charge utile du hook porte prompt_id, donc un tour se joint par identifiant, exactement. Le caviardage survit pour une raison plus forte — user.email est sur 52 enregistrements de log sur 52, avec les identifiants de compte et d'organisation. Il arrive quel que soit le drapeau.
Les métriques ne sont pas redondantes.claude_code.active_time.total vaut 9,714 s sur une vraie session et n'apparaît dans aucun log. Coût et jetons sont dans les deux flux ; le temps n'est que dans les métriques. Les jeter aurait supprimé un tiers de ce que cette couche doit répondre.
Il faut écouter /v1/traces. Copilot met son identité de conversation sur un span invoke_agent, pas sur un log. Un exportateur qui reçoit un 404 réessaie puis remonte une erreur à l'utilisateur. La route existe, rien n'en est stocké pour l'instant.
Ce que la revue a corrigé
Le serveur écoutait sur toutes les interfaces au lieu de la boucle locale, pendant que la commande affichait http://localhost : un endpoint en écriture, sans authentification, accessible du réseau local.
aidd telemetry on ne disait jamais qu'il faut lancer le collecteur. On pouvait allumer, exporter correctement, et ne rien stocker — en silence.
L'attribut d'identité de Cursor était déclaré comme mesuré alors qu'il venait de la documentation, et un test de conformité figeait la supposition. Il est unmeasured, ce qu'il est.
Portée
Le format est neutre par construction : l'identité est enregistrée avec le nom de l'attribut dont elle vient, parce que ce nom diffère partout. Chaque outil déclare le sien dans son propre fichier, le mappeur ne contient aucun identifiant d'outil, et un payload de forme Codex se mappe aujourd'hui sans branche supplémentaire — un test le prouve.
Ce qui atteint le disque est une liste blanche construite, pas une liste noire filtrée : un attribut qu'un éditeur ajoutera demain est jeté par défaut plutôt que fuité par défaut.
Reste non mesuré et déclaré comme tel : OpenCode, dont aucun export n'a jamais été capturé. Cursor ne peut être activé par personne ici — réglage d'équipe, plan Enterprise.
The receiver was implemented, then deliberately removed in 19122987: the framework now reads files the tools already wrote and opens no local listener or exporter route. Keeping this issue closed as completed would claim a sink that the current product no longer ships.
The evidence gathered here remains useful, but the receiver itself is no longer planned.
Activity
blafourcade commentedon Aug 15, 2026
Redaction ownership, settled 2026-08-15
This issue and #655 both claimed the attribute allowlist, and #655 declared
depends_on #647— a loop, since #647 cannot satisfy its own "Bash commands never reach disk" criterion without the allowlist #655 was defining.Settled: #647 owns the allowlist, because it owns ingest and ingest is where the separation can physically happen. #655 is re-scoped to the upload path only, which is a different boundary with a different threat model.
Consequence for #646: it must not set
OTEL_LOG_TOOL_DETAILSunless this component reports the allowlist active.blafourcade commentedon Aug 16, 2026
Allowlist decision, 2026-08-16 — identities are replaced, not stored
Measured: Claude Code's events carry
user.email,user.account_uuid,user.account_id,user.idandorganization.id. Without a rule they land on disk from the first session of the week, and the anonymity question gets answered by accident.Rule: vendor identity attributes are dropped at ingest and replaced by one stable label.
The label is a salted hash. Two properties, both load-bearing:
The one real design choice: where the salt lives
Per machine, and the same person on a laptop and a desktop becomes two people. It must therefore be shared at project or organisation scope, which means it is a value the framework has to place somewhere and protect — this issue owns that.
Copilot already ships this shape: it emits
enduser.pseudo.idand no address at all. We are matching an existing vendor practice rather than inventing one.What it does not foreclose
Named reporting stays reachable. A mapping from label to person, held separately and access-controlled, turns every already-collected record into a named one without migrating anything (#661). And if that mapping is never built, no name was ever written.
blafourcade commentedon Aug 18, 2026
Measured, and it makes this issue's redaction guard load-bearing from its first line rather than a later hardening.
Claude Code's export carries personal identity, on metrics as well as logs:
So the moment a sink exists and points at it, real email addresses land in it — including on the cost metric, which is the one every report will read.
This is what makes the design hold together rather than a contradiction: the run journal carries no identity, ever, and that is deliberate because it may end up in git. Identity arrives only through the export, where the sink drops the vendor attributes at ingest and replaces them with one stable salted label. Per-person reporting is therefore possible without any name ever being written to a file the framework owns.
The consequence for this issue: dropping-and-replacing is not a feature to add once reporting needs it. It has to be in the first version that receives a single datapoint, because the alternative is a collector that holds addresses nobody decided to collect.
blafourcade commentedon Aug 18, 2026
Measured on a real session: ingest the logs stream, and treat metrics as optional.
Every
claude_code.cost.usagedatapoint matched aclaude_code.api_requestlog record float-for-float — and only because turns happened to fall 3–7 s apart against a 1 s export tick. At the 60 s production default, or on faster back-to-back turns, several turns merge into one datapoint irrecoverably. The metrics stream carries no information the logs lack, and loses information under realistic settings.Two details for whoever builds this:
aggregationTemporality: 1— delta, not cumulative. No differencing.unit: "USD"lives only on the metric descriptor, never on a datapoint. A consumer reading datapoints alone has no currency at all. The log side is self-describing: the field is literallycost_usd.query_sourcedisagrees across streams for the same API calls:"main"on the metric,"sdk"on the log. Not usable as a join key.The cost numbers themselves are trustworthy: summed, they reconciled to the vendor’s own
total_cost_usdto the fifth decimal.blafourcade commentedon Aug 18, 2026
One line in this issue is now measured false, and the requirement it justifies survives for a different reason.
Per-step attribution does not require the flag. Measured on a paid session:
api_requestlog records carry an individually-timestampedcost_usd, and partitioning them by skill boundaries reconciled to the vendor total to the fifth decimal — with the flag unset. The flag was only ever needed for the skill name, and the hook reads that in the clear from the tool call. So we never set it, and Bash commands never leave the process in the first place.Redaction at ingest is still mandatory, and now for a stronger reason: the export carries personal identity whether or not the flag is set.
So this component still cannot be a plain receiver. It has to drop vendor identity attributes at ingest and replace them with one stable salted label — otherwise a local file quietly accumulates addresses nobody decided to collect. That is the requirement; the flag was the wrong justification for it.
Two other measured facts for whoever builds this:
cost.usagedatapoint matched anapi_requestrecord float-for-float, and only because turns fell 3–7 s apart against a 1 s export tick. At the 60 s default, several turns merge into one datapoint irrecoverably.unit: "USD"lives only on the metric descriptor. The log side is self-describing: the field iscost_usd.Still open, and a design decision rather than an unknown: what starts the receiver. The scope says it ships with the framework and appends to a documented path, but not whether it is a foreground command, something the CLI supervises, or a process the user is told to run. The done-when "the receiver failing or being absent never blocks or slows a session" constrains the answer without choosing it.
blafourcade commentedon Aug 19, 2026
Livré
aidd telemetry receiveécoute, caviarde à l'entrée, et écrit une ligne par requête facturée dansAIDD_USER_CONFIG_DIR ?? ~/.config/aiddpuistelemetry/.Trois corrections à ce que ce ticket affirmait, chacune mesurée :
Le caviardage n'a pas la justification écrite ici. Le ticket dit que l'attribution par étape exige
OTEL_LOG_TOOL_DETAILS=1. Faux : l'enregistrement de coût porteprompt.idet la charge utile du hook porteprompt_id, donc un tour se joint par identifiant, exactement. Le caviardage survit pour une raison plus forte —user.emailest sur 52 enregistrements de log sur 52, avec les identifiants de compte et d'organisation. Il arrive quel que soit le drapeau.Les métriques ne sont pas redondantes.
claude_code.active_time.totalvaut 9,714 s sur une vraie session et n'apparaît dans aucun log. Coût et jetons sont dans les deux flux ; le temps n'est que dans les métriques. Les jeter aurait supprimé un tiers de ce que cette couche doit répondre.Il faut écouter
/v1/traces. Copilot met son identité de conversation sur un spaninvoke_agent, pas sur un log. Un exportateur qui reçoit un 404 réessaie puis remonte une erreur à l'utilisateur. La route existe, rien n'en est stocké pour l'instant.Ce que la revue a corrigé
http://localhost: un endpoint en écriture, sans authentification, accessible du réseau local.aidd telemetry onne disait jamais qu'il faut lancer le collecteur. On pouvait allumer, exporter correctement, et ne rien stocker — en silence.unmeasured, ce qu'il est.Portée
Le format est neutre par construction : l'identité est enregistrée avec le nom de l'attribut dont elle vient, parce que ce nom diffère partout. Chaque outil déclare le sien dans son propre fichier, le mappeur ne contient aucun identifiant d'outil, et un payload de forme Codex se mappe aujourd'hui sans branche supplémentaire — un test le prouve.
Ce qui atteint le disque est une liste blanche construite, pas une liste noire filtrée : un attribut qu'un éditeur ajoutera demain est jeté par défaut plutôt que fuité par défaut.
Reste non mesuré et déclaré comme tel : OpenCode, dont aucun export n'a jamais été capturé. Cursor ne peut être activé par personne ici — réglage d'équipe, plan Enterprise.
feat(cli): a sink that keeps what a session exported, and nothing else
feat(cli): a sink that keeps what a session exported, and nothing else
feat(framework): measurement that works on five tools, each proven by…
blafourcade commentedon Sep 12, 2026
Superseded by the local-read decision in #684.
The receiver was implemented, then deliberately removed in
19122987: the framework now reads files the tools already wrote and opens no local listener or exporter route. Keeping this issue closed as completed would claim a sink that the current product no longer ships.The evidence gathered here remains useful, but the receiver itself is no longer planned.