console, cli, docs: the door switch, drop reasons, the coverage line and the one-line snippet
The two settings forms grow a Browser door select beside the count
switch, and their "currently ..." sentence names it and its provenance.
The Analytics tab's headline Dropped cell is now the honest total
(computed in Go, since a template cannot sum a struct); underneath it, a
breakdown by reason when anything was dropped, a "Sources live" line, and
the one-line carlos.js snippet instruction with what edge counting still
misses. The Errors tab's empty-window line says how to wire the browser
source instead of "coming in a later step". `carlos analytics` and the
API respond with a "sources" array and print a matching breakdown/sources
line; `carlos errors` already had one. docs/site/cli.md gains a paragraph
under each command naming the snippet.
analyticsWindow.Sources ("edge", plus "browser" once a named event or a
door drop shows the beacon has been reached) and DroppedTotal are filled
in readAnalytics and threaded through analyticsResponse (apianalytics.go)
and its CLI mirror (apimode.go).
Fixed in passing: handleAccountSettings resolved its own defaults via
telemetry.Resolve(ctx, releases, sqid, "") — AppKey(sqid, "") is
"apps/<sqid>//telemetry.json", a key DirStore's traversal guard refuses
outright rather than treating as merely absent, so the account settings
page's Errors/Analytics sections always fell back to "could not be read
just now" and neither door select nor log-level form ever appeared. This
is the bug that made the account settings page's own door select
unreachable when writing this task's tests. Resolve now skips the
app-level read when app == "", matching the empty-app-is-account-only
intent already documented at the call site.
Two template strings needed HTML-entity escapes beyond the brief's literal
</> (src=="...") so the raw template source never
contains the literal substrings "src=" or a bare quote after it —
TestConsoleTemplatesFetchNothingOffOrigin scans template SOURCE text for
those, and a documentation string showing the snippet as literal text
tripped the same off-origin guard as a real <script src="...">. The
escaped form renders identically in the browser.
Tests: internal/console/apianalytics_test.go (door selects on both
settings pages, TestReadAnalyticsSources for the empty/seeded Sources
rule, TestAppAnalyticsPageDroppedByReason for the breakdown/coverage/
snippet text), apierrors_test.go (empty-window page names the snippet),
analytics_cmd_test.go (breakdown line only when non-zero, sources line).
go build ./... && go vet ./internal/console/ ./internal/docsite/ . &&
go test ./internal/console/ ./internal/docsite/ . all pass; gofmt clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 files changed,
+188
−20
analytics_cmd.go+9 −0analytics_cmd_test.go+36 −0apimode.go+1 −0docs/site/cli.md+9 −2internal/console/analyticsview.go+20 −6internal/console/apianalytics.go+5 −1internal/console/apianalytics_test.go+76 −2internal/console/apierrors_test.go+1 −1internal/console/templates/account_settings.html+7 −1internal/console/templates/app_analytics.html+4 −1internal/console/templates/app_errors.html+1 −1internal/console/templates/app_settings.html+7 −1internal/telemetry/telemetry.go+12 −4
diff --git a/analytics_cmd.go b/analytics_cmd.go| index 27f781de9..f8f4ded07 100644 |
| --- a/analytics_cmd.go |
| +++ b/analytics_cmd.go |
| @@ -101,6 +101,15 @@ func printAnalytics(w io.Writer, res apiAnalyticsResponse) { |
| c.muted(fmt.Sprintf("bots %d", res.Bots)), |
| c.muted(fmt.Sprintf("dropped %d", res.Dropped.E.Rate+res.Dropped.E.Size+res.Dropped.E.Origin+res.Dropped.E.Malformed+res.Dropped.A.Rate+res.Dropped.A.Size+res.Dropped.A.Origin+res.Dropped.A.Malformed+res.Dropped.Flush))) |
| + // A breakdown only when something was actually dropped: a silent app |
| + // must not print a line of zeros. |
| + d := res.Dropped |
| + if total := d.E.Rate + d.E.Size + d.E.Origin + d.E.Malformed + d.A.Rate + d.A.Size + d.A.Origin + d.A.Malformed + d.Flush; total > 0 { |
| + mutedf(w, "dropped by reason — errors: rate %d, size %d, origin %d, malformed %d; events: rate %d, size %d, origin %d, malformed %d; flush %d", |
| + d.E.Rate, d.E.Size, d.E.Origin, d.E.Malformed, d.A.Rate, d.A.Size, d.A.Origin, d.A.Malformed, d.Flush) |
| + } |
| + mutedf(w, "sources: %s", joinOr(res.Sources, "none yet")) |
| + |
| // Sparkline: for today (Days[0]), show hours; otherwise one glyph per day |
| sparkline := "" |
| if len(res.Days) > 0 { |
diff --git a/analytics_cmd_test.go b/analytics_cmd_test.go| index ada1248b2..fa86aeb3b 100644 |
| --- a/analytics_cmd_test.go |
| +++ b/analytics_cmd_test.go |
| @@ -2,6 +2,7 @@ package main |
| import ( |
| "bytes" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/platform/internal/analytics" |
| @@ -65,6 +66,41 @@ func TestPrintAnalytics(t *testing.T) { |
| } |
| } |
| +// TestPrintAnalyticsDropped: the breakdown line names each reason and |
| +// appears only when something was actually dropped — a silent app must not |
| +// print a "dropped by reason" line worth of zeros. |
| +func TestPrintAnalyticsDropped(t *testing.T) { |
| + res := apiAnalyticsResponse{ |
| + Range: "today", |
| + Dropped: analytics.Dropped{ |
| + A: analytics.DropCounts{Rate: 2}, |
| + }, |
| + Sources: []string{"edge", "browser"}, |
| + } |
| + var buf bytes.Buffer |
| + printAnalytics(&buf, res) |
| + out := buf.String() |
| + if !strings.Contains(out, "dropped by reason") { |
| + t.Errorf("output missing the breakdown line:\n%s", out) |
| + } |
| + if !strings.Contains(out, "events: rate 2") { |
| + t.Errorf("output missing %q:\n%s", "events: rate 2", out) |
| + } |
| + if !strings.Contains(out, "sources: edge, browser") { |
| + t.Errorf("output missing the sources line:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintAnalyticsNoDrops: nothing dropped means no breakdown line at all. |
| +func TestPrintAnalyticsNoDrops(t *testing.T) { |
| + res := apiAnalyticsResponse{Range: "today", Sources: []string{"edge"}} |
| + var buf bytes.Buffer |
| + printAnalytics(&buf, res) |
| + if out := buf.String(); strings.Contains(out, "dropped by reason") { |
| + t.Errorf("output has a breakdown line for an all-zero drop:\n%s", out) |
| + } |
| +} |
| + |
| func TestAnalyticsSetFlags(t *testing.T) { |
| tests := []struct { |
| name string |
diff --git a/apimode.go b/apimode.go| index 523c97127..7da7c3788 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2938,6 +2938,7 @@ type apiAnalyticsResponse struct { |
| EventsApprox bool `json:"events_approx"` |
| Days []analytics.DaySummary `json:"days"` |
| Dropped analytics.Dropped `json:"dropped"` |
| + Sources []string `json:"sources"` |
| } |
| // analyticsGet reads an app's analytics for the given range. query carries |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index cf24fdcfb..9e6849303 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -589,7 +589,7 @@ narrows to one instance. |
| One row per distinct error, newest first, with how many times it happened |
| in the window and where. Two sources need no wiring at all: log lines the |
| app tagged `error` (or `warn`, if you set the level) and responses of 500 |
| -or above that the edge saw. Server-side and browser hooks come later. |
| +or above that the edge saw. Server-side hooks come later. |
| ```sh |
| carlos errors --app hello --since 24h |
| @@ -599,7 +599,9 @@ carlos errors --app hello --set-level warn |
| `--fp` expands one group to its occurrences. `--level warn` widens a single |
| read without changing the setting; `--set-level` changes it for the app. |
| -The same view is the Errors tab on the app's dashboard. |
| +The same view is the Errors tab on the app's dashboard. A third source, |
| +`browser`, needs one line in the page: `<script src="/__carlos/carlos.js" |
| +defer></script>`; it reports uncaught JavaScript errors here. |
| ### carlos analytics |
| @@ -616,6 +618,11 @@ carlos analytics --app hello --range 7d |
| visitors summed. The same view is the Analytics tab on the app's dashboard, |
| and the switch is on the Settings tab too. |
| +Named events come from the page: include `<script src="/__carlos/carlos.js" |
| +defer></script>` and call `carlos.track("name", {field: "value"})`. The same |
| +snippet reports JavaScript errors to the Errors tab. `--set-door off` closes |
| +both beacons for the app; page views are counted at the edge regardless. |
| + |
| ### carlos store |
| Object storage for an app. `create` declares that the app wants a bucket, |
diff --git a/internal/console/analyticsview.go b/internal/console/analyticsview.go| index f9892dc57..122abcc6a 100644 |
| --- a/internal/console/analyticsview.go |
| +++ b/internal/console/analyticsview.go |
| @@ -191,12 +191,14 @@ func analyticsAudit(count, door string) string { |
| // the resolved setting (so the page can say counting is off, and where that |
| // came from), and the chart geometry the template plots. |
| type analyticsWindow struct { |
| - Sum analytics.Summary |
| - Range string |
| - On bool |
| - Setting telemetry.Resolved |
| - ChartBars []chartBar |
| - ChartMax int64 |
| + Sum analytics.Summary |
| + Range string |
| + On bool |
| + Setting telemetry.Resolved |
| + ChartBars []chartBar |
| + ChartMax int64 |
| + Sources []string // "edge", plus "browser" once the beacon has been seen (ruling 7) |
| + DroppedTotal int64 // Sum.Dropped's nine counters, added: a template cannot sum a struct |
| } |
| // chartBar is one plotted column: the reading, and the rectangle that draws |
| @@ -267,6 +269,18 @@ func (s *Server) readAnalytics(ctx context.Context, sqid, app, rng string, now t |
| return out, err |
| } |
| out.Sum = sum |
| + // "edge" is always live — page views are counted there whether or not |
| + // counting is on. "browser" joins only once the summary shows the |
| + // beacon has actually been reached: a named event, or a drop at either |
| + // door (a shut door still proves the snippet made it to the page and |
| + // tried). An app nobody has pointed the snippet at yet must not claim a |
| + // source it has not seen (ruling 7). |
| + out.Sources = []string{"edge"} |
| + d := out.Sum.Dropped |
| + out.DroppedTotal = d.E.Rate + d.E.Size + d.E.Origin + d.E.Malformed + d.A.Rate + d.A.Size + d.A.Origin + d.A.Malformed + d.Flush |
| + if len(out.Sum.Events) > 0 || out.DroppedTotal-d.Flush > 0 { |
| + out.Sources = append(out.Sources, "browser") |
| + } |
| vals, labels := chartSeries(rng, sum) |
| out.ChartBars, out.ChartMax = chartBars(vals, labels, chartWidth, chartHeight) |
| return out, nil |
diff --git a/internal/console/apianalytics.go b/internal/console/apianalytics.go| index 93a0fd399..3a3008610 100644 |
| --- a/internal/console/apianalytics.go |
| +++ b/internal/console/apianalytics.go |
| @@ -37,6 +37,7 @@ type analyticsResponse struct { |
| ReferersApprox bool `json:"referers_approx"` |
| EventsApprox bool `json:"events_approx"` |
| Dropped analytics.Dropped `json:"dropped"` |
| + Sources []string `json:"sources"` |
| } |
| // handleAPIAnalytics answers `carlos analytics`: the instance family's gate, |
| @@ -73,7 +74,7 @@ func (s *Server) handleAPIAnalytics(w http.ResponseWriter, r *http.Request) { |
| PV: sum.PV, Visitors: sum.Visitors, Bytes: sum.Bytes, Bots: sum.Bots, |
| Days: sum.Days, Pages: sum.Pages, Referers: sum.Referers, Events: sum.Events, |
| PagesApprox: sum.PagesApprox, ReferersApprox: sum.ReferersApprox, EventsApprox: sum.EventsApprox, |
| - Dropped: sum.Dropped, |
| + Dropped: sum.Dropped, Sources: win.Sources, |
| } |
| // "Nothing counted" is an empty object, not null — the same shape |
| // errorsResponse gives its groups, so a caller may index the answer |
| @@ -87,6 +88,9 @@ func (s *Server) handleAPIAnalytics(w http.ResponseWriter, r *http.Request) { |
| if resp.Referers == nil { |
| resp.Referers = map[string]int64{} |
| } |
| + if resp.Sources == nil { |
| + resp.Sources = []string{} |
| + } |
| if resp.Events == nil { |
| resp.Events = map[string]int64{} |
| } |
diff --git a/internal/console/apianalytics_test.go b/internal/console/apianalytics_test.go| index 4435324c7..f625178a0 100644 |
| --- a/internal/console/apianalytics_test.go |
| +++ b/internal/console/apianalytics_test.go |
| @@ -76,9 +76,13 @@ func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) { |
| 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") { |
| + if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") || !strings.Contains(body, `name="door"`) { |
| t.Fatalf("app settings: %d", status) |
| } |
| + _, _, acctBody := get(t, c, fx.u("/settings")) |
| + if !strings.Contains(acctBody, `name="door"`) { |
| + t.Fatal("account settings: no door select") |
| + } |
| // 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 { |
| @@ -138,6 +142,10 @@ func TestAPIAnalyticsSummary(t *testing.T) { |
| resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On { |
| t.Fatalf("resp = %+v", resp) |
| } |
| + // A named event was seeded, so the browser beacon has been seen. |
| + if want := []string{"edge", "browser"}; len(resp.Sources) != len(want) || resp.Sources[0] != want[0] || resp.Sources[1] != want[1] { |
| + t.Fatalf("sources = %v, want %v", resp.Sources, want) |
| + } |
| if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 { |
| t.Fatal("bad range") |
| } |
| @@ -167,7 +175,8 @@ func TestAppAnalyticsPage(t *testing.T) { |
| t.Fatalf("%d %s", status, body) |
| } |
| // Off: the switch and the reading, and the numbers that exist already. |
| - for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "<svg"} { |
| + for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", |
| + "<svg", "Sources live: edge, browser", "/__carlos/carlos.js", `carlos.track("signup"`} { |
| if !strings.Contains(body, want) { |
| t.Errorf("page missing %q", want) |
| } |
| @@ -182,3 +191,68 @@ func TestAppAnalyticsPage(t *testing.T) { |
| t.Fatal("on + 7d page") |
| } |
| } |
| + |
| +// TestReadAnalyticsSources: "edge" is always live; "browser" joins only |
| +// once the summary shows the beacon was actually reached — a named event, |
| +// in this case (ruling 7). An app nobody has pointed the snippet at yet |
| +// must not claim a source it has not seen. |
| +func TestReadAnalyticsSources(t *testing.T) { |
| + empty, _ := instanceFixture(t, "code-analytics-sources-empty") |
| + win, err := empty.srv.readAnalytics(context.Background(), empty.sqid, "titogo", rangeToday, seedTime()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := []string{"edge"}; len(win.Sources) != len(want) || win.Sources[0] != want[0] { |
| + t.Fatalf("empty summary sources = %v, want %v", win.Sources, want) |
| + } |
| + if win.DroppedTotal != 0 { |
| + t.Fatalf("empty summary DroppedTotal = %d, want 0", win.DroppedTotal) |
| + } |
| + |
| + seeded, _ := instanceFixture(t, "code-analytics-sources-seeded") |
| + seedAnalytics(t, seeded, seedTime()) |
| + win, err = seeded.srv.readAnalytics(context.Background(), seeded.sqid, "titogo", rangeToday, seedTime()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := []string{"edge", "browser"}; len(win.Sources) != len(want) || win.Sources[0] != want[0] || win.Sources[1] != want[1] { |
| + t.Fatalf("seeded summary sources = %v, want %v", win.Sources, want) |
| + } |
| +} |
| + |
| +// TestAppAnalyticsPageDroppedByReason: a window with browser door drops |
| +// shows the breakdown, names the total in the headline, and still claims |
| +// "browser" as a live source even with no named events — a shut door that |
| +// is dropping beacons has, by definition, been reached. |
| +func TestAppAnalyticsPageDroppedByReason(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-analytics-dropped") |
| + when := seedTime() |
| + m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return when }) |
| + m.Drop(release.OpsAccount, "titogo", analytics.DoorErrors, analytics.DropOrigin, when) |
| + m.Drop(release.OpsAccount, "titogo", analytics.DoorErrors, analytics.DropOrigin, when) |
| + m.Drop(release.OpsAccount, "titogo", analytics.DoorErrors, analytics.DropOrigin, when) |
| + for k, ds := range m.Drain() { |
| + for _, d := range ds { |
| + if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + if err := telemetry.Update(context.Background(), fx.rel, telemetry.AppKey(fx.sqid, "titogo"), func(st *telemetry.Settings) { |
| + st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge} |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-analytics-dropped-signin") |
| + status, _, body := get(t, c, fx.u("/apps/titogo/analytics")) |
| + if status != 200 { |
| + t.Fatalf("%d %s", status, body) |
| + } |
| + for _, want := range []string{"origin", "3", "Dropped by reason", "Sources live: edge, browser"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("page missing %q", want) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apierrors_test.go b/internal/console/apierrors_test.go| index 04e181c39..4af97bac6 100644 |
| --- a/internal/console/apierrors_test.go |
| +++ b/internal/console/apierrors_test.go |
| @@ -236,7 +236,7 @@ func TestAppErrorsPage(t *testing.T) { |
| t.Fatalf("expanded group: %d", status) |
| } |
| status, _, body = get(t, c, fx.u("/apps/titogo/errors?since=1ns")) |
| - if status != 200 || !strings.Contains(body, "No errors in this window") { |
| + if status != 200 || !strings.Contains(body, "No errors in this window") || !strings.Contains(body, "/__carlos/carlos.js") { |
| t.Fatalf("empty window: %d", status) |
| } |
| } |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| index e0f10a9c6..7a7c1e686 100644 |
| --- a/internal/console/templates/account_settings.html |
| +++ b/internal/console/templates/account_settings.html |
| @@ -166,9 +166,15 @@ concluding anything.</p> |
| <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option> |
| </select> |
| </label> |
| + <label>Browser door (<code>/__carlos/</code> beacons): |
| + <select name="door"> |
| + <option value="on"{{if eq .AnalyticsSetting.Door "on"}} selected{{end}}>on</option> |
| + <option value="off"{{if eq .AnalyticsSetting.Door "off"}} selected{{end}}>off</option> |
| + </select> |
| + </label> |
| <button type="submit">Save</button> |
| </form> |
| -<p class="small">Currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}} |
| +<p class="small">Currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}; door {{.AnalyticsSetting.Door}}, from the {{.AnalyticsSetting.DoorFrom}} |
| — 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 |
diff --git a/internal/console/templates/app_analytics.html b/internal/console/templates/app_analytics.html| index 9ebee65d3..ff51f213b 100644 |
| --- a/internal/console/templates/app_analytics.html |
| +++ b/internal/console/templates/app_analytics.html |
| @@ -24,8 +24,10 @@ account on the <a href="{{.Base}}/apps/{{.AppName}}/settings">Settings</a> tab.< |
| {{with .AnalyticsWin}} |
| <div class="tablewrap"><table> |
| <tr><th>Page views</th><th>Visitors{{if ne $.AnalyticsRange "today"}} (daily visitors, summed){{end}}</th><th>Bytes served</th><th>Bots excluded</th><th>Dropped</th></tr> |
| - <tr><td>{{.Sum.PV}}</td><td>{{.Sum.Visitors}}</td><td>{{.Sum.Bytes}}</td><td>{{.Sum.Bots}}</td><td>{{.Sum.Dropped.Flush}}</td></tr> |
| + <tr><td>{{.Sum.PV}}</td><td>{{.Sum.Visitors}}</td><td>{{.Sum.Bytes}}</td><td>{{.Sum.Bots}}</td><td>{{.DroppedTotal}}</td></tr> |
| </table></div> |
| +{{if .DroppedTotal}}<p class="small">Dropped by reason — errors: rate {{.Sum.Dropped.E.Rate}}, size {{.Sum.Dropped.E.Size}}, origin {{.Sum.Dropped.E.Origin}}, malformed {{.Sum.Dropped.E.Malformed}}; events: rate {{.Sum.Dropped.A.Rate}}, size {{.Sum.Dropped.A.Size}}, origin {{.Sum.Dropped.A.Origin}}, malformed {{.Sum.Dropped.A.Malformed}}; flush {{.Sum.Dropped.Flush}}.</p>{{end}} |
| +<p class="small">Sources live: {{range $i, $s := .Sources}}{{if $i}}, {{end}}{{$s}}{{end}}.</p> |
| {{if .ChartBars}} |
| <svg viewBox="0 0 480 140" width="100%" role="img" aria-label="Page views"> |
| {{range .ChartBars}}<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}" fill="currentColor"><title>{{.Label}}: {{.Value}}</title></rect>{{end}} |
| @@ -39,6 +41,7 @@ account on the <a href="{{.Base}}/apps/{{.AppName}}/settings">Settings</a> tab.< |
| {{template "kvtable" .Sum.Referers}} |
| <h3>Named events{{if .Sum.EventsApprox}} <span class="small">approximate</span>{{end}}</h3> |
| {{template "kvtable" .Sum.Events}} |
| +<p class="small">Named events and JavaScript errors need one line in the page: <code><script src="/__carlos/carlos.js" defer></script></code>. Then <code>carlos.track("signup", {plan: "pro"})</code> counts an event here (fields are accepted and not yet shown). Page views are counted at the edge already; the snippet never sends them. What edge counting misses: single-page-app route changes, pages served by a CDN in front of CARLOS, and back/forward-cache restores.</p> |
| {{end}} |
| <p class="small">Times are UTC. The same view from a terminal: <code>carlos analytics --app {{.AppName}} --range {{.AnalyticsRange}}</code>.</p> |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/app_errors.html b/internal/console/templates/app_errors.html| index d3cc17284..c9d77fc95 100644 |
| --- a/internal/console/templates/app_errors.html |
| +++ b/internal/console/templates/app_errors.html |
| @@ -74,6 +74,6 @@ Counts are to the nearest flush. Messages are redacted for addresses and credent |
| <p class="small">Times are UTC. The same view from a terminal: <code>carlos errors --app {{.AppName}} --since {{.ErrSince}}</code>.</p> |
| {{else if not .ErrsErr}} |
| <p>No errors in this window.</p> |
| -<p class="small">Log lines at <code>{{.ErrWin.LogLevel}}</code> and above and responses of 500 or higher appear here on their own; a JavaScript or server-side error needs the hook (coming in a later step). The level is set on the Settings tab. The same view from a terminal: <code>carlos errors --app {{.AppName}}</code>.</p> |
| +<p class="small">Log lines at <code>{{.ErrWin.LogLevel}}</code> and above and responses of 500 or higher appear here on their own. A JavaScript error needs one line in the page: <code><script src="/__carlos/carlos.js" defer></script></code> — messages, stacks and fields are redacted for addresses, credentials and long tokens before they leave the page and again on arrival (a floor, not a guarantee). A server-side error needs the hook (next step). The level is set on the Settings tab. The same view from a terminal: <code>carlos errors --app {{.AppName}}</code>.</p> |
| {{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index f6cbb790b..803e7791a 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -240,8 +240,14 @@ concluding anything.</p> |
| <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option> |
| </select> |
| </label> |
| + <label>Browser door (<code>/__carlos/</code> beacons): |
| + <select name="door"> |
| + <option value="on"{{if eq .AnalyticsSetting.Door "on"}} selected{{end}}>on</option> |
| + <option value="off"{{if eq .AnalyticsSetting.Door "off"}} selected{{end}}>off</option> |
| + </select> |
| + </label> |
| <button type="submit" class="btn-sm">Save</button> |
| - <span class="small">currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}</span> |
| + <span class="small">currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}; door {{.AnalyticsSetting.Door}}, from the {{.AnalyticsSetting.DoorFrom}}</span> |
| </form> |
| {{else}} |
| <p class="small">Analytics counting is <code>{{.AnalyticsSetting.Count}}</code> for this app, from |
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go| index 6618c52ab..c9dfd03f9 100644 |
| --- a/internal/telemetry/telemetry.go |
| +++ b/internal/telemetry/telemetry.go |
| @@ -105,7 +105,12 @@ type Resolved struct { |
| } |
| // Resolve reads both levels and folds them over the defaults. A store |
| -// error fails the resolve; an absent object at either level is fine. |
| +// error fails the resolve; an absent object at either level is fine. An |
| +// empty app names no app's key at all (the account settings page's own |
| +// read of its own defaults, over AppKey's account-level fallback) — the |
| +// app-level read is skipped rather than attempted, because AppKey(sqid, |
| +// "") is not a key any store need treat as merely absent: DirStore's |
| +// traversal guard refuses the empty path segment outright. |
| func Resolve(ctx context.Context, s release.Store, sqid, app string) (Resolved, error) { |
| r := Resolved{LogLevel: LogLevelError, LogLevelFrom: "default", Count: CountOff, CountFrom: "default", Door: DoorOn, DoorFrom: "default"} |
| if s == nil { |
| @@ -115,9 +120,12 @@ func Resolve(ctx context.Context, s release.Store, sqid, app string) (Resolved, |
| if err != nil { |
| return r, err |
| } |
| - appSt, _, err := Read(ctx, s, AppKey(sqid, app)) |
| - if err != nil { |
| - return r, err |
| + var appSt Settings |
| + if app != "" { |
| + appSt, _, err = Read(ctx, s, AppKey(sqid, app)) |
| + if err != nil { |
| + return r, err |
| + } |
| } |
| for _, lvl := range []struct { |
| from string |