Observing

What the running mailer tells you in Grafana on kind: a request’s trace in Tempo, its log records in Loki, the mailer’s, humus’s and RabbitMQ’s metrics in Mimir, the alert rules in deploy/alerts.yaml, and the dashboard in deploy/dashboard.json.

Opening Grafana

up.sh generates Grafana’s admin password into the grafana-admin Secret. Read it, then forward Grafana’s port:

kubectl --context kind-kind --namespace mailer get secret grafana-admin \
  --output jsonpath='{.data.password}' | base64 --decode
kubectl --context kind-kind --namespace mailer port-forward service/grafana 3000:3000

Sign in at http://localhost:3000 as admin.

A request’s trace

Publish a request with mailrequest, as Deploying shows, and note the MessageId it prints. In Explore, choose Tempo and find the request’s trace by it:

{resource.service.name="mailer" && span.messaging.message.id="<MessageId>"}
SpanWhat it is
process mail.sendthe delivery, a child of the span that published the request
POSTeach attempt at the provider, under process mail.send
send mail.eventsthe mail.sent or mail.refused event
send mail.retrythe request, parked for another attempt
send mail.deadthe dead letter

The worker puts the attempt on the process span:

span := trace.SpanFromContext(ctx)
attempt, err = attemptOf(m)
if err != nil {
	span.SetAttributes(attemptKey.Int(1))
	return outcomeMalformed, 1, w.deadLetter(ctx, m, malformed, 1, err)
}
span.SetAttributes(attemptKey.Int(attempt))

And the outcome, once it is decided:

trace.SpanFromContext(ctx).SetAttributes(outcomeKey.String(outcome))

A parked request’s next process mail.send is a child of the send mail.retry that parked it, so every attempt at a request is in one trace.

Its log records

Tempo links each span to its records in Loki, by trace ID:

  - name: Tempo
    uid: tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        filterByTraceID: true
        spanStartTimeShift: -5m
        spanEndTimeShift: 5m
      tracesToMetrics:
        datasourceUid: mimir

On process mail.send, Logs for this span opens them. In Explore, with Loki, the same query is:

{service_name="mailer"} | trace_id="<trace ID>"

The worker writes one record per processing: mail request <outcome>, with messaging.message.id, mail.attempt and mail.outcome. A request it gave up on, or a malformed one, is a warning:

severity, text := log.SeverityInfo, "INFO"
if outcome == outcomeGaveUp || outcome == outcomeMalformed {
	severity, text = log.SeverityWarn, "WARN"
}

Its metrics

The mailer’s own instrument counts requests by mail.outcome: sent, refused, retried, gave_up or malformed:

requests, err := settings.Telemetry.MeterProvider.Meter(scope).Int64Counter("mail.requests",
	metric.WithDescription("Mail requests processed, by outcome."),
	metric.WithUnit("{request}"))

In Mimir, a name’s dots are underscores, and service.name is job:

sum by (mail_outcome) (rate(mail_requests{job="mailer"}[5m]))

humus records its own:

InstrumentRecorded by
messaging.client.consumed.messages, messaging.process.durationthe Service, for each delivery
messaging.client.sent.messages, messaging.client.operation.durationthe three Publishers
humus.circuit.state, humus.circuit.calls, humus.circuit.transitions, humus.retry.decisionsthe provider’s client, scope github.com/z5labs/humus/httpclient, and each Publisher, scope github.com/z5labs/humus/queue/rabbitmq

The Collector reads each queue’s depth and consumers from RabbitMQ’s management API, and the containers’ restarts from Kubernetes:

  rabbitmq:
    endpoint: http://rabbitmq:15672
    username: collector
    password: ${env:RABBITMQ_PASSWORD}
    collection_interval: 10s
  k8s_cluster:
    auth_type: serviceAccount
    collection_interval: 10s

Its login may read and write nothing, and is tagged monitoring, which the management API needs:

login collector '^$' '^$' '^$'
rabbitmqctl set_user_tags collector monitoring
rabbitmq_message_current{rabbitmq_queue_name="mail.send", state="ready"}
rabbitmq_consumer_count{rabbitmq_queue_name="mail.send"}
k8s_container_restarts{k8s_container_name="mailer"}

The alerts

deploy/alerts.yaml holds Grafana-managed alert rules, in the folder Mailer. The kind overlay provisions them:

  - name: grafana-alerting
    files:
      - ../alerts.yaml
AlertSeverityWhen
MailBacklogpagemail.send has held more than 1000 ready messages for 10 minutes
MailNotConsumingpagenothing has consumed mail.send for 5 minutes
MailParkedpagemail.send.dead isn’t empty
MailGivingUppagethe mailer gave up on a request in the last 15 minutes
MailRetryingticketthe mailer has been parking requests for 10 minutes
MailProviderBreakerOpenpagethe provider client’s breaker has been open for 5 minutes
MailRetriesThrottledpagea client’s retries were throttled in the last 5 minutes
MailCrashLoopingpagea mailer container restarted more than 3 times in 15 minutes

MailBacklog reads mail.send only, since a request in mail.send.retry waits there on purpose.

Each rule is a query of Mimir and a threshold:

      - uid: mail-parked
        title: MailParked
        condition: B
        data:
          - refId: A
            relativeTimeRange:
              from: 600
              to: 0
            datasourceUid: mimir
            model:
              refId: A
              instant: true
              expr: sum(rabbitmq_message_current{rabbitmq_queue_name="mail.send.dead"}) or vector(0)
          - refId: B
            datasourceUid: __expr__
            model:
              refId: B
              type: threshold
              expression: A
              conditions:
                - evaluator:
                    type: gt
                    params: [0]
        noDataState: OK
        execErrState: Error
        for: 0s
        labels:
          severity: page
        annotations:
          summary: mail.send.dead holds {{ $values.A.Value }} requests.
          description: Each one is a request the mailer couldn't send, with why in its x-mail-dead-reason header.

A counter’s rule adds the series that first appeared in its window, which increase alone reads as no change:

              expr: (sum(increase(mail_requests{job="mailer", mail_outcome="gave_up"}[15m])) or vector(0)) + (sum(mail_requests{job="mailer", mail_outcome="gave_up"} unless mail_requests{job="mailer", mail_outcome="gave_up"} offset 15m) or vector(0))

The dashboard

deploy/dashboard.json is the Mailer dashboard, at http://localhost:3000/d/mailer. The kind overlay provisions it from a file, so it can’t be changed in Grafana:

apiVersion: 1
providers:
  - name: mailer
    orgId: 1
    folder: Mailer
    type: file
    disableDeletion: true
    allowUiUpdates: false
    options:
      path: /etc/grafana/dashboards
PanelWhat it shows
Queue depthmail.send, mail.send.retry and mail.send.dead, by state
Consumers of mail.sendhow many mailers are consuming
Requests by outcomemail.requests, as a rate
Provider breakerthe provider client’s breaker, by state
Provider retry decisionsthe provider client’s retries, by decision
Restartseach mailer container’s restarts

Each panel is a query of Mimir:

      "targets": [
        {
          "refId": "A",
          "datasource": {
            "type": "prometheus",
            "uid": "mimir"
          },
          "expr": "sum by (mail_outcome) (rate(mail_requests{job=\"mailer\"}[$__rate_interval]))",
          "legendFormat": "{{mail_outcome}}"
        }
      ]