telemetry

package
v0.17.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: GPL-3.0 Imports: 19 Imported by: 0

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:

  1. raid invokes telemetry.Capture(name, props) at hook points (ExecuteCommand, ExecuteTask, first-run prompt accepted, etc.).
  2. If consent isn't on, Capture is a no-op. Same for missing API key (dev builds) and DO_NOT_TRACK=1.
  3. 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.
  4. 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

View Source
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.

View Source
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/.

View Source
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

View Source
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.

View Source
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.

View Source
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

func Capture(name string, properties map[string]any)

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

func CaptureSync(name string, properties map[string]any)

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

func CommandFailedProps(commandName string, errorCode string, durationMs int64) map[string]any

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

func FirstRunProps(installMethod string) map[string]any

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

func Flush(timeout time.Duration)

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

func OptOutProps(reason string) map[string]any

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

func PreviewPayload(name string, properties map[string]any) string

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

func SetEnabled(enabled bool) error

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

func TaskExecutedProps(taskType string, durationMs int64, success bool) map[string]any

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

type Event struct {
	Name       string         `json:"event"`
	Properties map[string]any `json:"properties"`
}

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:

  1. **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.

  2. **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.

type State

type State struct {
	Decided bool
	Enabled bool
}

State is the user-facing consent snapshot read by `raid telemetry status` and by IsActive. Decided distinguishes "user has been asked and chose off" from "user hasn't been asked yet" — the first-run prompt only fires when Decided is false.

func LoadState

func LoadState() State

LoadState reads consent from viper. Defaults: Decided=false, Enabled=false. Either default is safe — a fresh install or a config without these keys yields off until the user opts in.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL