Skip to content

feat(framework): a readable sink, since no file exporter exists #647

Description

@blafourcade

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, 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.

Relations

Field Value
parent #631
blocks #617, #629
related #646, #655

Activity

  1. added theissue type on Aug 14, 2026
  2. moved this from Ideation to Todo in AIDD Roadmapon Aug 14, 2026
  3. blafourcade commented on Aug 15, 2026

    @blafourcade
    ContributorAuthor

    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_DETAILS unless this component reports the allowlist active.

  4. blafourcade commented on Aug 16, 2026

    @blafourcade
    ContributorAuthor

    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.

    user.email = marie@boite.fr        →   person = a3f9c2e1
    

    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.
  5. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    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.

  6. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    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_source disagrees 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.

  7. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    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.

  8. self-assigned this
    on Aug 19, 2026
  9. blafourcade commented on Aug 19, 2026

    @blafourcade
    ContributorAuthor

    Livré

    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.

  10. added 2 commits that reference this issue on Aug 22, 2026
    d2b4fe6
    07b5a75
  11. added a commit that references this issue on Sep 2, 2026
    627408f
  12. blafourcade commented on Sep 12, 2026

    @blafourcade
    ContributorAuthor

    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.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

Priority

Low

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions