| new file mode 100644 |
| index 000000000..c723e06f5 |
| --- /dev/null |
| +++ b/internal/telemetry/telemetry.go |
| @@ -0,0 +1,139 @@ |
| +// 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/<sqid>/telemetry.json and |
| +// apps/<sqid>/<app>/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) { |
| + r := Resolved{LogLevel: LogLevelError, LogLevelFrom: "default", Count: CountOff, CountFrom: "default", Door: DoorOn, DoorFrom: "default"} |
| + if s == nil { |
| + return r, nil |
| + } |
| + acct, _, err := Read(ctx, s, AccountKey(sqid)) |
| + if err != nil { |
| + return r, err |
| + } |
| + appSt, _, err := Read(ctx, s, AppKey(sqid, app)) |
| + if err != nil { |
| + return r, err |
| + } |
| + 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, nil |
| +} |