// Package telemetry is the settings object behind the Errors and Analytics // tabs (spec 2026-09-07-errors-and-analytics-design.md §3.3): one JSON // shape at two levels — accounts//telemetry.json and // apps///telemetry.json — resolved app → account → built-in // default. Defaults are rulings (Paul, 2026-09-07): analytics off, the // browser door on, the log level error. package telemetry import ( "context" "encoding/json" "errors" "fmt" "os" "github.com/carlosframework/platform/internal/release" ) const ( LogLevelError = "error" LogLevelWarn = "warn" CountEdge = "edge" CountOff = "off" DoorOn = "on" DoorOff = "off" ) // Analytics is the analytics half. Empty strings mean "not set here". type Analytics struct { Count string `json:"count,omitempty"` Door string `json:"door,omitempty"` } // Errors is the errors half. type Errors struct { LogLevel string `json:"log_level,omitempty"` } // Settings is the wire shape at either level. Additive: unknown keys are // ignored, absent halves inherit. type Settings struct { Analytics *Analytics `json:"analytics,omitempty"` Errors *Errors `json:"errors,omitempty"` } // AccountKey is the account-level object, beside account.json (which stays // identity-only). func AccountKey(sqid string) string { return "accounts/" + sqid + "/telemetry.json" } // AppKey is the per-app override. func AppKey(sqid, app string) string { return release.AppPrefix(sqid, app) + "telemetry.json" } // ValidLogLevel reports whether s is a level the setting accepts. func ValidLogLevel(s string) bool { return s == LogLevelError || s == LogLevelWarn } // Read returns the object at key and its etag; an absent object is the // zero Settings with an empty etag and no error. func Read(ctx context.Context, s release.Store, key string) (Settings, string, error) { body, etag, err := s.Get(ctx, key) if err != nil { if errors.Is(err, os.ErrNotExist) { return Settings{}, "", nil } return Settings{}, "", err } var st Settings if err := json.Unmarshal(body, &st); err != nil { return Settings{}, "", fmt.Errorf("telemetry: %s: %w", key, err) } return st, etag, nil } // Update applies fn to the object at key under compare-and-swap, three // attempts, so two owners editing different halves at once both land. func Update(ctx context.Context, s release.Store, key string, fn func(*Settings)) error { var last error for attempt := 0; attempt < 3; attempt++ { st, etag, err := Read(ctx, s, key) if err != nil { return err } fn(&st) body, err := json.Marshal(st) if err != nil { return err } if _, err := s.Put(ctx, key, body, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { last = err continue } return err } return nil } return last } // Resolved is what a reader acts on, with each value's provenance so a // settings page can say "inherited from the account". type Resolved struct { LogLevel, LogLevelFrom string Count, CountFrom string Door, DoorFrom string } // Resolve reads both levels and folds them over the defaults. A store // error fails the resolve; an absent object at either level is fine. func Resolve(ctx context.Context, s release.Store, sqid, app string) (Resolved, error) { if s == nil { return Fold(Settings{}, Settings{}), nil } acct, _, err := Read(ctx, s, AccountKey(sqid)) if err != nil { return Fold(Settings{}, Settings{}), err } appSt, _, err := Read(ctx, s, AppKey(sqid, app)) if err != nil { return Fold(Settings{}, Settings{}), err } return Fold(acct, appSt), nil } // Fold is Resolve over settings already read: the account level, then the // app level, over the built-in defaults. A reader that caches the two // objects (the box's switch pass) folds them here. func Fold(acct, appSt Settings) Resolved { r := Resolved{LogLevel: LogLevelError, LogLevelFrom: "default", Count: CountOff, CountFrom: "default", Door: DoorOn, DoorFrom: "default"} for _, lvl := range []struct { from string st Settings }{{"account", acct}, {"app", appSt}} { if lvl.st.Errors != nil && ValidLogLevel(lvl.st.Errors.LogLevel) { r.LogLevel, r.LogLevelFrom = lvl.st.Errors.LogLevel, lvl.from } if lvl.st.Analytics != nil { if c := lvl.st.Analytics.Count; c == CountEdge || c == CountOff { r.Count, r.CountFrom = c, lvl.from } if d := lvl.st.Analytics.Door; d == DoorOn || d == DoorOff { r.Door, r.DoorFrom = d, lvl.from } } } return r }