chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
// Package telemetry implements opt-in, anonymous usage telemetry for Gortex.
//
// The cardinal rule is opt-in: nothing is recorded, buffered, or sent unless
// consent resolves to enabled, and the default at every layer is off. This
// file holds only the consent decision — the single source of truth every
// other telemetry path consults before doing anything.
package telemetry
import (
"os"
"strings"
)
// Source identifies which precedence rung decided consent — surfaced by
// `gortex telemetry status` and useful when a user asks "why is this on/off".
type Source string
const (
// SourceEnv: the GORTEX_TELEMETRY environment override.
SourceEnv Source = "env"
// SourceDoNotTrack: the cross-tool DO_NOT_TRACK standard.
SourceDoNotTrack Source = "do_not_track"
// SourceConfig: the persisted telemetry.enabled config value.
SourceConfig Source = "config"
// SourceDefault: no signal at any rung — the opt-in default (off).
SourceDefault Source = "default"
)
// Consent is the resolved telemetry decision plus the rung that produced it.
type Consent struct {
Enabled bool
Source Source
}
// ConsentConfig is the persisted-config view the resolver needs: the explicit
// telemetry.enabled value, or nil when the config says nothing about it (so the
// resolver can fall through to the default rather than treating "unset" as off).
type ConsentConfig struct {
Enabled *bool
}
// Environment variables consulted by the resolver.
const (
// EnvTelemetry is Gortex's explicit per-process override.
EnvTelemetry = "GORTEX_TELEMETRY"
// EnvDoNotTrack is the cross-tool standard (https://consoledonottrack.com).
EnvDoNotTrack = "DO_NOT_TRACK"
)
// ResolveConsent decides whether anonymous usage telemetry is enabled, applying
// a fixed four-rung precedence (highest wins). Telemetry is opt-in: with no
// signal at any rung it is off.
//
// 1. GORTEX_TELEMETRY — an explicit per-process override. A recognised off
// value (0/false/off/no/disable) forces off; an on value (1/true/on/yes/
// enable) forces on. Highest so a user can always override everything,
// including a global DO_NOT_TRACK, for one invocation.
// 2. DO_NOT_TRACK — the cross-tool privacy standard. Any value other than
// unset / "0" / "false" forces off. It can only ever disable, never enable.
// 3. config telemetry.enabled — the persisted user choice.
// 4. default — off.
//
// getenv defaults to os.Getenv when nil; tests inject a fake lookup.
func ResolveConsent(cfg ConsentConfig, getenv func(string) string) Consent {
if getenv == nil {
getenv = os.Getenv
}
// Rung 1: explicit Gortex env override (can force on or off).
if v, ok := parseConsentBool(getenv(EnvTelemetry)); ok {
return Consent{Enabled: v, Source: SourceEnv}
}
// Rung 2: DO_NOT_TRACK (off-only signal).
if doNotTrackAsserted(getenv(EnvDoNotTrack)) {
return Consent{Enabled: false, Source: SourceDoNotTrack}
}
// Rung 3: persisted config.
if cfg.Enabled != nil {
return Consent{Enabled: *cfg.Enabled, Source: SourceConfig}
}
// Rung 4: opt-in default.
return Consent{Enabled: false, Source: SourceDefault}
}
// parseConsentBool maps an on/off-ish string to a bool. ok is false for "" or
// an unrecognised value, so the caller falls through to the next rung instead
// of treating noise as a decision.
func parseConsentBool(s string) (val, ok bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "on", "yes", "enable", "enabled":
return true, true
case "0", "false", "off", "no", "disable", "disabled":
return false, true
default:
return false, false
}
}
// doNotTrackAsserted reports whether DO_NOT_TRACK requests no tracking. Per the
// standard a set value that is not "0"/"false" means "do not track"; unset,
// "0", and "false" do not assert it.
func doNotTrackAsserted(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
return s != "" && s != "0" && s != "false"
}
+130
View File
@@ -0,0 +1,130 @@
package telemetry
import (
"encoding/json"
"io"
"os"
"path/filepath"
"sync"
"time"
)
// consentFile is the persisted explicit-choice record under the telemetry dir.
const consentFile = "consent.json"
// PersistedConsent is the on-disk record of an explicit user choice (via
// `gortex telemetry on|off` or the installer prompt). Its presence is also the
// signal the opt-in-once notice uses to fire at most once.
type PersistedConsent struct {
Enabled bool `json:"enabled"`
Source string `json:"source"` // "cli" | "installer"
UpdatedAt string `json:"updated_at"` // RFC3339 UTC, informational
}
// LoadConsentConfig reads the persisted choice into a ConsentConfig for the
// resolver's third rung. A missing or unreadable file yields an unset
// (nil-pointer) value, so the resolver falls through to the opt-in default
// rather than treating "never chosen" as off.
func LoadConsentConfig(dir string) ConsentConfig {
b, err := os.ReadFile(filepath.Join(dir, consentFile))
if err != nil {
return ConsentConfig{}
}
var pc PersistedConsent
if json.Unmarshal(b, &pc) != nil {
return ConsentConfig{}
}
enabled := pc.Enabled
return ConsentConfig{Enabled: &enabled}
}
// HasPersistedConsent reports whether the user has made an explicit choice yet.
func HasPersistedConsent(dir string) bool {
_, err := os.Stat(filepath.Join(dir, consentFile))
return err == nil
}
// SaveConsent persists an explicit choice. Disabling also clears any buffered,
// unsent telemetry — off means off — best-effort. now defaults to time.Now.
func SaveConsent(dir string, enabled bool, source string, now func() time.Time) error {
if now == nil {
now = time.Now
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
pc := PersistedConsent{
Enabled: enabled,
Source: source,
UpdatedAt: now().UTC().Format(time.RFC3339),
}
b, err := json.MarshalIndent(pc, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, consentFile), b, 0o644); err != nil {
return err
}
if !enabled {
clearBuffer(dir)
}
return nil
}
// clearBuffer deletes every buffered rollup and the last-send marker, so a
// disable leaves nothing to transmit. The anonymous install id is left in place
// (it is not telemetry data and is never sent while disabled). Best-effort.
func clearBuffer(dir string) {
store := NewStore(dir)
if days, err := store.Days(); err == nil {
for _, d := range days {
_ = store.Delete(d)
}
}
_ = os.Remove(filepath.Join(dir, lastSendFile))
}
// CachedConsentResolver returns a consent check that re-resolves at most once
// per ttl, so a hot record path observes a `gortex telemetry on|off` toggle
// within ttl without an os.ReadFile per event. It is the live resolver a
// long-lived process hands to NewRecorderFunc. Safe for concurrent use.
func CachedConsentResolver(dir string, ttl time.Duration) func() bool {
var (
mu sync.Mutex
enabled bool
checkedAt time.Time
valid bool
)
return func() bool {
mu.Lock()
defer mu.Unlock()
now := time.Now()
if !valid || now.Sub(checkedAt) >= ttl {
enabled = ResolveConsent(LoadConsentConfig(dir), os.Getenv).Enabled
checkedAt = now
valid = true
}
return enabled
}
}
// MaybeFirstRunNotice prints a one-time, opt-in notice to w when the user has
// not yet made an explicit choice, and records a default (off) choice so it
// never fires again. Telemetry is opt-in, so this is an informational notice —
// it never enables anything. Returns whether it printed. Fail-soft: a write
// failure still suppresses future notices if the choice was recorded.
func MaybeFirstRunNotice(dir string, w io.Writer) bool {
if HasPersistedConsent(dir) {
return false
}
// Record the default (off) choice up front so the notice is one-time even
// if the process exits immediately after.
_ = SaveConsent(dir, false, "installer", time.Now)
if w != nil {
_, _ = io.WriteString(w,
"Gortex can collect anonymous usage stats (tool/command counts only — no code, "+
"paths, or names). It is OFF by default; enable with `gortex telemetry on`. "+
"See `gortex telemetry status`.\n")
}
return true
}
+79
View File
@@ -0,0 +1,79 @@
package telemetry
import "testing"
func TestResolveConsent(t *testing.T) {
cases := []struct {
name string
cfg ConsentConfig
env map[string]string
wantOn bool
wantSource Source
}{
// Rung 4 — default is off (opt-in).
{"default off", ConsentConfig{}, nil, false, SourceDefault},
// Rung 3 — config decides when no env signal.
{"config on", ConsentConfig{Enabled: new(true)}, nil, true, SourceConfig},
{"config off", ConsentConfig{Enabled: new(false)}, nil, false, SourceConfig},
// Rung 2 — DO_NOT_TRACK forces off.
{"dnt=1 off", ConsentConfig{}, map[string]string{"DO_NOT_TRACK": "1"}, false, SourceDoNotTrack},
{"dnt=true off", ConsentConfig{}, map[string]string{"DO_NOT_TRACK": "true"}, false, SourceDoNotTrack},
{"dnt beats config-on", ConsentConfig{Enabled: new(true)}, map[string]string{"DO_NOT_TRACK": "1"}, false, SourceDoNotTrack},
{"dnt=0 not asserted, config wins", ConsentConfig{Enabled: new(true)}, map[string]string{"DO_NOT_TRACK": "0"}, true, SourceConfig},
{"dnt=false not asserted, default", ConsentConfig{}, map[string]string{"DO_NOT_TRACK": "false"}, false, SourceDefault},
// Rung 1 — explicit Gortex env override wins over everything.
{"env on beats default", ConsentConfig{}, map[string]string{"GORTEX_TELEMETRY": "on"}, true, SourceEnv},
{"env off beats config-on", ConsentConfig{Enabled: new(true)}, map[string]string{"GORTEX_TELEMETRY": "off"}, false, SourceEnv},
{"env on beats dnt", ConsentConfig{}, map[string]string{"GORTEX_TELEMETRY": "1", "DO_NOT_TRACK": "1"}, true, SourceEnv},
{"env unrecognised falls through to dnt", ConsentConfig{}, map[string]string{"GORTEX_TELEMETRY": "maybe", "DO_NOT_TRACK": "1"}, false, SourceDoNotTrack},
{"env empty falls through to config", ConsentConfig{Enabled: new(true)}, map[string]string{"GORTEX_TELEMETRY": ""}, true, SourceConfig},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
getenv := func(k string) string { return c.env[k] }
got := ResolveConsent(c.cfg, getenv)
if got.Enabled != c.wantOn {
t.Errorf("Enabled = %v, want %v", got.Enabled, c.wantOn)
}
if got.Source != c.wantSource {
t.Errorf("Source = %q, want %q", got.Source, c.wantSource)
}
})
}
}
func TestResolveConsentDefaultsToOsGetenv(t *testing.T) {
// A nil getenv must not panic — it falls back to os.Getenv. In the test
// environment neither var is set, so consent is the default (off).
t.Setenv("GORTEX_TELEMETRY", "")
t.Setenv("DO_NOT_TRACK", "")
got := ResolveConsent(ConsentConfig{}, nil)
if got.Enabled || got.Source != SourceDefault {
t.Errorf("nil getenv: got %+v, want {false default}", got)
}
}
func TestParseConsentBool(t *testing.T) {
on := []string{"1", "true", "TRUE", "on", "Yes", " enable ", "enabled"}
off := []string{"0", "false", "Off", "no", "disable", "DISABLED"}
none := []string{"", "maybe", "2", "x"}
for _, s := range on {
if v, ok := parseConsentBool(s); !ok || !v {
t.Errorf("parseConsentBool(%q) = (%v,%v), want (true,true)", s, v, ok)
}
}
for _, s := range off {
if v, ok := parseConsentBool(s); !ok || v {
t.Errorf("parseConsentBool(%q) = (%v,%v), want (false,true)", s, v, ok)
}
}
for _, s := range none {
if _, ok := parseConsentBool(s); ok {
t.Errorf("parseConsentBool(%q) ok = true, want false", s)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package telemetry
import "strings"
// This file holds the small, named record-site helpers the rest of the codebase
// calls so the event taxonomy lives in one place. Each is nil-safe (a nil
// recorder is a no-op) and routes through Recorder.Record, so the consent gate,
// the hard allow-list, and the dimension sanitiser still apply — a record site
// can never widen what leaves the process.
// RecordIndex records one completed index pass: a coarse file-count bucket plus
// a per-language counter for each language present. Exact counts and paths
// never leave the process — only the bucket label and bounded language tokens.
// Beyond the bucket codegraph would record, the per-language breakdown rides
// the same hard allow-list + sanitiser, so it adds signal without widening the
// privacy surface.
func RecordIndex(rec *Recorder, fileCount int, langs []string) {
if rec == nil {
return
}
rec.Record("index", BucketFileCount(fileCount))
seen := make(map[string]bool, len(langs))
for _, lang := range langs {
lang = strings.TrimSpace(lang)
if lang == "" || seen[lang] {
continue
}
seen[lang] = true
rec.Record("index_lang", lang)
}
}
// RecordDaemonSession records one daemon session start, dimensioned by the
// backend kind (memory / sqlite). One event per daemon process.
func RecordDaemonSession(rec *Recorder, backend string) {
if rec == nil {
return
}
rec.Record("daemon_session", backend)
}
// RecordInstall records one install / uninstall, dimensioned by the agent
// target or scope (e.g. "claude", "global") — never a path or user identifier.
// action selects the allow-listed key: "install" (default) or "uninstall".
func RecordInstall(rec *Recorder, action, target string) {
if rec == nil {
return
}
key := "install"
if action == "uninstall" {
key = "uninstall"
}
rec.Record(key, target)
}
// RecordClient folds an MCP client application name into the rollup. The name
// is normalised to its first whitespace-delimited token, lowercased, so a
// "claude-code 1.0.42" handshake records the bounded token "claude-code" and
// the version (an identifying axis) is dropped before it reaches the sanitiser.
func RecordClient(rec *Recorder, clientName string) {
if rec == nil {
return
}
dim := NormalizeClientName(clientName)
if dim == "" {
return
}
rec.Record("client", dim)
}
// NormalizeClientName reduces an MCP clientInfo.name to a bounded, version-free
// token: the first whitespace-delimited field, lowercased. Returns "" for an
// empty / whitespace-only name. Exported so the snapshot path can pre-validate.
func NormalizeClientName(name string) string {
fields := strings.Fields(name)
if len(fields) == 0 {
return ""
}
return strings.ToLower(fields[0])
}
+116
View File
@@ -0,0 +1,116 @@
package telemetry
import (
"sync"
"time"
)
// recorderFlushThreshold bounds in-memory accumulation: once this many events
// are pending the recorder flushes to disk, so a long-lived daemon's buffer
// stays small and a crash loses at most this many counts.
const recorderFlushThreshold = 256
// Recorder accumulates allow-listed usage counts in memory and flushes them to
// a per-day Store. It is the gate every recording path goes through, and it
// upholds the telemetry invariants:
//
// - Consent-gated: a recorder built from disabled consent records nothing,
// opens no file, and creates no telemetry directory.
// - Nil-safe: a nil *Recorder's methods are no-ops, so call sites need no
// branch (handlers stay clean).
// - Fail-silent: recording is an in-memory map increment under a short mutex;
// disk errors on flush are swallowed, never retried, never surfaced.
type Recorder struct {
consent func() bool
store *Store
now func() time.Time
mu sync.Mutex
day string
counts map[string]int
pending int
}
// NewRecorder builds a recorder with a fixed consent decision, captured once.
// Suitable for a short-lived process (a single CLI command). When consent is
// disabled it returns a non-nil recorder that drops everything, so callers
// never need to special-case the off state.
func NewRecorder(consent Consent, store *Store) *Recorder {
enabled := consent.Enabled
return NewRecorderFunc(func() bool { return enabled }, store)
}
// NewRecorderFunc builds a recorder whose consent is re-evaluated on every
// record and flush via resolve. A long-lived process (the daemon) passes a
// live resolver so it stops recording — and stops re-creating a buffer that
// `gortex telemetry off` just cleared — the moment the user disables
// telemetry, instead of freezing the decision at startup. A nil resolve means
// never enabled. Use CachedConsentResolver to bound the resolver's I/O.
func NewRecorderFunc(resolve func() bool, store *Store) *Recorder {
if resolve == nil {
resolve = func() bool { return false }
}
return &Recorder{
consent: resolve,
store: store,
now: time.Now,
counts: map[string]int{},
}
}
// Record counts one allow-listed event, optionally qualified by a bucketed
// dimension. No-op when the recorder is nil, disabled, has no store, or the key
// is not allow-listed. Never performs I/O on the caller's path beyond an
// occasional threshold flush.
func (r *Recorder) Record(key, dim string) {
if r == nil || r.store == nil || !r.consent() {
return
}
name, ok := metricName(key, dim)
if !ok {
return
}
day := DayKey(r.now())
r.mu.Lock()
defer r.mu.Unlock()
if r.day != "" && r.day != day {
// The process crossed a UTC midnight: persist the prior day before
// switching so a long-running daemon doesn't smear two days together.
r.flushLocked()
}
r.day = day
r.counts[name]++
r.pending++
if r.pending >= recorderFlushThreshold {
r.flushLocked()
}
}
// Flush persists the accumulated counts and resets the in-memory buffer. Call
// it on shutdown (and periodically). No-op when disabled or empty; a store
// error is swallowed — telemetry must never disrupt the process.
func (r *Recorder) Flush() {
if r == nil || r.store == nil || !r.consent() {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.flushLocked()
}
// Enabled reports whether this recorder will record anything.
func (r *Recorder) Enabled() bool { return r != nil && r.consent() }
// flushLocked merges the pending counts into the day's store file and clears
// the buffer. The caller must hold r.mu.
func (r *Recorder) flushLocked() {
if r.day == "" || len(r.counts) == 0 {
r.pending = 0
return
}
roll := &Rollup{Day: r.day, Counts: r.counts}
_ = r.store.Merge(roll) // fail-silent: a disk error drops these counts
r.counts = map[string]int{}
r.pending = 0
}
+166
View File
@@ -0,0 +1,166 @@
package telemetry
import (
"testing"
"time"
)
func fixedNow(day string) func() time.Time {
t, _ := time.Parse("2006-01-02", day)
return func() time.Time { return t }
}
func TestRecorderRecordsWhenEnabled(t *testing.T) {
store := NewStore(t.TempDir())
r := NewRecorder(Consent{Enabled: true}, store)
r.now = fixedNow("2026-06-18")
r.Record("mcp_tool_call", "search_symbols")
r.Record("mcp_tool_call", "search_symbols")
r.Record("cli_command", "review")
r.Flush()
got, err := store.Load("2026-06-18")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Counts["mcp_tool_call:search_symbols"] != 2 {
t.Errorf("tool counter = %d, want 2", got.Counts["mcp_tool_call:search_symbols"])
}
if got.Counts["cli_command:review"] != 1 {
t.Errorf("cli counter = %d, want 1", got.Counts["cli_command:review"])
}
}
func TestRecorderDisabledRecordsNothing(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
r := NewRecorder(Consent{Enabled: false}, store)
r.now = fixedNow("2026-06-18")
if r.Enabled() {
t.Fatal("disabled recorder reports Enabled()")
}
r.Record("mcp_tool_call", "search_symbols")
r.Flush()
// Nothing recorded → no day files written at all (no telemetry dir churn).
days, err := store.Days()
if err != nil {
t.Fatalf("Days: %v", err)
}
if len(days) != 0 {
t.Errorf("disabled recorder wrote days %v", days)
}
}
func TestRecorderNilSafe(t *testing.T) {
var r *Recorder
// None of these may panic.
r.Record("mcp_tool_call", "x")
r.Flush()
if r.Enabled() {
t.Error("nil recorder reports Enabled()")
}
}
func TestRecorderDropsDisallowedKey(t *testing.T) {
store := NewStore(t.TempDir())
r := NewRecorder(Consent{Enabled: true}, store)
r.now = fixedNow("2026-06-18")
r.Record("file_path", "/Users/x/secret.go") // not allow-listed
r.Flush()
if days, _ := store.Days(); len(days) != 0 {
t.Errorf("disallowed key produced a rollup file: %v", days)
}
}
func TestRecorderThresholdAutoFlush(t *testing.T) {
store := NewStore(t.TempDir())
r := NewRecorder(Consent{Enabled: true}, store)
r.now = fixedNow("2026-06-18")
for range recorderFlushThreshold {
r.Record("index", "1k-10k")
}
// The threshold was reached, so a flush already happened without an
// explicit Flush() call.
got, _ := store.Load("2026-06-18")
if got.Counts["index:1k-10k"] != recorderFlushThreshold {
t.Errorf("auto-flush counter = %d, want %d", got.Counts["index:1k-10k"], recorderFlushThreshold)
}
}
func TestRecorderDayRollover(t *testing.T) {
store := NewStore(t.TempDir())
r := NewRecorder(Consent{Enabled: true}, store)
day := "2026-06-18"
r.now = func() time.Time { tm, _ := time.Parse("2006-01-02", day); return tm }
r.Record("cli_command", "a")
day = "2026-06-19" // clock advances past midnight
r.Record("cli_command", "b")
r.Flush()
d18, _ := store.Load("2026-06-18")
d19, _ := store.Load("2026-06-19")
if d18.Counts["cli_command:a"] != 1 {
t.Errorf("day-18 counter = %d, want 1 (prior day flushed on rollover)", d18.Counts["cli_command:a"])
}
if d19.Counts["cli_command:b"] != 1 {
t.Errorf("day-19 counter = %d, want 1", d19.Counts["cli_command:b"])
}
}
func TestRecorderLiveConsentStopsRecording(t *testing.T) {
store := NewStore(t.TempDir())
enabled := true
r := NewRecorderFunc(func() bool { return enabled }, store)
r.now = fixedNow("2026-06-18")
r.Record("cli_command", "a")
r.Flush()
if got, _ := store.Load("2026-06-18"); got.Counts["cli_command:a"] != 1 {
t.Fatalf("enabled recorder did not record: %v", got.Counts)
}
// Flip consent off live: further records drop and a flush is a no-op, so a
// running daemon stops recording the moment the user disables telemetry.
enabled = false
if r.Enabled() {
t.Error("Enabled() should follow live consent (off)")
}
r.Record("cli_command", "b")
r.Flush()
if got, _ := store.Load("2026-06-18"); got.Counts["cli_command:b"] != 0 {
t.Errorf("recorder kept recording after consent flipped off: %v", got.Counts)
}
}
func TestCachedConsentResolver(t *testing.T) {
t.Setenv("GORTEX_TELEMETRY", "")
t.Setenv("DO_NOT_TRACK", "")
dir := t.TempDir()
if err := SaveConsent(dir, true, "test", nil); err != nil {
t.Fatal(err)
}
resolve := CachedConsentResolver(dir, 20*time.Millisecond)
if !resolve() {
t.Fatal("resolver should reflect persisted enabled=true")
}
// Flip persisted to off; within the TTL the cached (true) value persists.
if err := SaveConsent(dir, false, "test", nil); err != nil {
t.Fatal(err)
}
if !resolve() {
t.Error("within TTL the cached value should persist")
}
// After the TTL elapses it re-reads → false.
time.Sleep(30 * time.Millisecond)
if resolve() {
t.Error("after TTL the resolver should re-read and return false")
}
}
+142
View File
@@ -0,0 +1,142 @@
package telemetry
import (
"regexp"
"time"
)
// allowedMetrics is the hard allow-list of telemetry counter keys. It is the
// privacy backbone: the aggregator physically cannot record a key that is not
// on this list, so a path, a symbol name, or any arbitrary string can never
// become a metric. Adding a counter is a deliberate edit here and nowhere else.
var allowedMetrics = map[string]bool{
"mcp_tool_call": true, // an MCP tool was invoked; dim = tool name
"cli_command": true, // a CLI subcommand ran; dim = command name
"index": true, // an index pass completed; dim = file-count bucket
"index_lang": true, // a language was present in an index pass; dim = language
"daemon_session": true, // a daemon session started; dim = backend kind
"install": true, // an install applied; dim = agent target / scope
"uninstall": true, // an uninstall applied; dim = agent target / scope
"client": true, // an MCP client connected; dim = client app name
}
// IsAllowedMetric reports whether key may be recorded.
func IsAllowedMetric(key string) bool { return allowedMetrics[key] }
// dimPattern bounds a metric dimension to a short, non-identifying token —
// bucket labels (`1k-10k`), tool/command names (`search_symbols`), backend
// kinds (`sqlite`). Anything with a path separator, whitespace, or length
// beyond 32 is rejected, so even a caller that passes a path or a symbol name
// as a dimension cannot leak it: the counter falls back to the bare key.
var dimPattern = regexp.MustCompile(`^[A-Za-z0-9_.<>+-]{1,32}$`)
// safeDim returns dim when it is a bounded, non-identifying token, else "".
func safeDim(dim string) string {
if dimPattern.MatchString(dim) {
return dim
}
return ""
}
// BucketFileCount collapses a file count into a coarse bucket so an exact
// count — which can narrow identification — is never recorded.
func BucketFileCount(n int) string {
switch {
case n < 100:
return "<100"
case n < 1000:
return "100-1k"
case n < 10000:
return "1k-10k"
default:
return "10k+"
}
}
// BucketDuration collapses an elapsed time into a coarse bucket.
func BucketDuration(d time.Duration) string {
ms := d.Milliseconds()
switch {
case ms < 10_000:
return "<10s"
case ms < 60_000:
return "10-60s"
case ms < 300_000:
return "1-5m"
default:
return "5m+"
}
}
// Rollup is one UTC day's aggregated, coarse counts. Counts maps an
// allow-listed metric key (optionally suffixed with a bucketed dimension after
// a colon) to the number of times it occurred that day.
type Rollup struct {
Day string `json:"day"` // YYYY-MM-DD in UTC
Counts map[string]int `json:"counts"`
}
// DayKey renders the UTC calendar day of t.
func DayKey(t time.Time) string { return t.UTC().Format("2006-01-02") }
// NewRollup creates an empty rollup for the UTC day of t.
func NewRollup(t time.Time) *Rollup {
return &Rollup{Day: DayKey(t), Counts: map[string]int{}}
}
// metricName resolves an allow-listed (key, dim) pair to its counter name. ok
// is false when key is not on the allow-list. A dimension that is not a bounded
// token is dropped, so the bare key is returned — the path/name guard. This is
// the single place the allow-list and dimension sanitiser are applied, shared
// by Rollup.Add and the Recorder so they cannot diverge.
func metricName(key, dim string) (name string, ok bool) {
if !allowedMetrics[key] {
return "", false
}
if d := safeDim(dim); d != "" {
return key + ":" + d, true
}
return key, true
}
// Add increments the counter for an allow-listed metric, optionally qualified
// by a dimension (a bucket label or a fixed enum like a tool name). It returns
// whether the event counted: a key not on the allow-list is silently dropped,
// and a dimension that is not a bounded token is discarded (the bare key still
// counts).
func (r *Rollup) Add(key, dim string) bool {
name, ok := metricName(key, dim)
if !ok {
return false
}
if r.Counts == nil {
r.Counts = map[string]int{}
}
r.Counts[name]++
return true
}
// Merge folds other's counts into r. Days are assumed equal (the caller groups
// by day); a mismatched day is a programmer error and is ignored rather than
// silently corrupting r's day label.
func (r *Rollup) Merge(other *Rollup) {
if other == nil || other.Day != r.Day {
return
}
if r.Counts == nil {
r.Counts = map[string]int{}
}
for k, v := range other.Counts {
r.Counts[k] += v
}
}
// Total is the sum of every counter — a convenience for "did anything happen
// today" checks before persisting or sending.
func (r *Rollup) Total() int {
n := 0
for _, v := range r.Counts {
n += v
}
return n
}
+204
View File
@@ -0,0 +1,204 @@
package telemetry
import (
"strings"
"testing"
"time"
)
// TestIndexAndSessionRecordSitesEmit proves the F9 record-site helpers emit the
// right allow-listed, bucketed counters: an index pass records a file-count
// BUCKET (never the exact count) plus deduped per-language counters, and the
// daemon-session / install / uninstall sites emit their bounded dimensions.
func TestIndexAndSessionRecordSitesEmit(t *testing.T) {
store := NewStore(t.TempDir())
r := NewRecorder(Consent{Enabled: true}, store)
r.now = fixedNow("2026-06-18")
RecordIndex(r, 4200, []string{"go", "python", "go", "", " "})
RecordDaemonSession(r, "sqlite")
RecordInstall(r, "install", "claude")
RecordInstall(r, "uninstall", "cursor")
r.Flush()
got, err := store.Load("2026-06-18")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Counts["index:1k-10k"] != 1 {
t.Errorf("index bucket = %d, want 1 (counts=%v)", got.Counts["index:1k-10k"], got.Counts)
}
if got.Counts["index:4200"] != 0 {
t.Error("the exact file count must never be recorded")
}
// Per-language counters: deduped (go appears twice), empties dropped.
if got.Counts["index_lang:go"] != 1 {
t.Errorf("index_lang:go = %d, want 1 (deduped)", got.Counts["index_lang:go"])
}
if got.Counts["index_lang:python"] != 1 {
t.Errorf("index_lang:python = %d, want 1", got.Counts["index_lang:python"])
}
if got.Counts["index_lang"] != 0 {
t.Error("a blank language must not record a bare index_lang counter")
}
if got.Counts["daemon_session:sqlite"] != 1 {
t.Errorf("daemon_session:sqlite = %d, want 1", got.Counts["daemon_session:sqlite"])
}
if got.Counts["install:claude"] != 1 || got.Counts["uninstall:cursor"] != 1 {
t.Errorf("install/uninstall counters wrong: %v", got.Counts)
}
}
// TestClientNameFolding proves an MCP client handshake folds to a bounded,
// version-free token, lowercased, and that empty names record nothing.
func TestClientNameFolding(t *testing.T) {
store := NewStore(t.TempDir())
r := NewRecorder(Consent{Enabled: true}, store)
r.now = fixedNow("2026-06-18")
RecordClient(r, "claude-code 1.0.42") // version dropped
RecordClient(r, "Cursor") // lowercased
RecordClient(r, " ") // whitespace-only → dropped
RecordClient(r, "") // empty → dropped
r.Flush()
got, err := store.Load("2026-06-18")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Counts["client:claude-code"] != 1 {
t.Errorf("client fold = %v, want client:claude-code", got.Counts)
}
if got.Counts["client:cursor"] != 1 {
t.Errorf("client name must be lowercased: %v", got.Counts)
}
for k := range got.Counts {
if strings.Contains(k, "1.0.42") {
t.Errorf("the client version leaked into telemetry: %q", k)
}
}
if got.Counts["client"] != 0 {
t.Error("an empty client name must not record a bare client counter")
}
if got := NormalizeClientName("VS Code 1.2"); got != "vs" {
t.Errorf("NormalizeClientName first-field-lowercase = %q, want vs", got)
}
}
func TestBucketFileCount(t *testing.T) {
cases := []struct {
n int
want string
}{
{0, "<100"}, {99, "<100"},
{100, "100-1k"}, {999, "100-1k"},
{1000, "1k-10k"}, {9999, "1k-10k"},
{10000, "10k+"}, {5_000_000, "10k+"},
}
for _, c := range cases {
if got := BucketFileCount(c.n); got != c.want {
t.Errorf("BucketFileCount(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestBucketDuration(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{5 * time.Second, "<10s"},
{10 * time.Second, "10-60s"},
{59 * time.Second, "10-60s"},
{time.Minute, "1-5m"},
{4 * time.Minute, "1-5m"},
{5 * time.Minute, "5m+"},
{time.Hour, "5m+"},
}
for _, c := range cases {
if got := BucketDuration(c.d); got != c.want {
t.Errorf("BucketDuration(%s) = %q, want %q", c.d, got, c.want)
}
}
}
func TestRollupAddAllowList(t *testing.T) {
r := NewRollup(time.Date(2026, 6, 18, 9, 0, 0, 0, time.UTC))
if r.Day != "2026-06-18" {
t.Fatalf("Day = %q, want 2026-06-18", r.Day)
}
// Allow-listed key counts.
if !r.Add("mcp_tool_call", "search_symbols") {
t.Error("allow-listed metric was dropped")
}
r.Add("mcp_tool_call", "search_symbols")
if got := r.Counts["mcp_tool_call:search_symbols"]; got != 2 {
t.Errorf("dim counter = %d, want 2", got)
}
// A key not on the allow-list is dropped entirely.
if r.Add("secret_path", "/Users/x/private.go") {
t.Error("non-allow-listed metric must be dropped")
}
if len(r.Counts) != 1 {
t.Errorf("dropped metric leaked into counts: %v", r.Counts)
}
// A path-like dimension on an allowed key is discarded; the bare key
// still counts (no leak of the path).
r.Add("index", "/abs/path/with/slashes")
if _, leaked := r.Counts["index:/abs/path/with/slashes"]; leaked {
t.Error("path dimension leaked into a counter name")
}
if got := r.Counts["index"]; got != 1 {
t.Errorf("bare key counter = %d, want 1", got)
}
// A bucket-label dimension passes.
r.Add("index", BucketFileCount(5000))
if got := r.Counts["index:1k-10k"]; got != 1 {
t.Errorf("bucket dim counter = %d, want 1", got)
}
}
func TestRollupMerge(t *testing.T) {
day := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
a := NewRollup(day)
a.Add("cli_command", "index")
b := NewRollup(day)
b.Add("cli_command", "index")
b.Add("daemon_session", "sqlite")
a.Merge(b)
if got := a.Counts["cli_command:index"]; got != 2 {
t.Errorf("merged counter = %d, want 2", got)
}
if got := a.Counts["daemon_session:sqlite"]; got != 1 {
t.Errorf("merged new counter = %d, want 1", got)
}
if a.Total() != 3 {
t.Errorf("Total = %d, want 3", a.Total())
}
// A mismatched-day merge is ignored (no corruption).
other := NewRollup(day.AddDate(0, 0, 1))
other.Add("cli_command", "x")
a.Merge(other)
if a.Total() != 3 {
t.Errorf("mismatched-day merge mutated rollup: Total = %d, want 3", a.Total())
}
}
func TestIsAllowedMetric(t *testing.T) {
for _, k := range []string{"mcp_tool_call", "cli_command", "index", "daemon_session"} {
if !IsAllowedMetric(k) {
t.Errorf("%q should be allow-listed", k)
}
}
for _, k := range []string{"", "file_path", "user_query", "anything_else"} {
if IsAllowedMetric(k) {
t.Errorf("%q must not be allow-listed", k)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package telemetry
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/google/uuid"
)
// sendSchemaVersion is the wire-contract version of the aggregate payload.
// Bump it (and specs/telemetry-contract.md) on any payload-shape change so the
// ingest endpoint can branch on it.
const sendSchemaVersion = 1
// EnvEndpoint names the environment variable that configures the ingest
// endpoint URL. There is deliberately NO built-in default: when it is empty,
// telemetry is aggregated locally but NEVER transmitted. The live-send path is
// gated on an operator setting this to a confirmed ingest URL — until then the
// whole pipeline runs end to end except the final POST, which is skipped.
const EnvEndpoint = "GORTEX_TELEMETRY_ENDPOINT"
const (
installIDFile = "install-id"
lastSendFile = "last-send"
sendTimeout = 5 * time.Second
)
// InstallID returns a stable anonymous per-machine id, minting and persisting a
// random UUIDv4 on first use. It is the only stable identifier telemetry
// carries — it ties a machine's daily aggregates together without revealing who
// or where. Fail-soft: if dir is unwritable the id is returned for this process
// but not persisted, so a payload stays well-formed and the next run re-mints.
func InstallID(dir string) string {
path := filepath.Join(dir, installIDFile)
if b, err := os.ReadFile(path); err == nil {
if id := strings.TrimSpace(string(b)); id != "" {
return id
}
}
id := uuid.NewString()
if err := os.MkdirAll(dir, 0o755); err == nil {
_ = os.WriteFile(path, []byte(id+"\n"), 0o644)
}
return id
}
// Payload is the daily-aggregate envelope POSTed to the ingest endpoint. Every
// field is coarse and non-identifying — an anonymous install id, the platform,
// and bucketed daily counts. See specs/telemetry-contract.md for the contract.
type Payload struct {
InstallID string `json:"install_id"`
SchemaVersion int `json:"schema_version"`
GortexVersion string `json:"gortex_version"`
OS string `json:"os"`
Arch string `json:"arch"`
CI bool `json:"ci"`
Days []*Rollup `json:"days"`
}
// BuildPayload assembles the envelope from completed-day rollups.
func BuildPayload(installID, version string, days []*Rollup, getenv func(string) string) Payload {
if getenv == nil {
getenv = os.Getenv
}
return Payload{
InstallID: installID,
SchemaVersion: sendSchemaVersion,
GortexVersion: version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
CI: isCI(getenv),
Days: days,
}
}
// isCI reports whether this looks like a CI environment, so aggregates can be
// separated from interactive use. Honors the de-facto `CI` variable.
func isCI(getenv func(string) string) bool {
v := strings.ToLower(strings.TrimSpace(getenv("CI")))
return v != "" && v != "0" && v != "false"
}
// Sender transmits completed daily rollups to the ingest endpoint, once per UTC
// day at most. It is fail-silent and a no-op whenever the endpoint is empty
// (the blocked state) or consent is disabled.
type Sender struct {
store *Store
dir string
endpoint string
version string
client *http.Client
now func() time.Time
getenv func(string) string
}
// NewSender builds a sender. The endpoint is read from EnvEndpoint with no
// fallback — an empty result leaves live send disabled.
func NewSender(store *Store, dir, version string, getenv func(string) string) *Sender {
if getenv == nil {
getenv = os.Getenv
}
return &Sender{
store: store,
dir: dir,
endpoint: strings.TrimSpace(getenv(EnvEndpoint)),
version: version,
client: &http.Client{Timeout: sendTimeout},
now: time.Now,
getenv: getenv,
}
}
// MaybeSend opportunistically transmits completed days. It no-ops unless every
// condition holds: consent enabled, an endpoint configured, this machine has
// not already attempted a send today, and there is at least one completed day
// buffered. It never returns an error — a failed send leaves the days buffered
// for the next day's attempt.
func (s *Sender) MaybeSend(ctx context.Context, consent Consent) {
if s == nil || !consent.Enabled || s.endpoint == "" {
return
}
today := DayKey(s.now())
if s.lastSendDay() == today {
return
}
days, err := s.store.LoadCompleted(today)
if err != nil || len(days) == 0 {
return
}
// Mark the attempt before sending so a slow or failing endpoint cannot
// produce more than one attempt per day; unsent days remain buffered and
// ship tomorrow.
s.markSent(today)
payload := BuildPayload(InstallID(s.dir), s.version, days, s.getenv)
if err := s.post(ctx, payload); err != nil {
return // fail-silent
}
for _, d := range days {
_ = s.store.Delete(d.Day)
}
}
// post POSTs the payload as JSON. Any non-2xx response is an error; there are
// no retries — a single attempt per day is the contract.
func (s *Sender) post(ctx context.Context, payload Payload) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode/100 != 2 {
return fmt.Errorf("telemetry ingest returned status %d", resp.StatusCode)
}
return nil
}
func (s *Sender) lastSendDay() string {
b, err := os.ReadFile(filepath.Join(s.dir, lastSendFile))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (s *Sender) markSent(day string) {
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return
}
_ = os.WriteFile(filepath.Join(s.dir, lastSendFile), []byte(day+"\n"), 0o644)
}
+187
View File
@@ -0,0 +1,187 @@
package telemetry
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func fixedClock(day string) func() time.Time {
t, _ := time.Parse("2006-01-02", day)
return func() time.Time { return t }
}
func TestInstallIDStableAcrossCalls(t *testing.T) {
dir := t.TempDir()
a := InstallID(dir)
b := InstallID(dir)
if a == "" {
t.Fatal("InstallID returned empty")
}
if a != b {
t.Errorf("InstallID not stable: %q != %q", a, b)
}
// A fresh dir mints a different id.
if c := InstallID(t.TempDir()); c == a {
t.Errorf("two machines share an install id: %q", c)
}
}
func seedCompletedDay(t *testing.T, store *Store, day string) {
t.Helper()
r := &Rollup{Day: day, Counts: map[string]int{}}
r.Add("mcp_tool_call", "search_symbols")
if err := store.Save(r); err != nil {
t.Fatalf("seed %s: %v", day, err)
}
}
func TestMaybeSendTransmitsAndClearsCompletedDays(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
seedCompletedDay(t, store, "2026-06-16")
seedCompletedDay(t, store, "2026-06-17")
// Today's open day must NOT be sent.
open := &Rollup{Day: "2026-06-18", Counts: map[string]int{}}
open.Add("cli_command", "review")
_ = store.Save(open)
var got Payload
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &got)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
s := NewSender(store, dir, "v9.9.9", func(k string) string {
if k == EnvEndpoint {
return srv.URL
}
return ""
})
s.now = fixedClock("2026-06-18")
s.MaybeSend(context.Background(), Consent{Enabled: true})
if atomic.LoadInt32(&hits) != 1 {
t.Fatalf("endpoint hit %d times, want 1", hits)
}
if got.SchemaVersion != sendSchemaVersion || got.GortexVersion != "v9.9.9" || got.InstallID == "" {
t.Errorf("payload envelope wrong: %+v", got)
}
if len(got.Days) != 2 {
t.Fatalf("payload carried %d days, want 2 (open day excluded)", len(got.Days))
}
// Sent days deleted; the open day survives.
days, _ := store.Days()
if len(days) != 1 || days[0] != "2026-06-18" {
t.Errorf("after send remaining days = %v, want [2026-06-18]", days)
}
}
func TestMaybeSendNoEndpointIsBlocked(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
seedCompletedDay(t, store, "2026-06-17")
// No EnvEndpoint set → the live send is blocked.
s := NewSender(store, dir, "v1", func(string) string { return "" })
s.now = fixedClock("2026-06-18")
s.MaybeSend(context.Background(), Consent{Enabled: true})
// Nothing transmitted, nothing deleted, no last-send marker.
if days, _ := store.Days(); len(days) != 1 {
t.Errorf("blocked send deleted buffered days: %v", days)
}
if s.lastSendDay() != "" {
t.Error("blocked send wrote a last-send marker")
}
}
func TestMaybeSendConsentDisabled(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
seedCompletedDay(t, store, "2026-06-17")
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
s := NewSender(store, dir, "v1", func(k string) string {
if k == EnvEndpoint {
return srv.URL
}
return ""
})
s.now = fixedClock("2026-06-18")
s.MaybeSend(context.Background(), Consent{Enabled: false})
if atomic.LoadInt32(&hits) != 0 {
t.Errorf("disabled consent still transmitted (%d hits)", hits)
}
}
func TestMaybeSendOncePerDay(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
seedCompletedDay(t, store, "2026-06-17")
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
mk := func() *Sender {
s := NewSender(store, dir, "v1", func(k string) string {
if k == EnvEndpoint {
return srv.URL
}
return ""
})
s.now = fixedClock("2026-06-18")
return s
}
mk().MaybeSend(context.Background(), Consent{Enabled: true})
// Re-seed a completed day and try again the same UTC day: must not re-send.
seedCompletedDay(t, store, "2026-06-16")
mk().MaybeSend(context.Background(), Consent{Enabled: true})
if atomic.LoadInt32(&hits) != 1 {
t.Errorf("sent %d times in one day, want 1", hits)
}
}
func TestMaybeSendServerErrorKeepsDays(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
seedCompletedDay(t, store, "2026-06-17")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
s := NewSender(store, dir, "v1", func(k string) string {
if k == EnvEndpoint {
return srv.URL
}
return ""
})
s.now = fixedClock("2026-06-18")
s.MaybeSend(context.Background(), Consent{Enabled: true}) // must not panic
// A 5xx is final (no retry) but the days are NOT deleted — they ship later.
if days, _ := store.Days(); len(days) != 1 {
t.Errorf("server error dropped buffered days: %v", days)
}
}
+140
View File
@@ -0,0 +1,140 @@
package telemetry
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// Store persists daily rollups as one JSON file per UTC day under a telemetry
// directory (rollup-YYYY-MM-DD.json). Per-day files let a completed day be
// read, sent, and deleted independently of the day still accumulating.
type Store struct {
dir string
}
// NewStore roots a store at dir; the directory is created on first write.
func NewStore(dir string) *Store { return &Store{dir: dir} }
const (
rollupFilePrefix = "rollup-"
rollupFileSuffix = ".json"
)
func (s *Store) pathForDay(day string) string {
return filepath.Join(s.dir, rollupFilePrefix+day+rollupFileSuffix)
}
// Load reads a day's rollup, returning an empty rollup (not an error) when the
// day has no file yet — the natural starting point for accumulation.
func (s *Store) Load(day string) (*Rollup, error) {
b, err := os.ReadFile(s.pathForDay(day))
if errors.Is(err, fs.ErrNotExist) {
return &Rollup{Day: day, Counts: map[string]int{}}, nil
}
if err != nil {
return nil, err
}
r := &Rollup{}
if err := json.Unmarshal(b, r); err != nil {
return nil, err
}
if r.Counts == nil {
r.Counts = map[string]int{}
}
return r, nil
}
// Save atomically writes a rollup's day file (temp file + rename), creating the
// telemetry directory on demand. Atomicity guarantees a reader (or a crash)
// never sees a half-written rollup.
func (s *Store) Save(r *Rollup) error {
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(s.dir, rollupFilePrefix+r.Day+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op after a successful rename
if _, err := tmp.Write(b); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, s.pathForDay(r.Day))
}
// Merge loads a day, folds r into it, and saves — the recorder's accumulate
// step, so concurrent processes converge on the same day's totals.
func (s *Store) Merge(r *Rollup) error {
existing, err := s.Load(r.Day)
if err != nil {
return err
}
existing.Merge(r)
return s.Save(existing)
}
// Days returns every persisted day, sorted ascending.
func (s *Store) Days() ([]string, error) {
entries, err := os.ReadDir(s.dir)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var days []string
for _, e := range entries {
n := e.Name()
if strings.HasPrefix(n, rollupFilePrefix) && strings.HasSuffix(n, rollupFileSuffix) {
days = append(days, strings.TrimSuffix(strings.TrimPrefix(n, rollupFilePrefix), rollupFileSuffix))
}
}
sort.Strings(days)
return days, nil
}
// LoadCompleted returns every rollup whose day is strictly before today (UTC).
// Today is still accumulating, so only completed days are safe to send — this
// is how volume scales with active machines, not with tool calls.
func (s *Store) LoadCompleted(today string) ([]*Rollup, error) {
days, err := s.Days()
if err != nil {
return nil, err
}
var out []*Rollup
for _, d := range days {
if d >= today {
continue // today or a future-dated file: still open
}
r, err := s.Load(d)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// Delete removes a day's file (after a successful send). A missing file is not
// an error — delete is idempotent.
func (s *Store) Delete(day string) error {
err := os.Remove(s.pathForDay(day))
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
+120
View File
@@ -0,0 +1,120 @@
package telemetry
import (
"testing"
"time"
)
func TestStoreRoundTrip(t *testing.T) {
s := NewStore(t.TempDir())
// Loading an absent day yields an empty rollup, not an error.
r, err := s.Load("2026-06-17")
if err != nil {
t.Fatalf("Load absent: %v", err)
}
if r.Total() != 0 {
t.Errorf("absent day Total = %d, want 0", r.Total())
}
r.Add("mcp_tool_call", "find_usages")
r.Add("mcp_tool_call", "find_usages")
r.Add("index", BucketFileCount(250))
if err := s.Save(r); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := s.Load("2026-06-17")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Counts["mcp_tool_call:find_usages"] != 2 || got.Counts["index:100-1k"] != 1 {
t.Errorf("round-trip mismatch: %v", got.Counts)
}
}
func TestStoreMergeAccumulates(t *testing.T) {
s := NewStore(t.TempDir())
day := "2026-06-17"
first := &Rollup{Day: day, Counts: map[string]int{}}
first.Add("cli_command", "review")
if err := s.Merge(first); err != nil {
t.Fatalf("Merge first: %v", err)
}
second := &Rollup{Day: day, Counts: map[string]int{}}
second.Add("cli_command", "review")
second.Add("daemon_session", "memory")
if err := s.Merge(second); err != nil {
t.Fatalf("Merge second: %v", err)
}
got, _ := s.Load(day)
if got.Counts["cli_command:review"] != 2 {
t.Errorf("merge did not accumulate: %v", got.Counts)
}
if got.Counts["daemon_session:memory"] != 1 {
t.Errorf("merge dropped a new counter: %v", got.Counts)
}
}
func TestStoreLoadCompleted(t *testing.T) {
s := NewStore(t.TempDir())
for _, day := range []string{"2026-06-15", "2026-06-16", "2026-06-17"} {
r := &Rollup{Day: day, Counts: map[string]int{}}
r.Add("cli_command", "x")
if err := s.Save(r); err != nil {
t.Fatalf("Save %s: %v", day, err)
}
}
// today = 2026-06-17 → only the two strictly-earlier days are completed.
completed, err := s.LoadCompleted("2026-06-17")
if err != nil {
t.Fatalf("LoadCompleted: %v", err)
}
if len(completed) != 2 {
t.Fatalf("completed days = %d, want 2", len(completed))
}
if completed[0].Day != "2026-06-15" || completed[1].Day != "2026-06-16" {
t.Errorf("completed days unsorted/wrong: %s, %s", completed[0].Day, completed[1].Day)
}
// Days() lists all three; Delete removes one idempotently.
days, _ := s.Days()
if len(days) != 3 {
t.Errorf("Days = %v, want 3", days)
}
if err := s.Delete("2026-06-15"); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := s.Delete("2026-06-15"); err != nil {
t.Fatalf("Delete (missing, must be idempotent): %v", err)
}
days, _ = s.Days()
if len(days) != 2 {
t.Errorf("after delete Days = %v, want 2", days)
}
}
func TestStoreDaysEmptyDir(t *testing.T) {
// A store whose dir was never written must report no days, not error.
s := NewStore(t.TempDir() + "/never-created")
days, err := s.Days()
if err != nil {
t.Fatalf("Days on absent dir: %v", err)
}
if len(days) != 0 {
t.Errorf("Days = %v, want empty", days)
}
// Save then re-list to confirm creation-on-write.
r := NewRollup(time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC))
r.Add("index", "10k+")
if err := s.Save(r); err != nil {
t.Fatalf("Save creates dir: %v", err)
}
if days, _ := s.Days(); len(days) != 1 {
t.Errorf("after first Save Days = %v, want 1", days)
}
}