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.