github.com/grafana/synthetic-monitoring-agent@v0.64.2/docs/architecture/updater.md (about) 1 --- 2 component: Updater 3 source_paths: 4 - internal/checks/ 5 last_reviewed_commit: f77bec5e 6 generated: 2026-05-23 7 --- 8 9 # Updater — `internal/checks` 10 11 ## Purpose 12 13 The Updater is the agent's connection to the API server. It does three 14 things: 15 16 1. Registers the probe with the API and holds a long-lived `GetChanges` stream that pushes check ADD/UPDATE/DELETE operations. 17 2. Owns the lifecycle of every Scraper — creating one per check, restarting it on UPDATE, tearing it down on DELETE. 18 3. Survives connection failures with bounded back-off, and supports a "soft disconnect" (SIGUSR1) for zero-downtime upgrades. 19 20 Everything else — Scraper, Prober, Publisher — is built once. The 21 Updater is the moving part that keeps them populated with current 22 configuration. 23 24 ## Where it lives 25 26 `internal/checks/` — primarily `checks.go`. 27 28 ## How it fits in 29 30 ```mermaid 31 flowchart LR 32 API["API server"] 33 Updater["Updater<br/>internal/checks.Updater"] 34 Scrapers["Scrapers (map[GlobalID]*Scraper)"] 35 Publisher 36 Tenants["Tenant manager<br/>via tenantCh"] 37 Meta["Metamonitoring<br/>via probeCh"] 38 39 API <-->|GetChanges stream<br/>RegisterProbe<br/>Ping| Updater 40 Updater -->|spawn / tear down| Scrapers 41 Scrapers -->|payloads| Publisher 42 Updater -->|tenant changes| Tenants 43 Updater -->|probe identity once| Meta 44 ``` 45 46 ## Lifecycle 47 48 ### Construction 49 50 `NewUpdater(UpdaterOptions)` — called from 51 `cmd/synthetic-monitoring-agent/main.go`. The options struct injects 52 every collaborator: gRPC connection, logger, back-off, publisher, tenant 53 and probe channels, the readiness callback, the k6 runner, a scraper 54 factory, tenant limits, secret provider, telemeter, usage reporter, cost 55 attribution labels, and the `SupportsProtocolSecrets` flag. 56 57 `NewUpdater` registers all Prometheus metrics owned by the Updater itself 58 *and* by every Scraper it will spawn (see 59 [Scraper-owned metrics](#scraper-owned-metrics) below). 60 61 ### Run loop 62 63 `Updater.Run(ctx)`: 64 65 ```mermaid 66 sequenceDiagram 67 participant Run as Updater.Run 68 participant Loop as Updater.loop 69 participant API as API server 70 participant HE as handleError 71 72 Run->>Loop: call loop() 73 Loop->>API: RegisterProbe 74 API-->>Loop: probe info + capabilities 75 Loop->>API: GetChanges (stream) 76 par stream consumption 77 Loop->>Loop: processChanges -> handleChangeBatch 78 and health pings 79 Loop->>API: Ping every HealthCheckInterval 80 end 81 Loop-->>Run: (wasConnected, err) 82 Run->>HE: handleError 83 alt fatal 84 HE-->>Run: return err 85 else transient / unknown 86 HE->>HE: sleep backoff.Duration() 87 HE-->>Run: loop again 88 else context cancelled 89 HE-->>Run: return nil 90 end 91 ``` 92 93 `Run` is an infinite `for` loop around `loop` + `handleError`. The 94 back-off resets when a previously-connected session ends; that way a 95 healthy probe that briefly loses connectivity doesn't immediately ramp 96 to the 30-second ceiling. 97 98 ### Steady state 99 100 Inside `loop()`: 101 102 - Register the probe (`client.RegisterProbe`). Validate capabilities against the available k6 runner (`validateProbeCapabilities` — returns `errCapabilityK6Missing` if the API expects scripted/browser support but no k6 runner is configured). 103 - Flip the readiness callback to `true` and the `sm_agent_api_connection_status` gauge to `1` (cleared via `defer`). 104 - Open the `GetChanges` stream, passing the set of currently-known checks as `ProbeState.Checks`. The API uses this to compute the right starting batch. 105 - Launch two goroutines via an inner `errgroup`: 106 - `ping(...)` — periodic `client.Ping` calls every `synthetic_monitoring.HealthCheckInterval`. 107 - `processChanges(ctx, stream)` — main consumer of the change stream. 108 109 When either goroutine returns, the loop unwinds and `Run` decides 110 whether to retry. 111 112 ### Shutdown 113 114 Two shutdown paths: 115 116 - **Parent context cancelled** (e.g. SIGTERM). All goroutines unwind, scrapers are stopped via their own contexts (see below), `Run` returns `nil`. 117 - **Fatal error** (e.g. `errNotAuthorized`, `errIncompatibleApi`, `errCapabilityK6Missing`). `handleError` short-circuits and returns the error. 118 119 ## SIGUSR1 / `/disconnect` flow 120 121 The disconnect path is *intentionally narrow*: only the API stream is 122 cancelled. Scrapers keep running. 123 124 This is implemented by `installSignalHandler(groupCtx)` inside `loop()`. 125 It returns a derived context (`sigCtx`) that is cancelled when SIGUSR1 is 126 delivered, plus a `*int32` flag that distinguishes signal-driven 127 cancellation from a normal context cancel. Crucially, `processChanges` 128 is called with the *parent* `ctx`, not `sigCtx`, so the 129 parent-`ctx`-bound child contexts used by every Scraper survive the 130 disconnect. 131 132 When `sigCtx` fires, the gRPC stream returns an error and `loop` exits 133 with `errProbeUnregistered` (mapped from the `signalFired` flag). `Run` 134 then back-offs and reconnects. Combined with the back-off (~2s minimum), 135 this gives a replacement agent a window to register and pick up the 136 probe's checks. 137 138 The HTTP `/disconnect` endpoint is just sugar for `kill -USR1 $pid`; 139 see [cmd.md](cmd.md). 140 141 ## Change handling 142 143 ### First batch 144 145 The first batch after each reconnect is special. The server only sends 146 ADDs (never DELETEs or UPDATEs), so the Updater must reconcile its 147 running scrapers against the ADDs received: 148 149 - For every ADD: if a scraper exists at that `GlobalID` with the same `ConfigVersion`, skip; if it exists with a different version, treat the ADD as an UPDATE; otherwise add and start a new scraper. 150 - After processing all ADDs, **stop every scraper whose check was not in the first batch** — those have been deleted while the agent was disconnected. 151 152 This happens in `handleFirstBatch` / `handleInitialChangeAddWithLock`. 153 The "delta first batch" optimisation is signalled by 154 `changes.IsDeltaFirstBatch`: if set, the API is sending an incremental 155 update instead of a full snapshot, and the regular change handler is 156 used. 157 158 ### Subsequent batches 159 160 `handleChangeBatch` dispatches each change to: 161 162 - `handleCheckAdd` — error if the check already exists. Adds and starts the scraper. 163 - `handleCheckUpdate` — stops and removes the existing scraper, then re-adds via `addAndStartScraperWithLock`. This is the "lazy way", but it's safe and keeps the update logic in one place. 164 - `handleCheckDelete` — stops the scraper and removes it from the map. 165 166 All three are guarded by `scrapersMutex`. 167 168 Tenant changes received alongside checks are forwarded to `tenantCh` so 169 the tenant manager can refresh its cache. 170 171 ### Feature-flag gating 172 173 `addAndStartScraperWithLock` is the choke point for "do we run this 174 check type at all?". Currently it gates `CheckTypeScripted` and 175 `CheckTypeMultiHttp` on the `feature.K6` flag — even though that flag is 176 permanently enabled today, the gate stays in place so the pattern is 177 easy to extend. If you add a new check type that should be conditional, 178 this is the place. 179 180 ## Error classification 181 182 Three custom types from `internal/error_types` drive the loop: 183 184 | Type | Sentinel values defined in | Effect in `handleError` | 185 | ---------------- | -------------------------- | ------------------------------------- | 186 | `FatalError` | `checks.go` | Log, return — `Run` exits. | 187 | `TransientError` | `checks.go` | Log warning, sleep `backoff.Duration()`, retry. | 188 | (anything else) | wrapped error | Treated as transient (with a warning). | 189 190 `FatalError` and `TransientError` are *types* from `internal/error_types`. 191 The sentinel error values that use those types (e.g. 192 `errNotAuthorized = FatalError("...")`) are defined in `checks.go`. 193 194 `grpcErrorHandler` (defined inside `loop`) converts gRPC status codes 195 into the right error type: 196 197 - `Canceled` → `context.Canceled` (clean shutdown). 198 - "transport is closing" message → `errTransportClosing` (transient). 199 - `Unavailable` → `TransientError`. 200 - `PermissionDenied` → `errNotAuthorized` (fatal). 201 - `Unimplemented` → `errIncompatibleApi` (fatal). 202 - everything else → the underlying gRPC error (treated as transient by default). 203 204 The `wasConnected` flag in `handleError` exists so we only reset the 205 back-off when a *previously healthy* session is interrupted — repeated 206 registration failures shouldn't keep dropping the back-off back to 2 207 seconds. 208 209 ## Probe identity propagation 210 211 `notifyProbeTenant` sends the probe pointer into `probeCh` exactly once 212 (`sync.Once`) and closes the channel. Metamonitoring uses this to learn 213 which tenant should receive the agent's own metrics. Subsequent 214 reconnects don't re-send. 215 216 ## Scraper-owned metrics 217 218 The Updater registers four Prometheus vectors *on behalf of every 219 Scraper it will create*: 220 221 - `sm_agent_scraper_operations_total{type, tenantId, regionId}` 222 - `sm_agent_scraper_errors_total{type, source, tenantId, regionId}` 223 - `sm_agent_updater_scrapers_total{type}` (gauge — running count) 224 - `sm_agent_updater_changes_total{type}` and `sm_agent_updater_change_errors_total{type}` (Updater-local). 225 226 `addAndStartScraperWithLock` derives a per-check sub-vector 227 (`CurryWith` / `GetMetricWith`) and hands it to `scraper.NewMetrics`. 228 This avoids one registration per scraper while keeping per-tenant / 229 per-region cardinality. 230 231 ## Key types and entry points 232 233 | Type / function | File | Notes | 234 | --------------------------------------------------------- | ---------------- | -------------------------------------------------- | 235 | `Updater` | `checks.go` | The struct. | 236 | `UpdaterOptions` | `checks.go` | Construction inputs from `cmd/`. | 237 | `NewUpdater(opts)` | `checks.go` | Builds and registers all metrics. | 238 | `(*Updater).Run(ctx)` | `checks.go` | Reconnect loop. | 239 | `(*Updater).loop(ctx)` | `checks.go` | One full connected session. | 240 | `handleError(...)` | `checks.go` | Maps errors to retry/fatal/exit decisions. | 241 | `installSignalHandler(ctx)` | `checks.go` | SIGUSR1 → derived context. | 242 | `processChanges`, `handleChangeBatch`, `handleFirstBatch` | `checks.go` | Stream consumption. | 243 | `handleCheckAdd / Update / Delete` | `checks.go` | Per-operation handlers, mutex-guarded. | 244 | `addAndStartScraperWithLock` | `checks.go` | Feature-flag gate + Scraper factory invocation. | 245 246 ## Testing strategy 247 248 Tests live in `internal/checks/checks_test.go`. Patterns to be aware of: 249 250 - **Table-driven** with `t.Run(name, ...)`. Most tests construct an `Updater` with the real `NewUpdater`, then drive it through mock collaborators. 251 - `TestNewUpdater`, `TestNewUpdaterSupportsProtocolSecrets` — verify metric registration and option propagation. 252 - `TestInstallSignalHandler` — exercises the SIGUSR1 path without a real signal by cancelling the parent context and asserting the `fired` flag stays `0`. 253 - `TestSleepCtx` — context-aware sleep helper. 254 - `TestHandleCheckOp` — drives add/update/delete operations through the locked path and asserts scraper-map state. 255 - `TestCheckHandlerProbeValidation` — exercises the capability / feature-flag interaction. 256 - `TestHandleError` — covers every branch of `handleError` (fatal/transient/unknown/cancelled) and the back-off reset behaviour. 257 - `TestProbeTenantCh` — `sync.Once` semantics around probe-id propagation. 258 259 Run only this package: 260 261 ```bash 262 make test-go GO_TEST_ARGS=./internal/checks/... 263 ``` 264 265 Tests do not hit a real API server; the gRPC client connection is a 266 `*grpc.ClientConn` against an in-process server where needed. 267 268 ## When to update this doc 269 270 Update this document when you: 271 272 - Add, remove, or rename a `synthetic_monitoring.Checks` RPC consumed by the Updater (e.g. `RegisterProbe`, `GetChanges`, `Ping`). 273 - Change the back-off strategy used by the reconnect loop (note: the back-off itself is constructed in `cmd/`, but the loop owns when to reset it). 274 - Add or modify a `CheckOperation_*` case in `handleChangeBatch` or `handleFirstBatch`. 275 - Add a new check-type gate in `addAndStartScraperWithLock`. 276 - Change SIGUSR1 / disconnect semantics (including which contexts get cancelled). 277 - Change the readiness contract (the `IsConnected` callback). 278 - Add, remove, or rename a field on `UpdaterOptions`. 279 - Add a new sub-goroutine inside `loop()` (today: `ping` and `processChanges`). 280 - Change the meaning of any `sm_agent_updater_*` or `sm_agent_scraper_*` metric. 281 - Add or change behaviour around the `probeCh` / `tenantCh` channels.