agent: push the telemetry switches once before the first request is served
Every app's door was shut for the first minute after an agent restart. The switch map starts empty, and empty is the safe zero — count off, door closed — so a page's beacons were refused for that minute with nothing to show for it. The two passes that fill the map could not: telemetryPass is a no-op until the edge's SetSwitches is registered, which happens in the ready hook just before serving; instancesLoop sleeps a minute before its first tick; and fullPass runs only on a serial bump. So the pass now runs once, right after the sink is wired and before the first request. It costs one route List plus two Gets per distinct app, on a path already doing much more than that. The resolve-failure log line said "analytics stays off" when what it actually means is both switches at their zero: analytics off AND the door closed. It says that now.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+50
−1
internal/host/agent.go+16 −0internal/host/telemetry_pass.go+1 −1internal/host/telemetry_pass_test.go+33 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 7ea4fc2c1..b0d19b062 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -454,6 +454,22 @@ func RunAgent(args []string) { |
| am := analytics.NewMeter(dataDir(), status.Label(readMachineID()), time.Now) |
| e.EnableAnalytics(am) |
| setAnalyticsSink(e.SetSwitches) |
| + // One pass right here, before the first request is served. Without |
| + // it the switch map is empty for the first minute of every restart — |
| + // which is every app's door CLOSED and every app's counting off, |
| + // because absent is the safe zero — and the two passes that would |
| + // fill it cannot: telemetryPass is a no-op until the sink above is |
| + // registered, instancesLoop sleeps a minute before its first tick, |
| + // and fullPass runs only on a serial bump. A minute of silently |
| + // refused beacons after every agent restart is exactly the kind of |
| + // gap nobody would ever find from the tab. Costs one route List plus |
| + // two Gets per distinct app, on a path that is already doing far |
| + // more work than that. |
| + if st, err := openDeployStore(context.Background()); err != nil { |
| + log.Printf("agent: telemetry first pass: %v (analytics stays off until the next pass)", err) |
| + } else { |
| + telemetryPass(context.Background(), st, reg) |
| + } |
| go usageLoop(m.Drain, am) |
| }, |
| // Certificate issuance is a deployment-global duty, so it runs where |
diff --git a/internal/host/telemetry_pass.go b/internal/host/telemetry_pass.go| index 97e1c2b1b..19ca9544c 100644 |
| --- a/internal/host/telemetry_pass.go |
| +++ b/internal/host/telemetry_pass.go |
| @@ -45,7 +45,7 @@ func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Regis |
| seen[key] = true |
| res, err := telemetry.Resolve(ctx, s, sqid, rt.App) |
| if err != nil { |
| - log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err) |
| + log.Printf("agent: telemetry resolve %s: %v (analytics off, door closed)", key, err) |
| continue |
| } |
| on[key] = res.Switches() |
diff --git a/internal/host/telemetry_pass_test.go b/internal/host/telemetry_pass_test.go| index 56498a829..794922326 100644 |
| --- a/internal/host/telemetry_pass_test.go |
| +++ b/internal/host/telemetry_pass_test.go |
| @@ -54,3 +54,36 @@ func TestTelemetrySwitchesDoorOffUnderAccountAnalyticsOn(t *testing.T) { |
| t.Fatalf("switches = %v", on) |
| } |
| } |
| + |
| +// TestTelemetryPassDeliversAsSoonAsTheSinkIsWired is the sequencing behind |
| +// the startup pass RunAgent makes right after setAnalyticsSink: a pass |
| +// before the sink exists delivers nothing (so the fast-converge loop's |
| +// earlier passes cannot have filled the map), and one immediately after it |
| +// delivers the whole map — no waiting for the instances loop's first tick a |
| +// minute later, which is a minute of every door shut on every restart. |
| +func TestTelemetryPassDeliversAsSoonAsTheSinkIsWired(t *testing.T) { |
| + old := analyticsSink.Load() |
| + t.Cleanup(func() { analyticsSink.Store(old) }) |
| + analyticsSink.Store(nil) |
| + |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := edgesTestRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No sink yet: the pass is a no-op, which is why a pass that ran before |
| + // the ready hook cannot be relied on to have opened anything. |
| + telemetryPass(ctx, s, reg) |
| + |
| + var got map[string]telemetry.Switches |
| + var calls int |
| + setAnalyticsSink(func(m map[string]telemetry.Switches) { calls++; got = m }) |
| + telemetryPass(ctx, s, reg) |
| + if calls != 1 { |
| + t.Fatalf("sink called %d times, want exactly the one pass after wiring", calls) |
| + } |
| + if got["bab/web"] != (telemetry.Switches{Count: false, Door: true}) { |
| + t.Fatalf("first pass delivered %v, want the fleet default (count off, door open)", got) |
| + } |
| +} |