Skip to content

feat(cli): anonymous opt-out product analytics (PostHog via cloud pulse proxy) - #133

Open
azlekov wants to merge 7 commits into
developfrom
feat/telemetry
Open

feat(cli): anonymous opt-out product analytics (PostHog via cloud pulse proxy)#133
azlekov wants to merge 7 commits into
developfrom
feat/telemetry

Conversation

@azlekov

@azlekov azlekov commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds anonymous, opt-out product analytics to the CLI: which commands run, how long they take, whether they succeed, and coarse facts about the repo they run in (host class, public/private, a per-install-salted hash) — flushed through the daemon to the Dira cloud's /api/v1/pulse proxy and forwarded server-side to PostHog Cloud EU. Full user-facing disclosure in docs/TELEMETRY.md; structural rules recorded as DIRASH-0033/0034 with a telemetry living spec.

How it works

  • CLI (cli/dira/src/telemetry.rs): thin main() wraps dispatch with a timer and a TelemetryGate (config knob, DIRA_TELEMETRY_ENABLED, DO_NOT_TRACK, CI, dev builds — any one suppresses). Emission is a 150ms-budgeted fire-and-forget over the control socket; the CLI never does network I/O for telemetry (D-0006 generalized).
  • Daemon (cli/dirad/src/telemetry_sync.rs): appends to a capped (5000-row) SQLite queue, hashes the canonical repo ref with a per-install salt before anything is stored, and drains in a knowledge_sync-shaped loop — per-chunk cursor advance on 2xx (D-0020), shared backoff ladder (DIRASH-0031), shared TLS-pinned client (D-0011). Gated on consent + cloud_url, never on device linking, so unlinked installs report anonymously.
  • Visibility (cli/dirad/src/repo_visibility.rs): unauthenticated probe of github.com/gitlab.com (200→public, 404→private, else unknown), cached 24h keyed by the salted hash. The repo name goes only to the forge that already hosts it — never to Dira (DIRASH-0034); stated in the disclosure.
  • Consent (DIRASH-0030 shape): dedicated onboarding step with TELEMETRY_DISCLOSURE shown on every path and a wording test pinning it to the shipped fields; one-time first-run notice on stderr; --telemetry <on|off>; consent changes emit cli_consent_recorded.
  • Identity: random install ULID + 32-byte salt in meta, never derived from the Ed25519 device key. dira device link passes the install id so the cloud can alias install → device at claim.

Privacy invariants (test-enforced)

Never sent: argv, paths, repo names, git identity, error text. The wire enum is closed with per-variant no-stray-field tests; error_kind is a closed taxonomy classified from error types. Changing what ships requires changing the disclosure in the same commit (standing rule).

Companion

dodi-smart/dirahq-cloud PR adds the /api/v1/pulse proxy + server-side allowlist. No hard merge-order constraint: the daemon treats a missing endpoint as a quiet skip, and the cloud ignores a missing installId.

Testing

just ci green (no contract drift — telemetry types deliberately live outside /contract, versioned v: 1). 1196 workspace tests, 0 failures, clippy -D warnings clean. An 8-angle review pass ran over the branch; all 10 findings fixed in the final commit.

🤖 Generated with Claude Code

azlekov and others added 7 commits August 25, 2026 00:57
… facts, event model, queue table

Adds the dira_core::telemetry module: TelemetryKnobs (default-on, telemetry.enabled
knob + KNOBS row), lazily minted anonymous install id + 32-byte salt in meta (never
derived from the device key), pure repo-facts capture (host classification + salted
HMAC-SHA256 of the canonical remote — never the plaintext), the closed TelemetryEvent
enum with per-variant no-stray-field wire tests, serde-only wire types with a
deterministic batch id, and the append-only telemetry_events queue table (migration
0006) with id-cursor store methods.

Wire types deliberately live outside /contract: telemetry is best-effort and versioned
independently (v: 1), so taxonomy changes never ride the contract release train.

