console: the analytics switch at both levels; telemetry PUT accepts analytics and bumps the serial
The switch is the feature's consent, so it is the owner's alone at both levels and off until someone turns it on. Two forms (analyticsview.go) ride the Errors log level's gate exactly -- session, constant-time CSRF, then the bare 404 a non-owner gets -- and both write through telemetry.Update, merging only the keys the form carried so a door set from the CLI survives an owner flipping counting on. Every analytics write bumps the serial, where the errors level does not: the edge acts on whether it counts, so boxes must converge within the tick rather than wait for the hourly floor. The CLI's telemetry PUT now takes either half or both in one call, validates count (edge|off) and door (on|off) -- "browser" stays refused until something honours it -- and names in the audit row only what that call actually changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+383
−14
internal/console/analyticsview.go+176 −0internal/console/apianalytics_test.go+70 −0internal/console/apierrors.go+68 −12internal/console/server.go+21 −2internal/console/templates/account_settings.html+23 −0internal/console/templates/app_settings.html+25 −0
diff --git a/internal/console/analyticsview.go b/internal/console/analyticsview.go| new file mode 100644 |
| index 000000000..9c05b9054 |
| --- /dev/null |
| +++ b/internal/console/analyticsview.go |
| @@ -0,0 +1,176 @@ |
| +package console |
| + |
| +// The Analytics switch (spec 2026-09-07 §3.3, §3.6): the two settings |
| +// forms that turn edge counting on, at app and account level. The switch |
| +// is the whole feature's consent — counting is off until an owner turns it |
| +// on — so these two doors are the owner's alone, and every write moves the |
| +// serial: a box acts on this setting, unlike the Errors log level, which |
| +// only the console reads. |
| + |
| +import ( |
| + "crypto/subtle" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + |
| + "github.com/carlosframework/platform/internal/telemetry" |
| +) |
| + |
| +// validCount and validDoor name the two keys' vocabularies, the way |
| +// telemetry.ValidLogLevel names the errors half's. "browser" is a mode the |
| +// spec reserves and no box acts on yet, so it is NOT valid here: a setting |
| +// nothing honours must not be storable. |
| +func validCount(s string) bool { return s == telemetry.CountEdge || s == telemetry.CountOff } |
| +func validDoor(s string) bool { return s == telemetry.DoorOn || s == telemetry.DoorOff } |
| + |
| +// mergeAnalytics folds the keys a caller actually sent into st, keeping the |
| +// one they did not: an owner flipping counting on must not silently reset a |
| +// door someone set from the CLI. An empty string means "leave alone", which |
| +// is also how the stored shape spells "not set here" (telemetry.Analytics). |
| +func mergeAnalytics(st *telemetry.Settings, count, door string) { |
| + if st.Analytics == nil { |
| + st.Analytics = &telemetry.Analytics{} |
| + } |
| + if count != "" { |
| + st.Analytics.Count = count |
| + } |
| + if door != "" { |
| + st.Analytics.Door = door |
| + } |
| +} |
| + |
| +// analyticsFormValues reads the two form fields both switches share. An |
| +// empty field leaves that key unchanged; a form that names neither changed |
| +// nothing, and says so rather than writing an empty object and bumping the |
| +// serial over it. |
| +func analyticsFormValues(r *http.Request) (count, door, bad string) { |
| + count, door = r.FormValue("count"), r.FormValue("door") |
| + if count == "" && door == "" { |
| + return "", "", "Choose what to change." |
| + } |
| + if count != "" && !validCount(count) { |
| + return "", "", "Choose edge or off for counting." |
| + } |
| + if door != "" && !validDoor(door) { |
| + return "", "", "Choose on or off for the browser door." |
| + } |
| + return count, door, "" |
| +} |
| + |
| +// analyticsFlash is what the owner is told after a save: what is now true, |
| +// named, rather than "saved". |
| +func analyticsFlash(count, door, scope string) string { |
| + switch { |
| + case count != "" && door != "": |
| + return "Analytics counting is now " + count + " and the browser door " + door + " " + scope + "." |
| + case door != "": |
| + return "The analytics browser door is now " + door + " " + scope + "." |
| + default: |
| + return "Analytics counting is now " + count + " " + scope + "." |
| + } |
| +} |
| + |
| +// handleAppAnalyticsSetting is the app Settings tab's form: owner, CSRF, |
| +// the two analytics keys. It mirrors handleAppErrorLevel gate for gate — |
| +// the session first, then the constant-time CSRF compare, then the bare |
| +// 404 a non-owner gets, so nobody learns from here what they could not |
| +// already see. |
| +func (s *Server) handleAppAnalyticsSetting(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + count, door, bad := analyticsFormValues(r) |
| + back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings" |
| + // The setting lives in the deployment bucket, so a console without one |
| + // has nowhere to put it — refused before the write rather than panicking |
| + // inside it, exactly as the errors level is. |
| + if s.cfg.Releases == nil { |
| + s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.") |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + } |
| + if bad != "" { |
| + s.setFlash(w, "err", bad) |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + } |
| + if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), func(st *telemetry.Settings) { |
| + mergeAnalytics(st, count, door) |
| + }); err != nil { |
| + log.Printf("console: analytics setting %s/%s: %v", acct.Sqid, app, err) |
| + s.setFlash(w, "err", "The setting could not be saved — try again in a moment.") |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + } |
| + s.audit(acct, app, addr, "analytics-setting", "set "+analyticsAudit(count, door)+" for this app") |
| + // Box-consumed: the edge starts or stops counting on the next pass, so |
| + // the serial moves rather than leaving it to the hourly floor. |
| + s.bumpSerial(r.Context()) |
| + s.setFlash(w, "ok", analyticsFlash(count, door, "for this app")) |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| +} |
| + |
| +// handleAccountAnalyticsSetting is the account settings form: the default |
| +// every app without its own setting inherits. |
| +func (s *Server) handleAccountAnalyticsSetting(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + count, door, bad := analyticsFormValues(r) |
| + back := "/a/" + url.PathEscape(acct.Sqid) + "/settings" |
| + if s.cfg.Releases == nil { |
| + s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.") |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + } |
| + if bad != "" { |
| + s.setFlash(w, "err", bad) |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + } |
| + if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AccountKey(acct.Sqid), func(st *telemetry.Settings) { |
| + mergeAnalytics(st, count, door) |
| + }); err != nil { |
| + log.Printf("console: account analytics setting %s: %v", acct.Sqid, err) |
| + s.setFlash(w, "err", "The setting could not be saved — try again in a moment.") |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| + return |
| + } |
| + // Filed under the account subject rather than an empty app column, for |
| + // the reason handleAccountErrorLevel spells out. |
| + s.audit(acct, accountAuditSubject, addr, "analytics-setting", "set the account's "+analyticsAudit(count, door)) |
| + s.bumpSerial(r.Context()) |
| + s.setFlash(w, "ok", analyticsFlash(count, door, "for every app without its own setting")) |
| + http.Redirect(w, r, back, http.StatusSeeOther) |
| +} |
| + |
| +// analyticsAudit names the change for the activity feed: only the keys the |
| +// form actually carried. |
| +func analyticsAudit(count, door string) string { |
| + switch { |
| + case count != "" && door != "": |
| + return "analytics counting to " + count + " and the browser door to " + door |
| + case door != "": |
| + return "the analytics browser door to " + door |
| + default: |
| + return "analytics counting to " + count |
| + } |
| +} |
diff --git a/internal/console/apianalytics_test.go b/internal/console/apianalytics_test.go| new file mode 100644 |
| index 000000000..2da623684 |
| --- /dev/null |
| +++ b/internal/console/apianalytics_test.go |
| @@ -0,0 +1,70 @@ |
| +package console |
| + |
| +// The analytics switch, at both ends: the CLI's telemetry PUT (which now |
| +// accepts the analytics half and bumps the serial, because a box acts on |
| +// it) and the two settings pages' owner-only forms. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| + "github.com/carlosframework/platform/internal/telemetry" |
| +) |
| + |
| +func TestAPITelemetryPutSetsAnalytics(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-analytics-set") |
| + op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) |
| + put := func(body string) (int, string) { |
| + st, _, b := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, body) |
| + return st, b |
| + } |
| + if st, b := put(`{"analytics":{"count":"edge"}}`); st != 200 { |
| + t.Fatalf("count=edge: %d %s", st, b) |
| + } |
| + r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo") |
| + if r.Count != "edge" || r.CountFrom != "app" || r.Door != "on" { |
| + t.Fatalf("resolved = %+v", r) |
| + } |
| + if st, _ := put(`{"analytics":{"count":"browser"}}`); st != 400 { |
| + t.Fatal("reserved mode must be refused for now") |
| + } |
| + if st, _ := put(`{"analytics":{"door":"sideways"}}`); st != 400 { |
| + t.Fatal("bad door") |
| + } |
| + if st, _ := put(`{"analytics":{"door":"off"},"errors":{"log_level":"warn"}}`); st != 200 { |
| + t.Fatal("both halves in one PUT") |
| + } |
| + r, _ = telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo") |
| + if r.Door != "off" || r.LogLevel != "warn" || r.Count != "edge" { |
| + t.Fatalf("after both: %+v", r) |
| + } |
| + // The serial moved: boxes converge on it. |
| + if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 { |
| + t.Fatalf("serial not bumped: %+v %v", doc, err) |
| + } |
| +} |
| + |
| +func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-analytics-page") |
| + fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-analytics-signin") |
| + status, _, body := get(t, c, fx.u("/apps/titogo/settings")) |
| + if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") { |
| + t.Fatalf("app settings: %d", status) |
| + } |
| + // Post the account-level switch on; the app page shows it inherited. |
| + status, _, _ = postForm(t, c, fx.u("/analytics/setting"), url.Values{"csrf": {sessionToken(t, fx, c)}, "count": {"edge"}, "door": {"on"}}) |
| + if status != 303 { |
| + t.Fatalf("account switch: %d", status) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| + if !strings.Contains(body, "currently edge, from the account") { |
| + t.Fatal("inheritance not shown") |
| + } |
| +} |
diff --git a/internal/console/apierrors.go b/internal/console/apierrors.go| index 66332fa38..150159381 100644 |
| --- a/internal/console/apierrors.go |
| +++ b/internal/console/apierrors.go |
| @@ -11,6 +11,7 @@ import ( |
| "encoding/json" |
| "log" |
| "net/http" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/apperr" |
| @@ -56,9 +57,9 @@ func (s *Server) handleAPIErrors(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, resp) |
| } |
| -// handleAPITelemetryPut sets the app-level telemetry object: today the |
| -// errors log level; the analytics keys arrive with step 2. Operate only — |
| -// an app's own token must not widen what its owner sees. |
| +// 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 { |
| @@ -69,31 +70,86 @@ func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) { |
| // 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 the Errors log level") |
| + 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\"}}") |
| + jsonError(w, http.StatusBadRequest, "body: want {\"errors\":{\"log_level\":\"error|warn\"}} or {\"analytics\":{\"count\":\"edge|off\",\"door\":\"on|off\"}}") |
| return |
| } |
| - if body.Errors == nil || !telemetry.ValidLogLevel(body.Errors.LogLevel) { |
| - jsonError(w, http.StatusBadRequest, "errors.log_level: want error or warn") |
| + // 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.Analytics != nil { |
| - jsonError(w, http.StatusBadRequest, "analytics: not settable yet") |
| + 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 := telemetry.Update(ctx, s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), func(st *telemetry.Settings) { |
| - st.Errors = &telemetry.Errors{LogLevel: body.Errors.LogLevel} |
| + 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 |
| } |
| - s.audit(acct, app, principal, "errors-level", "set the Errors log level to "+body.Errors.LogLevel) |
| - writeJSON(w, http.StatusOK, map[string]string{"log_level": body.Errors.LogLevel}) |
| + // 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) |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index e30629be7..0da617e31 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1039,6 +1039,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam) |
| s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings) |
| s.mux.HandleFunc("POST /a/{sqid}/errors/level", s.handleAccountErrorLevel) |
| + s.mux.HandleFunc("POST /a/{sqid}/analytics/setting", s.handleAccountAnalyticsSetting) |
| // Go 1.22's mux prefers this literal over apps/{app} below — exactly why |
| // "new" must be a reserved app name (claimApp): an app so named could |
| // never be viewed. |
| @@ -1054,6 +1055,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // (CSRF, then owner, then the byte-identical 404). |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/errors", s.handleAppErrors) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/errors/level", s.handleAppErrorLevel) |
| + // The analytics switch (analyticsview.go) rides the same gate at both |
| + // levels, and bumps the serial where the errors level does not: a box |
| + // acts on whether the edge counts. |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/analytics/setting", s.handleAppAnalyticsSetting) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings) |
| // The pipeline settings page (pipelinepage.go, release pipelines v2 §5): |
| @@ -1404,6 +1409,14 @@ type page struct { |
| ErrLevelSetting telemetry.Resolved |
| ErrLevelUnread bool |
| + // AnalyticsSetting is the same resolved object, under the name the |
| + // Analytics section of those two pages reads it by — telemetry.Resolve |
| + // returns both halves at once, so the pages fill these from the ONE call |
| + // they already make. AnalyticsUnread carries the same refusal to show |
| + // defaults as if they were the setting. |
| + AnalyticsSetting telemetry.Resolved |
| + AnalyticsUnread bool |
| + |
| // Action confirm interstitial (confirm.go): what is about to happen, |
| // carried fields and all. Nil everywhere except the confirm page. |
| Confirm *ConfirmView |
| @@ -2397,11 +2410,14 @@ func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) { |
| // is said rather than defaulted (see ErrLevelUnread), and never fails |
| // the page. |
| if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, ""); err != nil { |
| - log.Printf("console: account errors level %s: %v", acct.Sqid, err) |
| + log.Printf("console: account telemetry %s: %v", acct.Sqid, err) |
| p.ErrLevelUnread = true |
| } else { |
| p.ErrLevelSetting = res |
| } |
| + // The analytics switch is the OTHER half of that same resolved object — |
| + // one settings file, one read — under the name its own section uses. |
| + p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread |
| s.render(w, http.StatusOK, "account_settings", p) |
| } |
| @@ -2800,11 +2816,14 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) { |
| // rather than defaulted (see ErrLevelUnread), and never fails a page |
| // that is mostly about something else. |
| if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil { |
| - log.Printf("console: errors level %s/%s: %v", acct.Sqid, p.AppName, err) |
| + log.Printf("console: telemetry %s/%s: %v", acct.Sqid, p.AppName, err) |
| p.ErrLevelUnread = true |
| } else { |
| p.ErrLevelSetting = res |
| } |
| + // The analytics switch is the OTHER half of that same resolved object — |
| + // one settings file, one read — under the name its own section uses. |
| + p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread |
| s.render(w, http.StatusOK, "app_settings", p) |
| } |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| index 55237a630..e0f10a9c6 100644 |
| --- a/internal/console/templates/account_settings.html |
| +++ b/internal/console/templates/account_settings.html |
| @@ -153,6 +153,29 @@ overrides. Widening one look to warn is always available on an app’s Error |
| nothing here.</p> |
| {{end}} |
| +<h2>Analytics</h2> |
| +{{if .AnalyticsUnread}} |
| +<p class="small">This account’s analytics setting could not be read just now. Reload before |
| +concluding anything.</p> |
| +{{else}} |
| +<form class="signin" method="post" action="{{.Base}}/analytics/setting"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label>Count page views at the edge: |
| + <select name="count"> |
| + <option value="off"{{if eq .AnalyticsSetting.Count "off"}} selected{{end}}>off</option> |
| + <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option> |
| + </select> |
| + </label> |
| + <button type="submit">Save</button> |
| +</form> |
| +<p class="small">Currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}} |
| +— for every app without its own setting, which an app’s own Settings tab overrides.</p> |
| +{{end}} |
| +<p class="small">When on, the edge counts HTML page views and daily visitors as it serves them: no |
| +JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes every day and cannot be |
| +joined across days. Whether that needs consent where your visitors are is your call, which is why it |
| +is off until you turn it on.</p> |
| + |
| <h2>Delete this account</h2> |
| {{if .HasApps}} |
| <p class="small">This account has apps, so it can't be deleted. Delete or move the apps first; |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index ce8245a86..f14df31e8 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -189,6 +189,31 @@ change it.</p> |
| <p class="small">The <a href="{{.Base}}/apps/{{.Detail.App}}/errors">Errors tab</a> always lets you |
| widen one look to warn without changing this.</p> |
| +<h2>Analytics</h2> |
| +{{if .AnalyticsUnread}} |
| +<p class="small">This app’s analytics setting could not be read just now. Reload before |
| +concluding anything.</p> |
| +{{else if .IsOwner}} |
| +<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/analytics/setting" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label>Count page views at the edge: |
| + <select name="count"> |
| + <option value="off"{{if eq .AnalyticsSetting.Count "off"}} selected{{end}}>off</option> |
| + <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option> |
| + </select> |
| + </label> |
| + <button type="submit" class="btn-sm">Save</button> |
| + <span class="small">currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}</span> |
| +</form> |
| +{{else}} |
| +<p class="small">Analytics counting is <code>{{.AnalyticsSetting.Count}}</code> for this app, from |
| +the {{.AnalyticsSetting.CountFrom}}. Only the account owner can change it.</p> |
| +{{end}} |
| +<p class="small">When on, the edge counts HTML page views and daily visitors as it serves them: no |
| +JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes every day and cannot be |
| +joined across days. Whether that needs consent where your visitors are is your call, which is why it |
| +is off until you turn it on.</p> |
| + |
| {{if .Settings}}{{with .Settings.Features}} |
| <h2>Feature flags</h2> |
| {{if .Flags}} |