// The CLI's errors API — the read side of `carlos errors` and the write // side of the telemetry settings object (spec 2026-09-07 §3.6). It shares // the instance family's gate (instanceGate) exactly as the logs API does: // same principal proof, same byte-identical 404, same rate limiter — and an // app token may read its own app's errors, which is the trust the logs door // already extends it. package console import ( "context" "encoding/json" "log" "net/http" "strings" "time" "github.com/carlosframework/platform/internal/apperr" "github.com/carlosframework/platform/internal/telemetry" ) // errorsResponse is the wire shape `carlos errors` and the tab read. type errorsResponse struct { Groups []apperr.Group `json:"groups"` Records []apperr.Record `json:"records,omitempty"` Truncated bool `json:"truncated"` LogLevel string `json:"log_level"` Sources []string `json:"sources"` } // handleAPIErrors answers `carlos errors`: the instance family's gate // (an app token may read its own errors), the shared filter, one read. func (s *Server) handleAPIErrors(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.instanceGate(w, r) if !ok { return } f, level, bad := errorsFilterFromQuery(r.URL.Query(), time.Now()) if bad != "" { jsonError(w, http.StatusBadRequest, bad) return } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() win, err := s.readErrors(ctx, acct.Sqid, app, f, level) if err != nil { log.Printf("console: api errors %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the errors") return } resp := errorsResponse{Groups: win.Groups, Records: win.Records, Truncated: win.Truncated, LogLevel: win.LogLevel, Sources: win.Sources} if resp.Groups == nil { resp.Groups = []apperr.Group{} // "no errors" is [], not null } if resp.Sources == nil { resp.Sources = []string{} } writeJSON(w, http.StatusOK, resp) } // availabilityResponse is the wire shape `carlos availability` reads: the // errors shape without the log level, which Availability does not use. type availabilityResponse struct { Groups []apperr.Group `json:"groups"` Records []apperr.Record `json:"records,omitempty"` Truncated bool `json:"truncated"` Sources []string `json:"sources"` } // handleAPIAvailability answers `carlos availability`: the errors API's // gate and filter, the other half of the records (errView). source and // level parse as they do for errors but do not narrow this read. func (s *Server) handleAPIAvailability(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.instanceGate(w, r) if !ok { return } f, _, bad := errorsFilterFromQuery(r.URL.Query(), time.Now()) if bad != "" { jsonError(w, http.StatusBadRequest, bad) return } f.Source = "" ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() win, err := s.readAvailability(ctx, acct.Sqid, app, f) if err != nil { log.Printf("console: api availability %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read availability") return } resp := availabilityResponse{Groups: win.Groups, Records: win.Records, Truncated: win.Truncated, Sources: win.Sources} if resp.Groups == nil { resp.Groups = []apperr.Group{} } if resp.Sources == nil { resp.Sources = []string{} } writeJSON(w, http.StatusOK, resp) } // handleAPITelemetryPut sets the app-level telemetry object: the errors log // level, the analytics keys, or both in one call. Operate only — an app's // own token must not widen what its owner sees. func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) { principal, acct, app, ok := s.instanceGate(w, r) if !ok { return } // The browser form for this setting is owner-only, so the person path // here is too. A SERVICE holds no account role (AccountByID does not // populate Role), which is why this reads the kind first. p, _ := principalFrom(r.Context()) if p.Kind == PrincipalUser && acct.Role != RoleOwner { jsonError(w, http.StatusForbidden, "only an account owner can change an app's telemetry settings") return } var body telemetry.Settings if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, "body: want {\"errors\":{\"log_level\":\"error|warn\"}} or {\"analytics\":{\"count\":\"edge|off\",\"door\":\"on|off\"}}") return } // Either half may be absent — a caller setting the log level must not // have to restate the analytics keys, and vice versa — but a body that // sets nothing is a mistake worth naming rather than a silent no-op. if body.Errors == nil && body.Analytics == nil { jsonError(w, http.StatusBadRequest, "nothing to set: want errors, analytics or both") return } if body.Errors != nil && !telemetry.ValidLogLevel(body.Errors.LogLevel) { jsonError(w, http.StatusBadRequest, "errors.log_level: want error or warn") return } if a := body.Analytics; a != nil { if a.Count == "" && a.Door == "" { jsonError(w, http.StatusBadRequest, "analytics: want count, door or both") return } // An empty key means "leave this one alone", so only a value that is // present is checked — and "browser", the mode step 3 reserves, is // not one of them yet: a setting no box acts on must not be storable. if a.Count != "" && !validCount(a.Count) { jsonError(w, http.StatusBadRequest, "analytics.count: want edge or off") return } if a.Door != "" && !validDoor(a.Door) { jsonError(w, http.StatusBadRequest, "analytics.door: want on or off") return } } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() if err := s.updateAppTelemetry(ctx, acct, app, "telemetry", func(st *telemetry.Settings) { if body.Errors != nil { st.Errors = &telemetry.Errors{LogLevel: body.Errors.LogLevel} } if body.Analytics != nil { mergeAnalytics(st, body.Analytics.Count, body.Analytics.Door) } }); err != nil { log.Printf("console: api telemetry %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not write the setting") return } // The audit row names what THIS call changed: a PUT that moved the log // level and a PUT that turned counting on must not read alike in the // activity feed, and a half nobody sent must not be claimed as set. resp, action, changed := map[string]string{}, "", []string(nil) if body.Errors != nil { action = "errors-level" resp["log_level"] = body.Errors.LogLevel changed = append(changed, "the Errors log level to "+body.Errors.LogLevel) } if body.Analytics != nil { if action == "" { action = "analytics-setting" } else { action = "telemetry" // both halves in one call } if c := body.Analytics.Count; c != "" { resp["count"] = c changed = append(changed, "analytics counting to "+c) } if d := body.Analytics.Door; d != "" { resp["door"] = d changed = append(changed, "the analytics browser door to "+d) } } s.audit(acct, app, principal, action, "set "+strings.Join(changed, " and ")) if body.Analytics != nil { // The analytics switch is BOX-consumed: the edge counts or does not, // so every box has to hear about the change rather than wait for the // hourly floor. The errors level is console-read and needs no bump. s.bumpSerial(ctx) } writeJSON(w, http.StatusOK, resp) }