carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
7package console
8
9import (
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.
22type 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.
32func (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.
62type 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.
72func (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.
104func (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