carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
7package telemetry
8
9import (
10 "context"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "os"
15
16 "github.com/carlosframework/platform/internal/release"
17)
18
19const (
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".
29type Analytics struct {
30 Count string `json:"count,omitempty"`
31 Door string `json:"door,omitempty"`
32}
33
34// Errors is the errors half.
35type 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.
41type 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).
48func AccountKey(sqid string) string { return "accounts/" + sqid + "/telemetry.json" }
49
50// AppKey is the per-app override.
51func AppKey(sqid, app string) string { return release.AppPrefix(sqid, app) + "telemetry.json" }
52
53// ValidLogLevel reports whether s is a level the setting accepts.
54func 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.
58func 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.
75func 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".
101type 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.
109func 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.
127func 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