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
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:
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user