Documentation
¶
Overview ¶
Package telemetry implements raid's opt-in, anonymous CLI telemetry pipeline. Issue #80 spec'd the scope: measure adoption + usage, never capture user content, default off, easy to inspect and disable.
Lifecycle:
- raid invokes telemetry.Capture(name, props) at hook points (ExecuteCommand, ExecuteTask, first-run prompt accepted, etc.).
- If consent isn't on, Capture is a no-op. Same for missing API key (dev builds) and DO_NOT_TRACK=1.
- Otherwise the event is enriched with anonymous machine ID + version/os/arch and posted asynchronously to PostHog. The HTTP call never blocks the caller — failures drop silently so a network blip can't break a raid command.
- executeRoot calls Flush at the end of every invocation so in-flight events finish (or get dropped on timeout).
The package is read-only side-effect-free for users who never opt in via prompt or `raid telemetry on`: no goroutines spawned, no HTTP calls. Two narrow exceptions exist:
- MaybePromptForConsent persists a "decided=off" entry to viper when the prompt is skipped (DO_NOT_TRACK, non-TTY, headless) so we don't re-prompt on the next interactive run. No anonymous ID or network traffic results.
- CaptureOptOutConsented, used only by the first-run follow-up prompt when a declining user explicitly consents to record their decision, creates an anonymous ID and sends exactly one `raid_telemetry_opt_out` event before leaving telemetry off forever. This is per-event consent, never state-level.
Outside of those two paths, no anonymous ID is created or written until the user explicitly opts in.
Index ¶
- Constants
- Variables
- func Capture(name string, properties map[string]any)
- func CaptureOptOutConsented(reason string)
- func CaptureSync(name string, properties map[string]any)
- func CommandExecutedProps(commandName string, taskCount int, taskTypes []string, durationMs int64) map[string]any
- func CommandFailedProps(commandName string, errorCode string, durationMs int64) map[string]any
- func DoNotTrackActive() bool
- func FirstRunProps(installMethod string) map[string]any
- func Flush(timeout time.Duration)
- func HasAPIKey() bool
- func IDPath() string
- func IsActive() bool
- func LoadIDIfExists() string
- func OptOutProps(reason string) map[string]any
- func PreviewPayload(name string, properties map[string]any) string
- func PurgeID() error
- func Sampled() bool
- func SetDecidedOff() error
- func SetEnabled(enabled bool) error
- func TaskExecutedProps(taskType string, durationMs int64, success bool) map[string]any
- type Event
- type PromptResult
- type State
Constants ¶
const ( EventFirstRun = "raid_first_run" EventCommandExecuted = "raid_command_executed" EventCommandFailed = "raid_command_failed" EventTaskExecuted = "raid_task_executed" EventTelemetryOptOut = "raid_telemetry_opt_out" )
Event names. Stable contract — these are the labels that show up in PostHog and on the public telemetry-disclosure page.
const DoNotTrackEnvVar = "DO_NOT_TRACK"
DoNotTrackEnvVar is the standard cross-tool opt-out env var that raid honors as a hard off. See https://consoledonottrack.com/.
const IDFileEnv = "RAID_TELEMETRY_ID_FILE"
IDFileEnv lets tests redirect the ID file off the user's real $HOME without touching the global filesystem. Empty in normal use.
Variables ¶
var APIKey string
APIKey is the PostHog publishable project key. Empty by default; injected at release time via `-ldflags -X github.com/8bitalex/raid/src/internal/telemetry.APIKey=phc_...`. When empty, Capture is a no-op — that's how dev builds and `go run` stay silent.
var CaptureEndpoint = "https://us.i.posthog.com/i/v0/e/"
CaptureEndpoint is the PostHog capture URL. Overridable for tests so they can point at a httptest.Server without hitting the real endpoint.
var TaskSampleRate = 0.1
TaskSampleRate controls how many `raid_task_executed` events fire, as a fraction in [0, 1]. Tasks fire on average rate% of the time — the issue's "sampled to avoid flooding" requirement. Tests pin this to 1 (deterministic capture) or 0 (deterministic drop).
Default 0.1 keeps the per-invocation event volume bounded for commands with hundreds of tasks while still giving statistically useful samples at fleet scale.
Functions ¶
func Capture ¶
Capture is the public hook every event fires through. The network POST runs on a goroutine; Capture itself only blocks on loadOrCreateID(), which can touch the filesystem (home-dir resolution, mkdir, write) the first time an opted-in user fires an event. Subsequent calls hit the in-process cache and return without disk I/O. Capture is safe to call even when telemetry isn't active — it just no-ops.
Callers must not pass sensitive content in properties. Sanitization is enforced upstream by the event builders, not here.
func CaptureOptOutConsented ¶
func CaptureOptOutConsented(reason string)
CaptureOptOutConsented fires the opt-out event under explicit per-event consent — used by the first-run prompt when a user declines telemetry generally but agrees to send a single anonymous "denial recorded" event. Bypasses the standard IsActive() gate (which would short-circuit because consent has just been set to off, or is still undecided) but still respects the two hard kill-switches: a build with no APIKey and a DO_NOT_TRACK env var. Synchronous so the event has its best chance to land before raid exits.
Callers MUST ensure the user has explicitly consented to this specific event — never call this for any other purpose, and never extend it to a general "bypass" capture path. The whole trust model of telemetry rests on consent being explicit at the event level when state-level consent isn't true.
func CaptureSync ¶
CaptureSync is Capture's blocking variant. Used by `raid telemetry off` so the opt-out event is attempted synchronously before the process exits — we want to give the event the best chance to land rather than rely on Flush's timeout. This is best-effort: send() silently drops network and non-2xx errors, so delivery isn't guaranteed, just synchronously attempted.
func CommandExecutedProps ¶
func CommandExecutedProps(commandName string, taskCount int, taskTypes []string, durationMs int64) map[string]any
CommandExecutedProps builds the properties map for a successful command run.
- commandName: the command's `name:` from YAML. Treated as non-sensitive — it's a label the project author chose, not anything the end user typed in.
- taskCount: total task entries in the command.
- taskTypes: ordered list of task-type strings (Shell, Script, …), one entry per task in the command — duplicates are preserved so the per-command structure stays visible. Types only, never the cmd body or args.
- durationMs: wall-clock command duration in milliseconds.
func CommandFailedProps ¶
CommandFailedProps is the failure variant. errorCode is the structured-error code (`TASK_SHELL_FAILED`, `VERIFY_FAILED`, …) from #47 — never the error's message, which can contain paths or command bodies.
func DoNotTrackActive ¶
func DoNotTrackActive() bool
DoNotTrackActive is the public surface for callers that need to surface the DO_NOT_TRACK state (e.g. `raid telemetry status`). Mirrors the internal check exactly so the printed status matches what IsActive() actually enforces.
func FirstRunProps ¶
FirstRunProps is fired exactly once, when the user accepts the opt-in prompt. install_method is best-effort — empty when the invocation doesn't expose how raid was installed (e.g. `go install`, custom build).
func Flush ¶
Flush waits up to timeout for in-flight events to finish. Called at the end of executeRoot so async events sent during a command run don't get dropped when raid exits.
func HasAPIKey ¶
func HasAPIKey() bool
HasAPIKey reports whether this binary was built with a PostHog API key injected. Used by status to tell users that a dev build will never emit events even when consent is on.
func IDPath ¶
func IDPath() string
IDPath returns the on-disk path where raid stores the anonymous machine ID, after $RAID_TELEMETRY_ID_FILE override and $HOME resolution. Exposed so `raid telemetry status` can show it.
func IsActive ¶
func IsActive() bool
IsActive reports whether Capture will actually send. Combines the build-time API key, the consent state, and DO_NOT_TRACK.
This is the gate every telemetry path checks before doing real work — keep the test surface narrow by going through here, not by reading individual flags.
func LoadIDIfExists ¶
func LoadIDIfExists() string
LoadIDIfExists is the public wrapper around loadIDIfExists for commands that need to display the ID without forcing creation (`raid telemetry status` and the preview path).
func OptOutProps ¶
OptOutProps records the reason a user opted out, if they supplied one via `raid telemetry off --why "..."`. The reason is a free-text field the user controls — they can include whatever they want, but we never collect it implicitly.
func PreviewPayload ¶
PreviewPayload returns the JSON body that would be sent for the given event, without sending it. Used by `raid telemetry preview` so users can see exactly what raid emits before opting in.
Pretty-prints the JSON for human inspection. When no anonymous ID exists yet (the user hasn't opted in and we don't want to create the id file just for a preview), a placeholder string is shown in the distinct_id field so the preview still renders the full payload shape. Returns an empty string only on JSON marshaling failure.
func PurgeID ¶
func PurgeID() error
PurgeID deletes the on-disk ID file. PostHog can't link future events to past ones after a purge — `raid telemetry purge` exposes this so users can break continuity without losing the opt-in.
Returns nil when the file is already absent (purge is idempotent).
func Sampled ¶
func Sampled() bool
Sampled reports whether a task event should be captured this time per TaskSampleRate. Caller fires Capture only when this returns true. Fast-paths when telemetry is inactive so opted-out users don't pay the per-task RNG call.
func SetDecidedOff ¶
func SetDecidedOff() error
SetDecidedOff marks the user as having declined without ever being prompted. Used in non-interactive contexts (no TTY, --yes/--headless, DO_NOT_TRACK=1) so we don't keep trying to prompt later. Behaves identically to SetEnabled(false) but documents intent at call site.
func SetEnabled ¶
SetEnabled persists the user's consent choice. Always sets Decided so we don't re-prompt — a user who answered no should stay not-prompted until they explicitly run `raid telemetry on`.
func TaskExecutedProps ¶
TaskExecutedProps is the per-task variant. Sampled at the call site so PostHog isn't flooded for commands with hundreds of tasks. Only the task type and outcome leak — never the cmd body, path, URL, var name, default value, or any other content.
Types ¶
type Event ¶
Event is what Capture queues for delivery. Properties must already be sanitized — telemetry doesn't re-scan them at send time.
type PromptResult ¶
type PromptResult int
PromptResult describes what the first-run prompt resolved to. Used by the caller (cmd/raid.go) to decide whether to fire the first_run event and to surface a short post-prompt confirmation.
const ( // PromptSkipped means we never showed the prompt (non-interactive // context, DO_NOT_TRACK, already decided, no API key). For most // skip reasons consent is also marked decided=off so we won't try // again. Exception: the "no API key" branch (dev builds where // telemetry is dead code) returns PromptSkipped without persisting // any consent state — there's nothing useful to remember. PromptSkipped PromptResult = iota // PromptDeclined means the user explicitly chose no. PromptDeclined // PromptAccepted means the user explicitly chose yes. The caller // should fire EventFirstRun (with install_method if known). PromptAccepted )
func MaybePromptForConsent ¶
func MaybePromptForConsent(skipPersistent, skipTransient bool) PromptResult
MaybePromptForConsent runs the first-run consent flow when appropriate. Returns the resolved outcome so the caller can fire follow-up events.
Two skip tiers, by design:
**Persistent skip** — long-term reasons to be non-interactive: `--yes` / `--headless` / `RAID_HEADLESS=1`, `DO_NOT_TRACK=1`, stdin isn't a TTY (CI, pipes, agent hosts), already decided. These persist `decided=off` so future runs from the same machine don't re-attempt the prompt logic. Rationale: a host that's non-interactive today is probably non-interactive tomorrow; re-prompting on every run is noise.
Dev / no-API-key builds short-circuit before reaching this tier — there's nothing to opt out of, so consent state stays untouched and a later release-build run on the same machine will still get a fresh prompt.
**Transient skip** — per-invocation reasons that don't reflect the user's long-term posture: `--json` (machine-readable output mode for one command). The prompt is suppressed but no consent state is written, so a later interactive run still gets prompted. Rationale: piping one command through `--json` is a momentary tool choice, not an opt-out signal.
The split exists because conflating the two caused a real bug where `raid context --json | jq` silently opted the user out forever. Callers must classify their skip signal correctly.