github.com/grafana/synthetic-monitoring-agent@v0.64.2/docs/architecture/publisher.md (about) 1 --- 2 component: Publisher 3 source_paths: 4 - internal/pusher/ 5 - internal/pkg/prom/ 6 - internal/pkg/loki/ 7 last_reviewed_commit: f77bec5e 8 generated: 2026-05-23 9 --- 10 11 # Publisher — `internal/pusher` 12 13 ## Purpose 14 15 The Publisher accepts payloads from scrapers and the ad-hoc handler and 16 ships them outward: **metrics via Prometheus remote-write**, **logs via 17 the Loki push API**. It maintains a separate **push handler per tenant** 18 so one tenant's backpressure does not block another, and it includes 19 back-off, retry, idle-cleanup, and "tenant temporarily disabled" paths 20 that the rest of the agent doesn't have to know about. 21 22 ## Where it lives 23 24 `internal/pusher/` 25 26 | File / dir | Responsibility | 27 | ----------------------- | -------------------------------------------------------------------- | 28 | `pusher.go` | `Publisher`, `Payload`, `TenantProvider`, `Factory` interfaces. | 29 | `registry.go` | Generic `registry[T]` used to look up implementations by name. | 30 | `metrics.go` | Shared `Metrics` struct (counters/gauges) used by both v1 and v2. | 31 | `clients.go` | `ClientFromRemoteInfo` — turns the API-supplied `RemoteInfo` into a `prom.Client` config. | 32 | `v1/` | Original implementation: one goroutine per `Publish` call. Retained for compatibility; selected by `-publisher v1`. | 33 | `v2/` | Current implementation: per-tenant long-lived handlers with queues, retries, life-cycle. **Default.** | 34 | `internal/pkg/prom` | Prometheus remote-write client wrapper. | 35 | `internal/pkg/loki` | Loki push client wrapper (used by v1; v2 uses `prom.Client` for both because Loki's API is wire-compatible enough). | 36 37 ## How it fits in 38 39 ```mermaid 40 flowchart LR 41 Scrapers -->|Publish payload| Publisher 42 Adhoc -->|Publish payload| Publisher 43 Publisher -->|GetTenant| Tenants["Tenant manager<br/>(TenantProvider)"] 44 Publisher -->|remote_write| Prom[("Prometheus")] 45 Publisher -->|push| Loki[("Loki")] 46 ``` 47 48 ## Public contract 49 50 ```go 51 type Payload interface { 52 Tenant() model.GlobalID 53 Metrics() []prompb.TimeSeries 54 Streams() []logproto.Stream 55 } 56 57 type Publisher interface { 58 Publish(Payload) 59 } 60 61 type TenantProvider interface { 62 GetTenant(context.Context, *sm.TenantInfo) (*sm.Tenant, error) 63 } 64 65 type Factory func(ctx context.Context, tm TenantProvider, 66 logger zerolog.Logger, 67 promRegisterer prometheus.Registerer) Publisher 68 ``` 69 70 `Publish` is non-blocking and infallible from the caller's point of 71 view; errors are absorbed and reflected in metrics. 72 73 `Factory` is the registration shape. Both implementations register at 74 import time (in `cmd/synthetic-monitoring-agent/main.go`): 75 76 ```go 77 pusherRegistry.MustRegister(pusherV1.Name, pusherV1.NewPublisher) 78 pusherRegistry.MustRegister(pusherV2.Name, pusherV2.NewPublisher) 79 ``` 80 81 The `-publisher` flag picks one. v2 (`Name = "v2"`) is the default. 82 83 ## V1 vs V2 84 85 | Aspect | v1 | v2 | 86 | ---------------------------- | ---------------------------------------------- | ------------------------------------------------------------------ | 87 | Concurrency model | One goroutine per `Publish` call. | Long-lived per-tenant goroutine consuming a queue. | 88 | Per-tenant batching | No. | Yes — batched up to `maxPushBytes` (64 KiB). | 89 | Backpressure handling | Per-call. | Two queues (metrics, logs), bounded by bytes (`maxQueuedBytes`) and time (`maxQueuedTime`). | 90 | Tenant lifetime | n/a (stateless). | `maxLifetime` (2h ± 25 % jitter) to force tenant info refresh. | 91 | Idle cleanup | n/a. | `maxIdleTime` (65 min) — handler exits if no pushes arrive. | 92 | Failure modes | Retried inline. | Returned as classified `pushError` → `delayPusher` / `discardPusher`. | 93 | 429 / "Too Many Requests" | Standard back-off. | Dedicated `waitPeriod` (1 min) during which payloads keep arriving but pushes are paused. | 94 | Fatal failures | Returned to the caller. | `discardPusher` drops payloads for `discardPeriod` (15 min). | 95 | State exposed by metric | `sm_agent_publisher_*` | Same metrics + the v2-only `drop_total`, `responses_total`, `handlers_total`. | 96 97 If you are changing publish behaviour, **change v2.** v1 exists for 98 the rare deployment that pins `-publisher v1`; it should not gain new 99 features. 100 101 ## V2 internals 102 103 ### Top-level — `publisherImpl` 104 105 `publisher.go`: 106 107 - A single `Publisher` instance, no shared queue. 108 - `handlers map[model.GlobalID]payloadHandler`, mutex-guarded by `handlerMutex`. 109 - `Publish` looks up (or creates) the handler for the payload's tenant, then calls `handler.publish(payload)`. Handler creation atomically swaps a `nil` slot for a new `tenantPusher` and spawns a goroutine running `runHandler`. 110 - `replaceHandler` is the only mutation primitive. The truth-table comment in the source documents every old/current/new combination. Bugs in this area tend to look like "handlers vanish under load"; do not simplify without understanding that table. 111 112 ### `tenantPusher` — per-tenant state machine 113 114 `tenant_pusher.go`: 115 116 ```mermaid 117 stateDiagram-v2 118 [*] --> tenantPusher 119 tenantPusher --> tenantPusher: 429 -> delayPusher(waitPeriod) -> tenantPusher 120 tenantPusher --> tenantPusher: stale tenant -> delayPusher(tenantDelay) -> tenantPusher 121 tenantPusher --> discardPusher: fatal -> discard for discardPeriod 122 discardPusher --> [*]: timeout -> slot cleared 123 tenantPusher --> [*]: idle (maxIdleTime) or maxLifetime exceeded 124 tenantPusher --> [*]: context cancelled 125 ``` 126 127 - `run` fetches the tenant from the `TenantProvider`, derives the Prometheus and Loki remote configs (`tenant.MetricsRemote`, `tenant.EventsRemote`), and starts two queue pushers under an `errgroup`. 128 - An idle checker and a max-lifetime checker run as additional group members. 129 - When `run` returns it inspects the error: 130 - `nil`, `context.Canceled`, `errTenantIdle` → clear the slot. 131 - `pushError` with `errKindWait` → wrap self in a `delayPusher`, re-run after `waitPeriod`. 132 - `pushError` with `errKindTenant` → re-run after `tenantDelay` (forces a fresh `GetTenant` call). 133 - `pushError` with `errKindFatal` → switch to `discardPusher` for `discardPeriod`. 134 135 `delayPusher` and `discardPusher` are degenerate handlers that share 136 the same `payloadHandler` interface. `delayPusher` forwards `publish` 137 calls to the inner handler so payloads queue while we sleep; 138 `discardPusher` increments `DroppedCounter` and drops payloads on the 139 floor. 140 141 ### Queues 142 143 `queue.go`: 144 145 - One `queue` per stream type (metrics, logs). 146 - Backed by a slice of `queueEntry`. Bytes-based limits; `condition` (a 1-buffered channel) is used to wake the pusher when new data arrives. 147 - `push` loop: 148 - Wait on the condition or `ctx.Done()`. 149 - Snapshot the current records (`get`) and stream them to the remote via `prom.Client.StoreStream` over a `newConcatReader` that concatenates the snappy-encoded records (`snappy_concat.go`). 150 - Classify the result via `parsePublishError` (`errors.go`): 151 - HTTP 200 → success. 152 - HTTP 4xx (except 408 / 422) → fatal. 153 - HTTP 408, 422, 5xx, network error → retriable. 154 - HTTP 429 → returns `errKindWait` to bump the handler to `delayPusher`. 155 - On retriable error: requeue and back off (`backoffer.wait` — exponential, capped at `maxBackoff`). 156 157 `snappy_concat.go` is a small helper that lets the queue treat a 158 series of pre-snappy-encoded buffers as a single stream-of-frames 159 without re-encoding. Don't break the framing without updating both 160 sides. 161 162 ### Failure classification 163 164 `errors.go` defines `pushError{kind, inner}` and the only three kinds: 165 166 - `errKindFatal` — give up entirely (auth, malformed remote URL, unauthorized tenant). 167 - `errKindTenant` — tenant info likely stale, refresh. 168 - `errKindWait` — back off but keep accepting payloads. 169 170 If you add new branches in the response-classification switch, decide 171 which kind they belong to *first* — that's what drives all downstream 172 behaviour. 173 174 ### Tunables 175 176 Defaults live in `options.go` (`defaultPusherOptions`): 177 178 | Option | Default | Purpose | 179 | ------------------- | ------------- | -------------------------------------------------- | 180 | `maxPushBytes` | 64 KiB | Cap on a single remote-write request body. | 181 | `maxQueuedBytes` | 1 MiB | Backpressure ceiling per queue. | 182 | `maxQueuedTime` | 1 h | Drop records older than this from the queue. | 183 | `maxRetries` | 50 | Per-batch retry count. | 184 | `minBackoff`/`max` | 50 ms / 30 s | Exponential back-off bounds. | 185 | `maxLifetime` | 2 h ± 25 % | Force a tenant refresh after this long. | 186 | `maxIdleTime` | 65 min | Tear down handlers that haven't received pushes. | 187 | `tenantDelay` | 10 s | Delay between `GetTenant` retries. | 188 | `waitPeriod` | 1 min | Back-off after 429. | 189 | `discardPeriod` | 15 min | How long fatal errors keep payloads on the floor. | 190 191 These are not currently exposed as flags. If you make them 192 configurable, do it in one place (`pusherOptions`) and document the 193 flag in `cmd.md`. 194 195 ## Metrics 196 197 All counters are tagged with `regionID`, `tenantID`, and `type` 198 (`metrics` or `logs`). See `metrics.go` for the canonical names: 199 200 - `sm_agent_publisher_push_total{regionID, tenantID, type}` 201 - `sm_agent_publisher_push_errors_total{..., status}` 202 - `sm_agent_publisher_push_failed_total{..., reason}` 203 - `sm_agent_publisher_push_bytes{...}` 204 - `sm_agent_publisher_retries_total{...}` 205 - `sm_agent_publisher_drop_total{...}` *(v2 only — registered for v1 too, but always zero)* 206 - `sm_agent_publisher_responses_total{..., status}` *(v2 only — registered for v1 too, but always zero)* 207 - `sm_agent_publisher_handlers_total` *(v2 only — gauge; registered for v1 too, but always zero)* 208 209 `Metrics.WithTenant(localID, regionID)` and `WithType(t)` produce 210 pre-curried sub-vectors so tenant pushers don't pay for label resolution 211 on every increment. 212 213 ## Backing clients 214 215 - `internal/pkg/prom` — Prometheus remote-write client. Used by both v1 and v2. 216 - `internal/pkg/loki` — Loki push client. Used by v1. v2 uses the same `prom.Client` for Loki because the wire format is close enough. 217 - `pusher.ClientFromRemoteInfo(remote)` (`clients.go`) builds a `*prom.ClientConfig` from `sm.RemoteInfo` — appends `/push`, sets the 5s timeout, copies basic-auth credentials, sets `X-Prometheus-Remote-Write-Version: 0.1.0`. The URL-suffix dance is noted as hacky in the source; don't replicate the pattern elsewhere. 218 219 ## Tenant lifecycle interaction 220 221 The Publisher does not own the tenant cache; it consumes it via 222 `TenantProvider`. In practice that's the `tenants.Manager` configured 223 in `cmd/`. A `maxLifetime`-induced refresh is how v2 picks up rotated 224 remote credentials or changed routing — there is no other refresh 225 signal. 226 227 If you change tenant secret rotation timing, also revisit `maxLifetime` 228 and `maxLifetimeJitter`. 229 230 ## Key types and entry points 231 232 | Type / function | File | Notes | 233 | ------------------------------ | ------------------- | ---------------------------------------------- | 234 | `Publisher`, `Payload` | `pusher.go` | Public contract. | 235 | `Factory` | `pusher.go` | Constructor signature. | 236 | `registry[T]`, `NewRegistry` | `registry.go` | Generic name → impl lookup. | 237 | `NewMetrics` | `metrics.go` | Registers all publisher metrics. | 238 | `NewPublisher` (v2) | `v2/publisher.go` | The v2 entry point. | 239 | `publisherImpl.Publish` | `v2/publisher.go` | Per-tenant dispatch. | 240 | `tenantPusher.run` | `v2/tenant_pusher.go` | Per-tenant lifecycle. | 241 | `queue.push` | `v2/queue.go` | Batching + retry loop. | 242 | `parsePublishError` | `v2/errors.go` | HTTP status → `pushError` kind. | 243 244 ## Testing strategy 245 246 - **Unit tests** for the queue (`queue_test.go`), tenant pusher (`tenant_pusher_test.go`), error classifier (`errors_test.go`), snappy concatenation (`snappy_concat_test.go`), and condition primitive (`condition_test.go`). 247 - Tests use **`httptest.Server`** to stand in for remote-write / Loki push endpoints. 248 - The queue tests build sequences of `insert` / `expect` actions to pin batching and retry behaviour — read `queue_test.go` before changing batching constants. 249 - Some tests are gated by `testing.Short()` because they exercise back-off timers. 250 251 Run only the publisher tests: 252 253 ```bash 254 make test-go GO_TEST_ARGS=./internal/pusher/... 255 ``` 256 257 ## When to update this doc 258 259 Update this document when you: 260 261 - Add or remove a Publisher implementation (a new `v3`, or removal of v1). 262 - Change the `Publisher`, `Payload`, `TenantProvider`, or `Factory` contract. 263 - Add or rename a `pushError` kind in `v2/errors.go`. 264 - Add a new state to the per-tenant state machine (a new `payloadHandler` peer to `delayPusher` / `discardPusher`). 265 - Change a default in `defaultPusherOptions` that operators rely on (`maxLifetime`, `maxIdleTime`, `waitPeriod`, `discardPeriod`, `maxRetries`, `minBackoff`/`maxBackoff`). 266 - Change the metric set in `metrics.go`. 267 - Change the response-classification table in `parsePublishError` / `queue.push`. 268 - Touch `ClientFromRemoteInfo` in a way that changes the URL or auth contract.