Documentation
¶
Overview ¶
Package telemetry is the single audit surface for all telemetry transmission from the specscore CLI. Every outbound telemetry HTTP request originates from this package; no other package may import a vendor telemetry SDK (see boundary_test.go for enforcement).
The public Event struct is a closed-enum wrapper: the field set is fixed, and arbitrary string-keyed maps are NOT accepted from callers. Adding a key requires a code change in this package and a corresponding update to docs/telemetry.md. This is the implementation of cli/telemetry#req:fixed-event-property-keys.
Index ¶
- Constants
- Variables
- func CollectOSEnvSignals(getEnv func(string) string) (specZero bool, doNotTrack bool, ciDetected bool)
- func DebugCrashReports(text, cliVersion string)
- func Emit(ctx context.Context, event Event)
- func InstallID() (id string, justCreated bool, err error)
- func InstallIDPath() (string, error)
- func IsSafeMessageID(messageID string) bool
- func RegisterChannel(name ChannelName, transmit TransmitFunc)
- func ResolveCaller(flagValue, envValue string) string
- func ResolveOptOut(sigs OptOutSignals, knownChannels []ChannelName) map[ChannelName]ChannelDecision
- func ScrubFrame(file string, line int, function string) (basename string, scrubbedLine int, scrubbedFunction string)
- func ScrubMessage(recovered any) (messageID string, isUnscrubbed bool)
- func StatePath() (string, error)
- func WriteState(s State) error
- type ChannelDecision
- type ChannelName
- type Event
- type OptOutSignals
- type OptOutSource
- type PanicInfo
- type SafePanicPayload
- type State
- type StateReadResult
- type TransmitFunc
Constants ¶
const ( CallerCLI = "cli" CallerClaude = "claude" CallerCodex = "codex" CallerAider = "aider" CallerOpenCode = "opencode" CallerGoose = "goose" CallerCursor = "cursor" CallerGemini = "gemini" CallerCopilot = "copilot" CallerDevin = "devin" CallerCline = "cline" CallerRoo = "roo" CallerContinue = "continue" CallerWindsurf = "windsurf" CallerZed = "zed" CallerAmazonQ = "amazon-q" CallerTabnine = "tabnine" CallerPiDev = "pi.dev" CallerAntigravityGoogle = "antigravity.google" CallerOther = "other" )
Caller constants. The user-facing values are the strings PostHog ever sees. CallerCLI is the default for invocations without --caller or SPECSCORE_CALLER set. CallerOther is the coercion target for any value not in CallerEnumKnown.
const UnscrubbedPanicMessage = "unscrubbed panic"
UnscrubbedPanicMessage is the literal string sent as the Sentry event's `message` field when a recovered panic does NOT match the SafePanic allowlist. Exported so tests can assert against it.
Variables ¶
var CallerEnumKnown = []string{ CallerCLI, CallerClaude, CallerCodex, CallerAider, CallerOpenCode, CallerGoose, CallerCursor, CallerGemini, CallerCopilot, CallerDevin, CallerCline, CallerRoo, CallerContinue, CallerWindsurf, CallerZed, CallerAmazonQ, CallerTabnine, CallerPiDev, CallerAntigravityGoogle, CallerOther, }
CallerEnumKnown is the closed-enum set of recognized values for the `caller` event property per cli/telemetry/usage-telemetry#req:caller-enum-known-values. Any caller value not in this set is coerced to CallerOther before transmission. Order matches the spec's table; extending requires a code change here AND a spec amendment.
Functions ¶
func CollectOSEnvSignals ¶
func CollectOSEnvSignals(getEnv func(string) string) (specZero bool, doNotTrack bool, ciDetected bool)
CollectOSEnvSignals reads the recognised env vars from the process environment and populates the env-var-related fields of OptOutSignals. Callers compose this with the flag and persistent-state inputs.
func DebugCrashReports ¶
func DebugCrashReports(text, cliVersion string)
DebugCrashReports emits a synthetic crash-reports event for verification purposes (cli/telemetry/errors-telemetry#req:debug-error-subcommand). The text is interpreted as a candidate SafePanic messageID — allowlisted → verbatim, unknown → "unscrubbed panic" with the `message: unscrubbed` tag. Always tagged debug=true so Sentry alert rules can filter these out of operator notifications.
No-op when errorsClientInitialized is false (empty DSN). Returns nothing; failures to send are absorbed by the defer recover().
func Emit ¶
Emit transmits the event through every registered channel. Each channel's transmit-fn is invoked in its own goroutine bounded by transmitTimeout (500 ms hard cap, per cli/telemetry#req:transmission-hard-timeout). On timeout the goroutine continues to run but its result is discarded; the Event struct is the only thing callers may pass. A map[string]any payload MUST NOT compile — the function signature is the closed-enum boundary.
func InstallID ¶
InstallID returns the per-machine install identifier, creating it on first invocation. The boolean return value is true iff the file was created during THIS call (used by callers to drive IsFirstRun semantics + first-run-notice suppression).
Behavior summary (cli/telemetry#req:install-id-*):
- Path: ~/.specscore/install_id on Unix; the platform-appropriate equivalent on Windows (uses os.UserConfigDir).
- Format: UUID v4, lowercase, hyphenated, no surrounding whitespace, a single trailing newline.
- Creation: atomic write-then-rename so a concurrent read never observes a partial file.
- Mode bits: directory 0700, file 0600.
- Per-machine, NOT per-project: a single developer working across N repositories counts as one install.
- Immutability: once created, the CLI MUST NOT regenerate or rotate the file in normal operation. Users delete the file manually to rotate.
On any I/O error (no home dir, permission denied, read-only fs), the function returns ("", false, err); callers MUST treat this as "telemetry disabled for this invocation" — never abort the user's command.
func InstallIDPath ¶
InstallIDPath returns the absolute path of the install-id file without reading or creating it. Used by tests, by the first-run-notice trigger (which only checks existence), and by `specscore telemetry status`.
func IsSafeMessageID ¶
IsSafeMessageID reports whether messageID is in the allowlist.
func RegisterChannel ¶
func RegisterChannel(name ChannelName, transmit TransmitFunc)
RegisterChannel attaches a channel's transmit-fn to the registry. Channels MUST register from a Go init() function. Panics on:
- an unknown channel name (one not in knownChannelNames())
- duplicate registration of the same channel
The init()-time panic surfaces at startup, not at runtime, which makes invalid registrations a build/test-time failure rather than a user-visible crash.
func ResolveCaller ¶
ResolveCaller computes the final caller value to attach to a usage-stats event, per cli/telemetry/usage-telemetry#req:caller-resolution. Precedence:
- flagValue (from --caller on the current invocation)
- envValue (from SPECSCORE_CALLER env var)
- default literal "cli"
The resolved string is then passed through the closed-enum coercion: if it matches a known value, return it verbatim; otherwise return CallerOther (REQ:caller-enum-known-values). The coercion happens here, NOT at the cobra-flag-parsing layer — the flag accepts arbitrary strings so a script passing --caller my-custom-tag never fails the user's command, only the transmitted value is constrained.
Empty strings at either source are treated as absent (fall through to the next precedence rung).
func ResolveOptOut ¶
func ResolveOptOut(sigs OptOutSignals, knownChannels []ChannelName) map[ChannelName]ChannelDecision
ResolveOptOut applies the 4-level precedence to produce one ChannelDecision per known channel. See cli/telemetry#req:opt-out-signal-precedence for the full contract:
- --no-telemetry flag → ALL channels disabled (source: flag).
- SPECSCORE_TELEMETRY=0 or DO_NOT_TRACK=1 → ALL channels disabled (source: env var).
- CI auto-disable (any recognised CI marker) → ALL channels disabled (source: CI auto-disable).
- Persistent state from ~/.specscore/telemetry.yaml → per-channel; if the file is malformed, ALL channels disabled (source: invalid persistent state).
- Otherwise: ALL channels enabled (source: default).
The `knownChannels` parameter is taken as a slice argument, NOT imported from the channel-registry package — this keeps the evaluator free of any code-time dependency on the registry. In practice callers pass RegisteredChannels(), but tests can pass any subset.
REQ:opt-out-always-wins: a `--caller` value or SPECSCORE_CALLER env var MUST NOT re-enable a disabled channel. This function never consults caller signals — opt-out wins by virtue of not having that input.
func ScrubFrame ¶
func ScrubFrame(file string, line int, function string) (basename string, scrubbedLine int, scrubbedFunction string)
ScrubFrame applies the per-frame contract: file path → basename, function name + line number preserved verbatim. Trailing whitespace and embedded newlines in the path are also stripped (defense against adversarial inputs).
REQ:stack-frame-scrubber clause 3 (strip local-variable values from frame metadata) is enforced at the Sentry-SDK config layer inside errors.go, NOT here — Go's debug.Stack() doesn't surface local values to begin with, but the Sentry SDK is configured with AttachStacktrace + SendDefaultPII=false to prevent any reintroduction.
func ScrubMessage ¶
ScrubMessage classifies a recovered panic value into either an allowlisted messageID (returned verbatim with isUnscrubbed=false) or the "unscrubbed panic" sentinel (isUnscrubbed=true). The caller is responsible for setting the corresponding Sentry tag (`message: unscrubbed`) when isUnscrubbed=true.
Acceptance behavior matches cli/telemetry/errors-telemetry#req:panic- message-safe-allowlist: anything that is not a SafePanicPayload with an allowlisted MessageID falls into the unscrubbed bucket. Notably this includes:
- Plain string panics: panic("...")
- Unwrapped errors: panic(errors.New(...))
- Runtime panics: nil dereferences, index-out-of-range, etc.
- SafePanic with an unknown messageID (allowlist miss)
func StatePath ¶
StatePath returns the absolute path of telemetry.yaml. Used by tests and by the `specscore telemetry status` subcommand.
func WriteState ¶
WriteState writes telemetry.yaml atomically with the canonical schema- pointer comment on line 1. Per cli/telemetry#req:persistent-state-file- shape, writes that would produce a non-conforming file (unknown keys we shouldn't be able to write in the first place — defense in depth) fail at write time with a stderr-bound error.
Callers (`specscore telemetry enable|disable [channel]`) construct a State, possibly merging the freshly-read state with a single field change, and call WriteState. The function does NOT auto-create the file — only writes when the caller explicitly persists a preference.
Types ¶
type ChannelDecision ¶
type ChannelDecision struct {
Enabled bool
Source OptOutSource
}
ChannelDecision is the per-channel result of opt-out evaluation. Source names the precedence rung that decided; Enabled is the final answer.
type ChannelName ¶
type ChannelName string
ChannelName is the closed-enum type for registered telemetry channels. New names require a code change in this package per cli/telemetry#req:channel-registry.
const ( // ChannelUsageStats is the PostHog product-analytics channel, implemented // by internal/telemetry/usage.go and specified by cli/telemetry/ // usage-telemetry. ChannelUsageStats ChannelName = "usage-stats" // ChannelCrashReports is the Sentry crash-reporting channel, implemented // by internal/telemetry/errors.go and specified by cli/telemetry/ // errors-telemetry. ChannelCrashReports ChannelName = "crash-reports" )
func RegisteredChannels ¶
func RegisteredChannels() []ChannelName
RegisteredChannels returns the sorted list of currently-registered channels. Used by `specscore telemetry status` and by Emit to iterate channels.
type Event ¶
type Event struct {
// Command is the cobra command path of the executed command, dot-separated
// (e.g. "feature.create", "spec.lint"). Not space-separated.
Command string
// Success is true iff ExitCode == 0.
Success bool
// DurationMs is the wall-clock duration from PersistentPreRun start to
// PersistentPostRun emission, in integer milliseconds.
DurationMs int64
// ExitCode is the integer exit code returned by the command.
ExitCode int
// CLIVersion is the version string from `specscore --version`, embedded at
// build time via goreleaser.
CLIVersion string
// OS is runtime.GOOS (e.g. "darwin", "linux", "windows").
OS string
// Arch is runtime.GOARCH (e.g. "amd64", "arm64").
Arch string
// Caller is the resolved AI-agent identifier, per the usage-stats channel's
// caller-resolution contract. Empty in invocations where no caller was set;
// the usage-stats channel applies the closed-enum guard before transmission.
Caller string
// InstallID is the per-machine UUID v4 created on first invocation.
InstallID string
// IsFirstRun is true iff the install_id file was created during THIS
// invocation.
IsFirstRun bool
// Panic carries a recovered panic value when a panic was caught by the
// CLI's top-level recover handler. nil when no panic occurred. Consumed by
// the crash-reports channel; ignored by the usage-stats channel.
Panic *PanicInfo
}
Event is the typed payload for a single CLI invocation. The ten primary fields below are the closed-enum property set enumerated in cli/telemetry#req:fixed-event-property-keys; the additional context fields (Panic, RecoveredAt) are dispatch hints consumed by per-channel transmit-fns to decide whether to send.
Callers MUST construct Event by field name. The package does NOT expose any API that accepts map[string]any — see AC:fixed-event-property-keys-enforced- at-compile-time.
type OptOutSignals ¶
type OptOutSignals struct {
// NoTelemetryFlag is true when the global --no-telemetry flag was set
// on the current invocation. Highest-precedence signal.
NoTelemetryFlag bool
// SpecScoreTelemetryZero is true when env var SPECSCORE_TELEMETRY=0
// (the literal string "0") was set.
SpecScoreTelemetryZero bool
// DoNotTrack is true when env var DO_NOT_TRACK=1 (the literal string
// "1") was set — the rfc-ish convention.
DoNotTrack bool
// CIDetected is true when ANY of the recognised CI markers (CI=true,
// GITHUB_ACTIONS=true, GITLAB_CI=true, BUILDKITE=true, CIRCLECI=true)
// were set with their case-sensitive value match.
CIDetected bool
// PersistentState is the parsed ~/.specscore/telemetry.yaml content.
// Zero-value when no preference set; ChannelEnabled() handles the
// default-opt-in fallback.
PersistentState State
// PersistentStateInvalid is true when ~/.specscore/telemetry.yaml
// existed but failed validation. When true, the evaluator MUST treat
// every channel as disabled — corrupt config is a fail-safe.
PersistentStateInvalid bool
}
OptOutSignals carries the inputs to the opt-out evaluator. The struct lets callers (the cobra root) decouple signal collection from evaluation: the root reads flags + env vars + persistent state once and passes the bundle to ResolveOptOut. The evaluator is then a pure function — easy to test in isolation against the precedence matrix in cli/telemetry#ac:opt-out-precedence.
type OptOutSource ¶
type OptOutSource string
OptOutSource identifies which precedence rung caused a channel's decision. Used by `specscore telemetry status` to display the source per channel (cli/telemetry#ac:telemetry-subcommand-status).
const ( OptOutSourceFlag OptOutSource = "flag" OptOutSourceEnvVar OptOutSource = "env var" OptOutSourceCI OptOutSource = "CI auto-disable" OptOutSourcePersistentState OptOutSource = "persistent state" OptOutSourcePersistentBroken OptOutSource = "invalid persistent state — see stderr" OptOutSourceDefault OptOutSource = "default" )
type PanicInfo ¶
type PanicInfo struct {
// Value is the value passed to panic(). May be a string, an error, or a
// telemetry.SafePanic payload (see scrubber.go) that wraps a safe
// messageID.
Value any
// Stack is the unmodified output of runtime/debug.Stack() at the moment
// of recovery, including the panicking goroutine's stack frames.
Stack []byte
}
PanicInfo carries the context of a recovered panic for the crash-reports channel. The Stack field contains the raw output of runtime/debug.Stack(). Scrubbing happens inside the crash-reports transmit-fn (see scrubber.go), NOT here — this struct is just the carrier.
type SafePanicPayload ¶
SafePanicPayload wraps a known-safe messageID + the unwrapped error chain. Code wishing to panic with a transmittable message uses:
panic(telemetry.SafePanic("spec-load-failed", err))
At recovery time, the transmit callback inspects the recovered value: if it's a SafePanicPayload AND its MessageID is in the allowlist, the messageID is sent verbatim as the Sentry event's `message` field. Anything else gets the "unscrubbed panic" coercion.
func SafePanic ¶
func SafePanic(messageID string, err error) SafePanicPayload
SafePanic constructs a SafePanicPayload. The messageID is NOT validated here — allowlist membership is checked at transmission time via ScrubMessage. An unknown ID is legal at call sites; the event is just coerced to "unscrubbed panic" at send.
func (SafePanicPayload) Error ¶
func (p SafePanicPayload) Error() string
Error makes SafePanicPayload satisfy the error interface, useful when the payload is observed through errors.As / errors.Is in user code.
func (SafePanicPayload) Unwrap ¶
func (p SafePanicPayload) Unwrap() error
Unwrap exposes the wrapped error for errors.Is / errors.As chains.
type State ¶
type State struct {
Enabled *bool `yaml:"enabled,omitempty"`
UsageStats *bool `yaml:"usage-stats,omitempty"`
CrashReports *bool `yaml:"crash-reports,omitempty"`
}
State is the in-memory shape of telemetry.yaml. A nil-valued pointer field means "key absent from the file" — distinct from "key present with false." The opt-out evaluator uses presence-vs-absent semantics to implement the per-channel-overrides-global rule.
func (State) ChannelEnabled ¶
func (s State) ChannelEnabled(name ChannelName) (enabled bool, source string)
ChannelEnabled resolves the effective enabled state for a single channel from the persistent state alone. The full opt-out precedence (flag → env → CI → persistent state) is implemented by the opt-out evaluator (Task 4); this helper only answers the "what does the persistent state say about this channel?" question.
Semantics:
- Per-channel override (UsageStats or CrashReports) takes precedence over the global Enabled when set.
- When neither per-channel nor global is set, returns (true, "default") meaning "default-opt-in applies."
- When per-channel is set, returns (*, "persistent state per-channel").
- When only global is set, returns (*, "persistent state global").
type StateReadResult ¶
type StateReadResult struct {
// State is the parsed preferences. Zero-value when the file doesn't exist
// or when InvalidReason is non-empty.
State State
// FileExisted is true iff the file was readable (regardless of content
// validity).
FileExisted bool
// InvalidReason names the validation failure (unknown key, wrong type,
// non-YAML). Empty when State is usable.
InvalidReason string
}
StateReadResult is what ReadState returns to its caller. The InvalidReason field is non-empty iff the file existed but failed validation — callers MUST treat InvalidReason!="" the same as "telemetry disabled for this invocation" while continuing the user's command. The split between (File-Not-Found ⇒ no preference set) and (File-Malformed ⇒ disable + warn) implements cli/telemetry#ac:persistent-state-file-shape-rejected.
func ReadState ¶
func ReadState() (StateReadResult, error)
ReadState reads telemetry.yaml. See StateReadResult docs for the three outcomes:
- File absent (no preference set) → FileExisted=false, no error.
- File present and valid → State populated, FileExisted=true, no error.
- File present and malformed → FileExisted=true, InvalidReason set, no error returned from ReadState (the malformed-file case is a user- facing warning, not an I/O failure from the CLI's perspective).
Genuine I/O errors (permission denied on a present file, etc.) are returned as the error result.
type TransmitFunc ¶
TransmitFunc is the signature each channel's transmit callback must satisfy. Transmission failure is silent — channels do not propagate errors to the caller. Per-channel triggering decisions (e.g. crash-reports skipping when Event.Panic is nil and ExitCode < 10) happen inside the TransmitFunc body.