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.