telemetry

package
v0.14.0 Latest Latest
Warning

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

Go to latest
Published: Jun 18, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

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

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

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

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

func Emit(ctx context.Context, event Event)

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

func InstallID() (id string, justCreated bool, err error)

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

func InstallIDPath() (string, error)

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

func IsSafeMessageID(messageID string) bool

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

func ResolveCaller(flagValue, envValue string) string

ResolveCaller computes the final caller value to attach to a usage-stats event, per cli/telemetry/usage-telemetry#req:caller-resolution. Precedence:

  1. flagValue (from --caller on the current invocation)
  2. envValue (from SPECSCORE_CALLER env var)
  3. 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:

  1. --no-telemetry flag → ALL channels disabled (source: flag).
  2. SPECSCORE_TELEMETRY=0 or DO_NOT_TRACK=1 → ALL channels disabled (source: env var).
  3. CI auto-disable (any recognised CI marker) → ALL channels disabled (source: CI auto-disable).
  4. Persistent state from ~/.specscore/telemetry.yaml → per-channel; if the file is malformed, ALL channels disabled (source: invalid persistent state).
  5. 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

func ScrubMessage(recovered any) (messageID string, isUnscrubbed bool)

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

func StatePath() (string, error)

StatePath returns the absolute path of telemetry.yaml. Used by tests and by the `specscore telemetry status` subcommand.

func WriteState

func WriteState(s State) error

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

type SafePanicPayload struct {
	MessageID string
	Wrapped   error
}

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

type TransmitFunc func(ctx context.Context, event Event)

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.

Jump to

Keyboard shortcuts

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