carlos / platform Public

telemetry: the two-level settings object behind Errors and Analytics (spec §3.3)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 742404cb6ec462e411c9e53af47bc4836a4bcc5e parent f5ef07b
2 files changed, +186 −0
  • internal/telemetry/telemetry.go +139 −0
  • internal/telemetry/telemetry_test.go +47 −0
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
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
+}
diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go
new file mode 100644
index 000000000..3e2dc5557
--- /dev/null
+++ b/internal/telemetry/telemetry_test.go
@@ -0,0 +1,47 @@
+package telemetry
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestResolveAppThenAccountThenDefault(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ r, err := Resolve(ctx, s, "bab", "web")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.LogLevel != LogLevelError || r.LogLevelFrom != "default" || r.Count != CountOff || r.CountFrom != "default" || r.Door != DoorOn {
+ t.Fatalf("defaults = %+v", r)
+ }
+ if err := Update(ctx, s, AccountKey("bab"), func(st *Settings) {
+ st.Errors = &Errors{LogLevel: LogLevelWarn}
+ st.Analytics = &Analytics{Count: CountEdge}
+ }); err != nil {
+ t.Fatal(err)
+ }
+ r, _ = Resolve(ctx, s, "bab", "web")
+ if r.LogLevel != LogLevelWarn || r.LogLevelFrom != "account" || r.Count != CountEdge || r.CountFrom != "account" || r.Door != DoorOn || r.DoorFrom != "default" {
+ t.Fatalf("account level = %+v", r)
+ }
+ if err := Update(ctx, s, AppKey("bab", "web"), func(st *Settings) {
+ st.Analytics = &Analytics{Count: CountOff}
+ }); err != nil {
+ t.Fatal(err)
+ }
+ r, _ = Resolve(ctx, s, "bab", "web")
+ if r.LogLevel != LogLevelWarn || r.Count != CountOff || r.CountFrom != "app" {
+ t.Fatalf("app override = %+v", r)
+ }
+ // Update preserves the other key.
+ if err := Update(ctx, s, AppKey("bab", "web"), func(st *Settings) { st.Errors = &Errors{LogLevel: LogLevelError} }); err != nil {
+ t.Fatal(err)
+ }
+ got, _, _ := Read(ctx, s, AppKey("bab", "web"))
+ if got.Analytics == nil || got.Analytics.Count != CountOff || got.Errors == nil || got.Errors.LogLevel != LogLevelError {
+ t.Fatalf("read back = %+v", got)
+ }
+}