| 1 | // Package telemetry is the settings object behind the Errors and Analytics |
| 2 | // tabs (spec 2026-09-07-errors-and-analytics-design.md §3.3): one JSON |
| 3 | // shape at two levels — accounts/<sqid>/telemetry.json and |
| 4 | // apps/<sqid>/<app>/telemetry.json — resolved app → account → built-in |
| 5 | // default. Defaults are rulings (Paul, 2026-09-07): analytics off, the |
| 6 | // browser door on, the log level error. |
| 7 | package telemetry |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "encoding/json" |
| 12 | "errors" |
| 13 | "fmt" |
| 14 | "os" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | const ( |
| 20 | LogLevelError = "error" |
| 21 | LogLevelWarn = "warn" |
| 22 | CountEdge = "edge" |
| 23 | CountOff = "off" |
| 24 | DoorOn = "on" |
| 25 | DoorOff = "off" |
| 26 | ) |
| 27 | |
| 28 | // Analytics is the analytics half. Empty strings mean "not set here". |
| 29 | type Analytics struct { |
| 30 | Count string `json:"count,omitempty"` |
| 31 | Door string `json:"door,omitempty"` |
| 32 | } |
| 33 | |
| 34 | // Errors is the errors half. |
| 35 | type Errors struct { |
| 36 | LogLevel string `json:"log_level,omitempty"` |
| 37 | } |
| 38 | |
| 39 | // Settings is the wire shape at either level. Additive: unknown keys are |
| 40 | // ignored, absent halves inherit. |
| 41 | type Settings struct { |
| 42 | Analytics *Analytics `json:"analytics,omitempty"` |
| 43 | Errors *Errors `json:"errors,omitempty"` |
| 44 | } |
| 45 | |
| 46 | // AccountKey is the account-level object, beside account.json (which stays |
| 47 | // identity-only). |
| 48 | func AccountKey(sqid string) string { return "accounts/" + sqid + "/telemetry.json" } |
| 49 | |
| 50 | // AppKey is the per-app override. |
| 51 | func AppKey(sqid, app string) string { return release.AppPrefix(sqid, app) + "telemetry.json" } |
| 52 | |
| 53 | // ValidLogLevel reports whether s is a level the setting accepts. |
| 54 | func ValidLogLevel(s string) bool { return s == LogLevelError || s == LogLevelWarn } |
| 55 | |
| 56 | // Read returns the object at key and its etag; an absent object is the |
| 57 | // zero Settings with an empty etag and no error. |
| 58 | func Read(ctx context.Context, s release.Store, key string) (Settings, string, error) { |
| 59 | body, etag, err := s.Get(ctx, key) |
| 60 | if err != nil { |
| 61 | if errors.Is(err, os.ErrNotExist) { |
| 62 | return Settings{}, "", nil |
| 63 | } |
| 64 | return Settings{}, "", err |
| 65 | } |
| 66 | var st Settings |
| 67 | if err := json.Unmarshal(body, &st); err != nil { |
| 68 | return Settings{}, "", fmt.Errorf("telemetry: %s: %w", key, err) |
| 69 | } |
| 70 | return st, etag, nil |
| 71 | } |
| 72 | |
| 73 | // Update applies fn to the object at key under compare-and-swap, three |
| 74 | // attempts, so two owners editing different halves at once both land. |
| 75 | func Update(ctx context.Context, s release.Store, key string, fn func(*Settings)) error { |
| 76 | var last error |
| 77 | for attempt := 0; attempt < 3; attempt++ { |
| 78 | st, etag, err := Read(ctx, s, key) |
| 79 | if err != nil { |
| 80 | return err |
| 81 | } |
| 82 | fn(&st) |
| 83 | body, err := json.Marshal(st) |
| 84 | if err != nil { |
| 85 | return err |
| 86 | } |
| 87 | if _, err := s.Put(ctx, key, body, etag); err != nil { |
| 88 | if errors.Is(err, release.ErrCASConflict) { |
| 89 | last = err |
| 90 | continue |
| 91 | } |
| 92 | return err |
| 93 | } |
| 94 | return nil |
| 95 | } |
| 96 | return last |
| 97 | } |
| 98 | |
| 99 | // Resolved is what a reader acts on, with each value's provenance so a |
| 100 | // settings page can say "inherited from the account". |
| 101 | type Resolved struct { |
| 102 | LogLevel, LogLevelFrom string |
| 103 | Count, CountFrom string |
| 104 | Door, DoorFrom string |
| 105 | } |
| 106 | |
| 107 | // Resolve reads both levels and folds them over the defaults. A store |
| 108 | // error fails the resolve; an absent object at either level is fine. |
| 109 | func Resolve(ctx context.Context, s release.Store, sqid, app string) (Resolved, error) { |
| 110 | if s == nil { |
| 111 | return Fold(Settings{}, Settings{}), nil |
| 112 | } |
| 113 | acct, _, err := Read(ctx, s, AccountKey(sqid)) |
| 114 | if err != nil { |
| 115 | return Fold(Settings{}, Settings{}), err |
| 116 | } |
| 117 | appSt, _, err := Read(ctx, s, AppKey(sqid, app)) |
| 118 | if err != nil { |
| 119 | return Fold(Settings{}, Settings{}), err |
| 120 | } |
| 121 | return Fold(acct, appSt), nil |
| 122 | } |
| 123 | |
| 124 | // Fold is Resolve over settings already read: the account level, then the |
| 125 | // app level, over the built-in defaults. A reader that caches the two |
| 126 | // objects (the box's switch pass) folds them here. |
| 127 | func Fold(acct, appSt Settings) Resolved { |
| 128 | r := Resolved{LogLevel: LogLevelError, LogLevelFrom: "default", Count: CountOff, CountFrom: "default", Door: DoorOn, DoorFrom: "default"} |
| 129 | for _, lvl := range []struct { |
| 130 | from string |
| 131 | st Settings |
| 132 | }{{"account", acct}, {"app", appSt}} { |
| 133 | if lvl.st.Errors != nil && ValidLogLevel(lvl.st.Errors.LogLevel) { |
| 134 | r.LogLevel, r.LogLevelFrom = lvl.st.Errors.LogLevel, lvl.from |
| 135 | } |
| 136 | if lvl.st.Analytics != nil { |
| 137 | if c := lvl.st.Analytics.Count; c == CountEdge || c == CountOff { |
| 138 | r.Count, r.CountFrom = c, lvl.from |
| 139 | } |
| 140 | if d := lvl.st.Analytics.Door; d == DoorOn || d == DoorOff { |
| 141 | r.Door, r.DoorFrom = d, lvl.from |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | return r |
| 146 | } |
| 147 | |