Why: local zod/serde pair instead of a contract type; unsigned batches so unlinked
installs can report.
Rejected: reusing the Ed25519 device identity as distinct_id (couples analytics to
the signing identity; forbidden by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Asen Lekov <asenlekoff@gmail.com>
…d pulse endpoint

Adds Request::IngestTelemetry and the telemetry_sync channel: control-socket ingest
appends to the local queue (consent re-checked daemon-side), and a knowledge_sync-shaped
background task drains (cursor, until] chunks to {cloud_url}/api/v1/pulse over the
shared TLS-pinned client, advancing the cursor per accepted chunk on its own 2xx and
backing off via the shared ladder. Gated on cloud_url + telemetry.enabled only — never
device linkage, so unlinked installs report anonymously. Daemon start/stop lifecycle
events enqueue through the same path; nuke clears the queue, cursor, and health.

Why: 400 responses advance past the poison chunk (loudly, rows kept for inspection)
so a malformed batch can never wedge the queue; 404 is a quiet endpoint-missing skip
for older clouds.
Rejected: signing telemetry batches — would couple analytics to the device identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Asen Lekov <asenlekoff@gmail.com>
…d the TELEMETRY.md disclosure

Adds the DIRASH-0030-shaped consent step: TELEMETRY_DISCLOSURE names exactly what is
sent and what never is, shown on every path before any branching; the confirm defaults
to on (opt-out), a decline writes telemetry.enabled=false through config_cmd, and an
answer equal to the current state writes nothing. --telemetry <on|off> skips the
prompt like --knowledge; --yes keeps the default. docs/TELEMETRY.md is the user-facing
disclosure (taxonomy, privacy guarantees, EU residency, kill switches) with a README
pointer. A wording test pins the disclosure to the shipped property categories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Asen Lekov <asenlekoff@gmail.com>
…etry

Thin main() now times the dispatched command and records cli_command_executed
(top-level command name only, duration, success, closed error-kind classification —
never message text) through a 150ms-budgeted UDS fire-and-forget that can never slow
or fail a command. TelemetryGate makes the documented kill switches real: CI, dev
builds, DO_NOT_TRACK, DIRA_TELEMETRY_ENABLED=0, and the config knob. The first
interactive run prints the disclosure to stderr once (marker file). Consent changes
emit cli_consent_recorded from both the onboarding step and config set; device link
fetches the daemon-minted install id (gate-checked, never blocking the link) so the
cloud can alias install->device at claim.

The canonical repo ref crosses only the local socket: the daemon hashes it with the
per-install salt before anything is stored, so plaintext repo identity never lands
in the queue.

Why: disabling telemetry still emits one final consent_recorded(enabled=false) so
the opt-out itself is measurable; every other switch suppresses even that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Asen Lekov <asenlekoff@gmail.com>
The decision pins the structural rules — opt-out with tested disclosure, random
install identity never derived from the device key, daemon-side salted repo hashing,
unsigned consent-gated channel — and RULES.md gains the matching standing rule.

Spec: telemetry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Asen Lekov <asenlekoff@gmail.com>
… never blocking ingest

telemetry_sync::ingest now resolves public|private for github.com and gitlab.com
remotes via an unauthenticated GET to the forge that already hosts the repo (200 ->
public, 404 -> private-or-absent, anything else -> unknown), 2s timeout, dirad/<ver>
user agent, no tokens or cookies ever. Answers cache 24h (10min for unknown) keyed by
the salted repo hash — the plaintext ref is never a cache key — capped at 256 entries
with in-flight bounding. A cold cache stores the first event as unknown and a detached
probe warms the cache for the next; stored rows are never retro-updated.
Bitbucket/self-hosted short-circuit to unknown with no request.

Records DIRASH-0034: the probe sends the repo name only to the forge that hosts it,
never to Dira's cloud — an explicit carve-out under DIRASH-0033's anonymity guarantee.

Spec: telemetry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Asen Lekov <asenlekoff@gmail.com>
…dentity race, error taxonomy, queue cap

Review pass fixes: the disclosure (TELEMETRY.md + TELEMETRY_DISCLOSURE + wording test)
now states that public/private is checked with the repo own forge, never with Dira
(DIRASH-0034); install identity is minted once through a OnceCell on AppState, fixing
a first-run salt race between concurrent ingests and removing two SQLite reads per
event; classify_error no longer tags any "failed" message as daemon_error (closed
marker list + SilentExit downcast); the generic daemon-failure path returns a
SilentExit sentinel so failed commands record telemetry before exiting 1; the local
queue is capped at 5000 rows so no-cloud installs stay bounded; dead ConsentSource
variants (env/default) are removed along with the doc promise they implied;
"config set telemetry.enabled off" no longer records its own invocation (fresh gate
for config commands); socket budgets and the on/off parser are shared instead of
copied; the ULID-ordering-dependent visibility test now identifies rows by payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Asen Lekov <asenlekoff@gmail.com>
@azlekov

azlekov commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Companion cloud PR: dodi-smart/dirahq-cloud#146

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant