analytics: the restart note is per app-day; PUT doc; serial-unchanged test
Restarted was cleared off the meter's `started` field after the FIRST delta of ANY app, so only one app's day file ever carried the "counts since the last flush may be missing" note. It is now per (app, day): each app gets its own first-delta-of-the-start-day flag, tracked in restartedFlagged so a later drain of the same start day does not repeat it; `started` (and the map) are still cleared in Drain once the wall clock moves past the start day. telemetryPut's doc comment now says it carries the errors log level and the analytics switches, either or both in one body. apianalytics_test.go: an errors-only PUT reads release.ReadSerial before and after and asserts Serial is unchanged, since only the analytics half is box-consumed and worth a serial bump.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+77
−27
apimode.go+3 −2internal/analytics/meter.go+22 −14internal/analytics/meter_test.go+35 −11internal/console/apianalytics_test.go+17 −0
CI passed — run details
diff --git a/apimode.go b/apimode.go| index beb5fa821..4348c1561 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2930,8 +2930,9 @@ func (s *apiSession) analyticsGet(ctx context.Context, a apiAcct, app string, qu |
| return res, nil |
| } |
| -// telemetryPut sets an app's telemetry settings (currently just the errors |
| -// log level) — PUT /api/cli/apps/{account}/{app}/telemetry. |
| +// telemetryPut sets an app's telemetry settings: the errors log level and |
| +// the analytics switches, either or both in one body — PUT |
| +// /api/cli/apps/{account}/{app}/telemetry. |
| func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, body any) error { |
| status, resp, err := s.putJSON(ctx, apiPath(a, app, "/telemetry"), body) |
| if err != nil { |
diff --git a/internal/analytics/meter.go b/internal/analytics/meter.go| index 4455f818e..50d1d5baa 100644 |
| --- a/internal/analytics/meter.go |
| +++ b/internal/analytics/meter.go |
| @@ -95,23 +95,24 @@ type Meter struct { |
| saltDir string |
| now func() time.Time |
| - mu sync.Mutex |
| - cur map[AppKey]map[string]*Delta // app → date → delta being built |
| - pending map[AppKey][]Delta // re-queued after a failed flush, each marked once |
| - dropPending map[AppKey]int64 // deltas dropped by a Requeue that found an already-marked one, awaiting the next delta to land on |
| - seq int64 |
| - saltDay string |
| - salt []byte |
| - saltFailDay string // date DaySalt last failed for, "" once it next succeeds |
| - saltFailAt time.Time // when it failed; not retried until saltRetryDelay has passed |
| - started string // date the process started: Restarted is set on the very first delta created after start |
| + mu sync.Mutex |
| + cur map[AppKey]map[string]*Delta // app → date → delta being built |
| + pending map[AppKey][]Delta // re-queued after a failed flush, each marked once |
| + dropPending map[AppKey]int64 // deltas dropped by a Requeue that found an already-marked one, awaiting the next delta to land on |
| + seq int64 |
| + saltDay string |
| + salt []byte |
| + saltFailDay string // date DaySalt last failed for, "" once it next succeeds |
| + saltFailAt time.Time // when it failed; not retried until saltRetryDelay has passed |
| + started string // date the process started; "" once the wall clock has moved past it (see Drain) |
| + restartedFlagged map[AppKey]bool // apps whose start-day delta has already carried Restarted |
| } |
| // NewMeter returns a meter whose salts live in saltDir (the box data dir). |
| func NewMeter(saltDir, box string, now func() time.Time) *Meter { |
| return &Meter{box: box, saltDir: saltDir, now: now, cur: map[AppKey]map[string]*Delta{}, |
| pending: map[AppKey][]Delta{}, dropPending: map[AppKey]int64{}, |
| - seq: now().UnixMilli(), started: usage.DayOf(now())} |
| + seq: now().UnixMilli(), started: usage.DayOf(now()), restartedFlagged: map[AppKey]bool{}} |
| } |
| func (m *Meter) saltFor(date string) []byte { |
| @@ -132,6 +133,12 @@ func (m *Meter) saltFor(date string) []byte { |
| return s |
| } |
| +// delta returns the app's delta being built for date, creating it (and |
| +// setting Restarted) on first touch. Restarted is per (app, day), not per |
| +// process: every app gets its own first-delta-of-the-start-day flag, since |
| +// each app's counts since the last flush may be missing regardless of |
| +// which app happened to be observed first. restartedFlagged stops a later |
| +// drain's fresh delta for the same app-day from being flagged again. |
| func (m *Meter) delta(k AppKey, date string) *Delta { |
| days := m.cur[k] |
| if days == nil { |
| @@ -140,12 +147,12 @@ func (m *Meter) delta(k AppKey, date string) *Delta { |
| } |
| d := days[date] |
| if d == nil { |
| - restarted := date == m.started |
| + restarted := date == m.started && !m.restartedFlagged[k] |
| d = &Delta{Date: date, Box: m.box, Hours: map[string]HourRow{}, Visitors: &HLL{}, |
| Pages: NewTopK(), Referers: NewTopK(), Events: NewTopK(), Restarted: restarted} |
| days[date] = d |
| if restarted { |
| - m.started = "" // the note belongs to the first delta created after start, not every delta of the day |
| + m.restartedFlagged[k] = true // this app's start-day note is spent; a later drain's fresh delta for the same day must not repeat it |
| } |
| } |
| return d |
| @@ -226,7 +233,8 @@ func (m *Meter) Drain() map[AppKey][]Delta { |
| } |
| } |
| if m.started != "" && usage.DayOf(m.now()) != m.started { |
| - m.started = "" // the restart note is only for the day it happened |
| + m.started = "" // the restart note is only for the day it happened |
| + m.restartedFlagged = map[AppKey]bool{} // free the bookkeeping once no future delta can consult it |
| } |
| return out |
| } |
diff --git a/internal/analytics/meter_test.go b/internal/analytics/meter_test.go| index da7abf4a5..a485da65e 100644 |
| --- a/internal/analytics/meter_test.go |
| +++ b/internal/analytics/meter_test.go |
| @@ -178,23 +178,47 @@ func TestMeterSaltFailureIsThrottled(t *testing.T) { |
| } |
| } |
| -// TestMeterRestartedOnlyOnFirstDelta: Restarted marks the very first |
| -// delta created after the meter starts, not every app-day's first |
| -// delta. |
| -func TestMeterRestartedOnlyOnFirstDelta(t *testing.T) { |
| +// TestMeterRestartedPerAppOnStartDay: Restarted is per (app, day), not per |
| +// process. Every app observed on the start day gets its own first-delta |
| +// flag (the "counts since the last flush may be missing" note is true for |
| +// each app independently); a later drain of the same start day must not |
| +// repeat it; and it never fires once the wall clock has moved past the |
| +// start day. |
| +func TestMeterRestartedPerAppOnStartDay(t *testing.T) { |
| now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) |
| m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) |
| - m.Observe(obs("GET", 200, "text/html")) // first delta created since start: bab/web |
| + |
| + appA := AppKey{Acct: "bab", App: "web"} |
| + appB := AppKey{Acct: "other", App: "app2"} |
| + |
| + m.Observe(obs("GET", 200, "text/html")) // app A's first delta of the start day |
| o := obs("GET", 200, "text/html") |
| - o.Acct, o.App = "other", "app2" |
| - m.Observe(o) // a different app's first delta of the day, but not the meter's first since start |
| + o.Acct, o.App = appB.Acct, appB.App |
| + m.Observe(o) // app B's first delta of the start day |
| out := m.Drain() |
| - if !out[AppKey{Acct: "bab", App: "web"}][0].Restarted { |
| - t.Fatal("the very first delta created after start must carry Restarted") |
| + if !out[appA][0].Restarted { |
| + t.Fatal("app A's first delta of the start day must carry Restarted") |
| } |
| - if out[AppKey{Acct: "other", App: "app2"}][0].Restarted { |
| - t.Fatal("only the meter's first delta since start should carry Restarted, not every app's") |
| + if !out[appB][0].Restarted { |
| + t.Fatal("app B's first delta of the start day must also carry Restarted: the flag is per app-day, not per process") |
| + } |
| + |
| + // A second drain of the same start day for app A must not repeat the note. |
| + m.Observe(obs("GET", 200, "text/html")) |
| + out = m.Drain() |
| + if out[appA][0].Restarted { |
| + t.Fatal("app A's second delta of the start day must not be flagged again") |
| + } |
| + |
| + // The next day, nothing is flagged: the note belongs to the start day. |
| + now = time.Date(2026, 9, 8, 9, 30, 0, 0, time.UTC) |
| + next := obs("GET", 200, "text/html") |
| + next.When = now |
| + m.Observe(next) |
| + out = m.Drain() |
| + if out[appA][0].Restarted { |
| + t.Fatal("a day after the start day must never carry Restarted") |
| } |
| } |
diff --git a/internal/console/apianalytics_test.go b/internal/console/apianalytics_test.go| index af33e1ecd..4435324c7 100644 |
| --- a/internal/console/apianalytics_test.go |
| +++ b/internal/console/apianalytics_test.go |
| @@ -51,6 +51,23 @@ func TestAPITelemetryPutSetsAnalytics(t *testing.T) { |
| if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 { |
| t.Fatalf("serial not bumped: %+v %v", doc, err) |
| } |
| + |
| + // An errors-only PUT touches no box-acted-on setting, so it must not |
| + // bump the serial: boxes would converge for no reason. |
| + before, _, err := release.ReadSerial(context.Background(), fx.rel) |
| + if err != nil { |
| + t.Fatalf("serial before: %v", err) |
| + } |
| + if st, b := put(`{"errors":{"log_level":"error"}}`); st != 200 { |
| + t.Fatalf("errors-only: %d %s", st, b) |
| + } |
| + after, _, err := release.ReadSerial(context.Background(), fx.rel) |
| + if err != nil { |
| + t.Fatalf("serial after: %v", err) |
| + } |
| + if after.Serial != before.Serial { |
| + t.Fatalf("errors-only PUT bumped the serial: before %+v, after %+v", before, after) |
| + } |
| } |
| func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) { |