| 1 | // The CLI's errors API — the read side of `carlos errors` and the write |
| 2 | // side of the telemetry settings object (spec 2026-09-07 §3.6). It shares |
| 3 | // the instance family's gate (instanceGate) exactly as the logs API does: |
| 4 | // same principal proof, same byte-identical 404, same rate limiter — and an |
| 5 | // app token may read its own app's errors, which is the trust the logs door |
| 6 | // already extends it. |
| 7 | package console |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "encoding/json" |
| 12 | "log" |
| 13 | "net/http" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/apperr" |
| 18 | "github.com/carlosframework/platform/internal/telemetry" |
| 19 | ) |
| 20 | |
| 21 | // errorsResponse is the wire shape `carlos errors` and the tab read. |
| 22 | type errorsResponse struct { |
| 23 | Groups []apperr.Group `json:"groups"` |
| 24 | Records []apperr.Record `json:"records,omitempty"` |
| 25 | Truncated bool `json:"truncated"` |
| 26 | LogLevel string `json:"log_level"` |
| 27 | Sources []string `json:"sources"` |
| 28 | } |
| 29 | |
| 30 | // handleAPIErrors answers `carlos errors`: the instance family's gate |
| 31 | // (an app token may read its own errors), the shared filter, one read. |
| 32 | func (s *Server) handleAPIErrors(w http.ResponseWriter, r *http.Request) { |
| 33 | _, acct, app, ok := s.instanceGate(w, r) |
| 34 | if !ok { |
| 35 | return |
| 36 | } |
| 37 | f, level, bad := errorsFilterFromQuery(r.URL.Query(), time.Now()) |
| 38 | if bad != "" { |
| 39 | jsonError(w, http.StatusBadRequest, bad) |
| 40 | return |
| 41 | } |
| 42 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 43 | defer cancel() |
| 44 | win, err := s.readErrors(ctx, acct.Sqid, app, f, level) |
| 45 | if err != nil { |
| 46 | log.Printf("console: api errors %s/%s: %v", acct.Sqid, app, err) |
| 47 | jsonError(w, http.StatusBadGateway, "could not read the errors") |
| 48 | return |
| 49 | } |
| 50 | resp := errorsResponse{Groups: win.Groups, Records: win.Records, Truncated: win.Truncated, LogLevel: win.LogLevel, Sources: win.Sources} |
| 51 | if resp.Groups == nil { |
| 52 | resp.Groups = []apperr.Group{} // "no errors" is [], not null |
| 53 | } |
| 54 | if resp.Sources == nil { |
| 55 | resp.Sources = []string{} |
| 56 | } |
| 57 | writeJSON(w, http.StatusOK, resp) |
| 58 | } |
| 59 | |
| 60 | // availabilityResponse is the wire shape `carlos availability` reads: the |
| 61 | // errors shape without the log level, which Availability does not use. |
| 62 | type availabilityResponse struct { |
| 63 | Groups []apperr.Group `json:"groups"` |
| 64 | Records []apperr.Record `json:"records,omitempty"` |
| 65 | Truncated bool `json:"truncated"` |
| 66 | Sources []string `json:"sources"` |
| 67 | } |
| 68 | |
| 69 | // handleAPIAvailability answers `carlos availability`: the errors API's |
| 70 | // gate and filter, the other half of the records (errView). source and |
| 71 | // level parse as they do for errors but do not narrow this read. |
| 72 | func (s *Server) handleAPIAvailability(w http.ResponseWriter, r *http.Request) { |
| 73 | _, acct, app, ok := s.instanceGate(w, r) |
| 74 | if !ok { |
| 75 | return |
| 76 | } |
| 77 | f, _, bad := errorsFilterFromQuery(r.URL.Query(), time.Now()) |
| 78 | if bad != "" { |
| 79 | jsonError(w, http.StatusBadRequest, bad) |
| 80 | return |
| 81 | } |
| 82 | f.Source = "" |
| 83 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 84 | defer cancel() |
| 85 | win, err := s.readAvailability(ctx, acct.Sqid, app, f) |
| 86 | if err != nil { |
| 87 | log.Printf("console: api availability %s/%s: %v", acct.Sqid, app, err) |
| 88 | jsonError(w, http.StatusBadGateway, "could not read availability") |
| 89 | return |
| 90 | } |
| 91 | resp := availabilityResponse{Groups: win.Groups, Records: win.Records, Truncated: win.Truncated, Sources: win.Sources} |
| 92 | if resp.Groups == nil { |
| 93 | resp.Groups = []apperr.Group{} |
| 94 | } |
| 95 | if resp.Sources == nil { |
| 96 | resp.Sources = []string{} |
| 97 | } |
| 98 | writeJSON(w, http.StatusOK, resp) |
| 99 | } |
| 100 | |
| 101 | // handleAPITelemetryPut sets the app-level telemetry object: the errors log |
| 102 | // level, the analytics keys, or both in one call. Operate only — an app's |
| 103 | // own token must not widen what its owner sees. |
| 104 | func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) { |
| 105 | principal, acct, app, ok := s.instanceGate(w, r) |
| 106 | if !ok { |
| 107 | return |
| 108 | } |
| 109 | // The browser form for this setting is owner-only, so the person path |
| 110 | // here is too. A SERVICE holds no account role (AccountByID does not |
| 111 | // populate Role), which is why this reads the kind first. |
| 112 | p, _ := principalFrom(r.Context()) |
| 113 | if p.Kind == PrincipalUser && acct.Role != RoleOwner { |
| 114 | jsonError(w, http.StatusForbidden, "only an account owner can change an app's telemetry settings") |
| 115 | return |
| 116 | } |
| 117 | var body telemetry.Settings |
| 118 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { |
| 119 | jsonError(w, http.StatusBadRequest, "body: want {\"errors\":{\"log_level\":\"error|warn\"}} or {\"analytics\":{\"count\":\"edge|off\",\"door\":\"on|off\"}}") |
| 120 | return |
| 121 | } |
| 122 | // Either half may be absent — a caller setting the log level must not |
| 123 | // have to restate the analytics keys, and vice versa — but a body that |
| 124 | // sets nothing is a mistake worth naming rather than a silent no-op. |
| 125 | if body.Errors == nil && body.Analytics == nil { |
| 126 | jsonError(w, http.StatusBadRequest, "nothing to set: want errors, analytics or both") |
| 127 | return |
| 128 | } |
| 129 | if body.Errors != nil && !telemetry.ValidLogLevel(body.Errors.LogLevel) { |
| 130 | jsonError(w, http.StatusBadRequest, "errors.log_level: want error or warn") |
| 131 | return |
| 132 | } |
| 133 | if a := body.Analytics; a != nil { |
| 134 | if a.Count == "" && a.Door == "" { |
| 135 | jsonError(w, http.StatusBadRequest, "analytics: want count, door or both") |
| 136 | return |
| 137 | } |
| 138 | // An empty key means "leave this one alone", so only a value that is |
| 139 | // present is checked — and "browser", the mode step 3 reserves, is |
| 140 | // not one of them yet: a setting no box acts on must not be storable. |
| 141 | if a.Count != "" && !validCount(a.Count) { |
| 142 | jsonError(w, http.StatusBadRequest, "analytics.count: want edge or off") |
| 143 | return |
| 144 | } |
| 145 | if a.Door != "" && !validDoor(a.Door) { |
| 146 | jsonError(w, http.StatusBadRequest, "analytics.door: want on or off") |
| 147 | return |
| 148 | } |
| 149 | } |
| 150 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 151 | defer cancel() |
| 152 | if err := s.updateAppTelemetry(ctx, acct, app, "telemetry", func(st *telemetry.Settings) { |
| 153 | if body.Errors != nil { |
| 154 | st.Errors = &telemetry.Errors{LogLevel: body.Errors.LogLevel} |
| 155 | } |
| 156 | if body.Analytics != nil { |
| 157 | mergeAnalytics(st, body.Analytics.Count, body.Analytics.Door) |
| 158 | } |
| 159 | }); err != nil { |
| 160 | log.Printf("console: api telemetry %s/%s: %v", acct.Sqid, app, err) |
| 161 | jsonError(w, http.StatusBadGateway, "could not write the setting") |
| 162 | return |
| 163 | } |
| 164 | // The audit row names what THIS call changed: a PUT that moved the log |
| 165 | // level and a PUT that turned counting on must not read alike in the |
| 166 | // activity feed, and a half nobody sent must not be claimed as set. |
| 167 | resp, action, changed := map[string]string{}, "", []string(nil) |
| 168 | if body.Errors != nil { |
| 169 | action = "errors-level" |
| 170 | resp["log_level"] = body.Errors.LogLevel |
| 171 | changed = append(changed, "the Errors log level to "+body.Errors.LogLevel) |
| 172 | } |
| 173 | if body.Analytics != nil { |
| 174 | if action == "" { |
| 175 | action = "analytics-setting" |
| 176 | } else { |
| 177 | action = "telemetry" // both halves in one call |
| 178 | } |
| 179 | if c := body.Analytics.Count; c != "" { |
| 180 | resp["count"] = c |
| 181 | changed = append(changed, "analytics counting to "+c) |
| 182 | } |
| 183 | if d := body.Analytics.Door; d != "" { |
| 184 | resp["door"] = d |
| 185 | changed = append(changed, "the analytics browser door to "+d) |
| 186 | } |
| 187 | } |
| 188 | s.audit(acct, app, principal, action, "set "+strings.Join(changed, " and ")) |
| 189 | if body.Analytics != nil { |
| 190 | // The analytics switch is BOX-consumed: the edge counts or does not, |
| 191 | // so every box has to hear about the change rather than wait for the |
| 192 | // hourly floor. The errors level is console-read and needs no bump. |
| 193 | s.bumpSerial(ctx) |
| 194 | } |
| 195 | writeJSON(w, http.StatusOK, resp) |
| 196 | } |
| 197 | |