Repository navigation
feat(framework): a readable sink, since no file exporter exists #647
Description
Activity
- added a parent issue
on Aug 14, 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.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.
user.email = marie@boite.fr → person = a3f9c2e1The 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.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.
- 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.idSo 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.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:
- 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 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.- Sums are
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_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.
user.email on claude_code.cost.usage ← the metric every report reads user.id user.account_uuid user.account_id organization.idSo 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.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. - Sums are delta, not cumulative, and
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.
- Ingest the logs stream; treat metrics as optional. Every
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é
- 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 onne 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.
- Le serveur écoutait sur toutes les interfaces au lieu de la boucle locale, pendant que la commande affichait
- added 2 commits that reference this issue
on Aug 22, 2026 - added a commit that references this issue
on Sep 2, 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.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fieldsDone
Outcome
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,prometheusandnone. Console output dies with the process, the Prometheus endpoint is served by that same process and dies with it. Onlyotlpsurvives 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.
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
OTEL_LOG_TOOL_DETAILS=1.Completion Evidence
A recorded session whose figures survive a machine restart, and a stored payload showing the redacted attributes are absent.
Relations