Monitoring & Observability
How Labs64.IO modules report their health, how to collect that in each way of running the platform, and which alerts tell you that a service is down or that audit events are not arriving.
On this page
- How it works
- What the modules report
- Choose your setup
- One module with Docker Compose
- Full ecosystem on a local cluster
- Your Kubernetes cluster
- AWS with managed services
- Service level objectives
- Is every audit event delivered?
- Cluster health
- Who gets which alert
- Make sure alerts reach someone
- Dashboards to have
- Before you go live
How it works
Monitoring is part of the deployment, not of the application. The modules contain no monitoring vendor’s code. Each one publishes metrics on an HTTP endpoint and writes its logs to standard output. Tracing is switched on from outside, through environment variables the Helm chart sets. The same container image runs with monitoring on or off.
flowchart LR
subgraph modules["Labs64.IO modules"]
M["/actuator/prometheus<br/>metrics"]
L["stdout<br/>logs"]
T["OpenTelemetry agent<br/>traces (optional)"]
end
C["Collector"]
subgraph store["Your backends"]
PM[("Metrics store<br/>Prometheus-compatible")]
LG[("Log store")]
TR[("Trace store")]
end
R["Alert rules"]
AM["Alert manager"]
G["Grafana"]
P(["On-call people"])
M --> C --> PM
L --> C --> LG
T --> C --> TR
PM --> R --> AM --> P
PM & LG & TR --> G
Three things are always true, whichever deployment you run:
| Rule | Why it matters |
|---|---|
Metrics come from a scrape of /actuator/prometheus | Metric names never change when you turn tracing on or off, so dashboards and alerts keep working |
A module’s job label is <namespace>/<module>, for example labs64io/auditflow | The alert rules and dashboards the charts ship select modules by that label. Your collector must set it (see Your Kubernetes cluster) |
| Alerts are evaluated next to the metrics, not in Grafana | Alerting keeps working when nobody has a dashboard open, and when Grafana is down |
What the modules report
| Signal | Source | Always on | Examples |
|---|---|---|---|
| Request metrics | Every Java module, Micrometer | Yes | http_server_requests_seconds_count, _bucket (per status, uri) |
| Runtime metrics | Every Java module | Yes | jvm_memory_used_bytes, process_start_time_seconds |
| Event-flow metrics | AuditFlow | Yes | See the table below |
| Logs | Every container, JSON on stdout | Yes | Error lines, the request ID, the tenant |
| Traces | OpenTelemetry Java agent and Python auto-instrumentation | Only with observability.enabled: true | One trace from the gateway through AuditFlow to its sink |
AuditFlow reports what happens to every event, which is how you can tell whether events are lost:
| Metric | Counts | Labels |
|---|---|---|
auditflow_pipeline_outcomes_total | Pipeline decisions per event; outcome="ENQUEUED" is one delivery queued for a sink | tenant, pipeline, outcome |
auditflow_delivery_outcomes_total | Delivery attempts: delivered, duplicate (already delivered), failures | tenant, pipeline, outcome |
auditflow_delivery_deadlettered_total | Deliveries given up after the retries and moved to the dead-letter queue | tenant, pipeline, reason |
auditflow_delivery_latency_seconds | Time from first enqueue to delivery, retries included. Histogram buckets 1 s, 10 s, 1 min, 5 min, 15 min, 1 h, 3 h | tenant, pipeline |
auditflow_events_quarantined_total | Events AuditFlow could not evaluate: unparseable, or for an unknown or disabled tenant | |
auditflow_tenant_ratelimit_fallback_total | Rate-limit checks done per pod because the shared store was unreachable | |
auditflow_tenant_config_reload_errors_total | Tenant documents that failed to load; the last valid one stays in use |
Choose your setup
| Metrics | Logs | Traces | Dashboards | Alerts | |
|---|---|---|---|---|---|
| Quickstart | Not installed | kubectl logs | No | No | No |
| One module locally | Prometheus | Loki | Tempo | Grafana, AuditFlow dashboard | No |
| Full ecosystem locally | Prometheus | Loki | Tempo | Grafana, preloaded dashboards | No |
| Your Kubernetes cluster | Your Prometheus | Your log store | Optional | Charts ship one per module | Charts ship the SLO rules; you add the rest |
| AWS with managed services | Amazon Managed Service for Prometheus | CloudWatch Logs | Off by default | Amazon Managed Grafana | Prometheus rules and CloudWatch alarms |
The local setups are for seeing what the modules report. They keep data only as long as the containers live, and alert no one. Before you go live, follow one of the last two rows.
One module with Docker Compose
AuditFlow’s repository has an observability overlay: an OpenTelemetry Collector, Prometheus, Loki, Tempo and Grafana, next to the module.
just up obs
# or, without just:
docker compose -f docker-compose.yml -f docker-compose-observability.yml up --build -d
Grafana is at http://localhost:3000 (admin / admin) with an AuditFlow dashboard already loaded. AuditFlow deployment lists the other ports.
Full ecosystem on a local cluster
From the workspace, just up-otel starts the local cluster with the monitoring stack and tracing switched on, then opens Grafana at http://gateway.localhost/grafana/ (the command prints the password). It installs:
| Component | Role |
|---|---|
| OpenTelemetry Collector, one per node | Scrapes the modules’ metrics, reads container logs, receives traces |
| Prometheus (kube-prometheus-stack) | Stores metrics and evaluates the charts’ SLO rules |
| Loki | Stores logs |
| Tempo | Stores traces |
| Grafana | Dashboards: ecosystem overview, cluster, Traefik, authentication, JVM, AuditFlow, Checkout, the collector itself |
Alertmanager is not installed locally, so rules can fire but notify no one. Look at the Alerts page of Grafana or Prometheus instead.
Your Kubernetes cluster
You run the monitoring stack, and the charts plug into it. The order below is what makes the shipped rules and dashboards work.
flowchart TB
A["1. Install Prometheus with the operator<br/>(kube-prometheus-stack)"] --> B["2. Install a collector that scrapes<br/>annotated pods and sets job"]
B --> C["3. Install the module charts<br/>with observability enabled"]
C --> D["4. Add the AuditFlow event-flow rules"]
D --> E["5. Route alerts by severity"]
E --> F["6. Prove that alerts arrive"]
1. Prometheus with the operator. Install the Prometheus operator before the module charts, for example with kube-prometheus-stack. Each module chart renders its PrometheusRule only when the monitoring.coreos.com/v1 API exists at install time. A module installed earlier needs a helm upgrade once the operator is in. Make Prometheus pick up rules from every release (ruleSelectorNilUsesHelmValues: false in kube-prometheus-stack), and enable Alertmanager.
2. A collector. It must:
- scrape pods annotated
prometheus.io/scrape: "true", atprometheus.io/portandprometheus.io/path; - set
jobto<namespace>/<app.kubernetes.io/name>with a relabel rule, which the shipped rules depend on; - for traces, accept OTLP on port 4318 of each node, since the charts point the agent at
http://$(NODE_IP):4318.
The chart repository’s overrides/opentelemetry/values-collector.local.yaml (labs64.io-helm-charts) does all three for the upstream opentelemetry-collector chart, running one collector per node. Point its exporters at your backends. A Prometheus PodMonitor with the same relabeling works for the metrics too.
3. The module charts. Set observability.enabled: true on every module. It adds the scrape annotations and, for traces, attaches the agent. Keep slo.enabled: true (the default) to get the SLO rules and a Grafana dashboard per module: a ConfigMap labelled grafana_dashboard: "1", which the Grafana sidecar of kube-prometheus-stack loads.
| Chart value | Default | Effect |
|---|---|---|
observability.enabled | false | Scrape annotations, the OpenTelemetry agent and its environment |
observability.otlpEndpoint | http://$(NODE_IP):4318 | Where traces and logs are sent |
slo.enabled | true | PrometheusRule with the SLO rules, plus the dashboard ConfigMap |
slo.availability.targetRatio | 0.999 | Share of requests without a 5xx |
slo.latency.targetRatio | 0.99 | Share of requests faster than the threshold |
slo.latency.thresholdSeconds | 0.5 | The latency threshold |
4. AuditFlow event-flow rules. The charts ship request SLOs only. Add the rules below to know whether events arrive.
5. Route alerts by severity (below).
6. Prove that alerts arrive (below).
AWS with managed services
On AWS you can avoid running Prometheus, Loki and Grafana yourself: each part of the stack has a managed service.
flowchart LR
subgraph eks["EKS cluster"]
MOD["Modules<br/>/actuator/prometheus"]
K8S["Kubernetes API<br/>pods, nodes, HPAs"]
COL["Metrics collector<br/>1 replica"]
CWA["CloudWatch add-on<br/>Fluent Bit, Container Insights"]
CAN["Canary CronJob<br/>every 5 min"]
end
subgraph aws["AWS managed services"]
AMP[("Amazon Managed Service<br/>for Prometheus<br/>rules + alert manager")]
CW[("CloudWatch<br/>metrics, logs, alarms")]
SVC["ALB, WAF, SQS, RDS,<br/>ElastiCache, NAT, Lambda"]
SNS["SNS topics<br/>by severity"]
AMG["Amazon Managed Grafana"]
end
MOD -- scrape --> COL
K8S --> COL
COL -- "remote write, SigV4" --> AMP
CWA -- "logs, node metrics" --> CW
CAN -- "OK / FAIL log lines" --> CWA
SVC -- "native metrics" --> CW
AMP -- alerts --> SNS
CW -- alarms --> SNS
SNS --> OUT(["Mail, pager, Slack"])
AMP & CW --> AMG
| Part | Service | Notes |
|---|---|---|
| Application metrics | Amazon Managed Service for Prometheus | The collector configuration is overrides/opentelemetry/values-collector.aws.yaml in labs64.io-helm-charts: one replica, metrics only, writing with SigV4 under an IAM role for its service account. Load the same rules as on your own cluster into the workspace’s rule groups. |
| Cluster state (pods pending, node pressure, HPA at maximum) | The same collector, k8s_cluster receiver | A few series per pod and node. That is far cheaper than Container Insights’ enhanced mode, whose control-plane records make up most of its log volume. |
| Logs | CloudWatch Logs, through the amazon-cloudwatch-observability add-on (Fluent Bit) | Give the log groups a retention before the add-on creates them, or they never expire. |
| AWS services | CloudWatch alarms | Load balancer 5xx and healthy targets, WAF blocks, queue depth and age, dead-letter queues, database connections, cache memory, NAT errors, certificate expiry. |
| Alert delivery | SNS | The Prometheus alert manager on AWS can only send to SNS; CloudWatch alarms send there too. Use one topic per severity (below). For Slack, AWS Chatbot subscribes to a topic. |
| Dashboards | Amazon Managed Grafana | One workspace reads both the Prometheus workspace and CloudWatch, with sign-in through IAM Identity Center. Keep dashboards as JSON files and load them through the Grafana API, so a change in the UI is not the only copy. |
| Traces | Not collected by default | Tracing services bill per trace or span. Turn it on for an investigation, not permanently. |
Service level objectives
Each module chart defines two objectives over 30 days:
- Availability: 99.9% of requests answered without a 5xx.
- Latency: 99% of requests answered within 0.5 s.
The rules alert on how fast the error budget is being spent, not on single bad minutes. The budget is the 0.1% (or 1%) of requests that may fail. An alert needs a long and a short window both above the same burn rate. The long window shows that the problem matters; the short one shows it is still going on, so the alert also resolves soon after the problem stops.
| Alert | Burn rate | Windows | Budget spent | Severity |
|---|---|---|---|---|
AvailabilityBudgetBurnFast, LatencyBudgetBurnFast | 14.4× | 1 h and 5 min | 2% in an hour | critical |
AvailabilityBudgetBurnSlow | 6× | 6 h and 30 min | 5% in six hours | critical |
LatencyBudgetBurnSlow | 6× | 6 h and 30 min | 5% in six hours | warning |
AvailabilityBudgetBurnTicket | 3× | 1 d and 2 h | 10% in a day | warning |
The rules also record the error ratio per window (slo:availability:error_ratio_rate1h, slo:latency:slow_ratio_rate6h, and so on). Chart those, so a dashboard and its alert always agree.
Is every audit event delivered?
A request SLO says whether AuditFlow answers. It does not say whether events reach their sinks. These are the places an event can be lost, and what notices:
flowchart LR
P["Publisher"] -->|"4xx"| X1["Rejected:<br/>publisher drops it"]
P -->|"2xx"| A["Accepted"]
A -->|"unparseable,<br/>unknown tenant"| Q["Quarantine"]
A --> E["Enqueued per pipeline"]
E -->|"sink OK"| D["Delivered"]
E -->|"sink fails"| R["Retried for up to 24 h"]
R --> D
R -->|"gives up"| DLQ["Dead-letter queue"]
E -.->|"no outcome at all"| U["Unaccounted"]
| Where | Alert on |
|---|---|
| Rejected at the door (400, 401, 403, 413) | More than 1% of publishes answered with that status |
| Quarantined | Any increase of auditflow_events_quarantined_total |
| A sink keeps failing | Under 90% of delivery attempts succeed for 15 minutes, per tenant and pipeline |
| Dead-lettered | Any new auditflow_delivery_deadlettered_total. On Amazon SQS, also the age of the oldest dead-letter message: SQS deletes it after 14 days |
| Late | Deliveries later than 5 minutes, as an objective: 99% within 5 minutes |
| Lost without an error | Deliveries enqueued 3 to 9 hours ago that were neither delivered nor dead-lettered |
| Nothing flows at all | A synthetic event published every few minutes that does not reach a sink |
These rules cover all of it except the synthetic event. Replace labs64io with your namespace:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: auditflow-event-flow
namespace: labs64io
spec:
groups:
- name: auditflow-event-flow
rules:
- alert: AuditFlowDeliveryFailing
expr: |
sum by (tenant, pipeline) (rate(auditflow_delivery_outcomes_total{job="labs64io/auditflow", outcome="delivered"}[15m]))
/ sum by (tenant, pipeline) (rate(auditflow_delivery_outcomes_total{job="labs64io/auditflow"}[15m])) < 0.9
for: 15m
labels: { severity: critical }
annotations:
summary: "Less than 90% of deliveries of {{ $labels.tenant }}/{{ $labels.pipeline }} succeed"
- alert: AuditFlowDeadLettered
# The counter starts at 1, so the second operand catches a series that is new.
expr: |
sum by (tenant, pipeline, reason) (increase(auditflow_delivery_deadlettered_total{job="labs64io/auditflow"}[10m])) > 0
or sum by (tenant, pipeline, reason) (auditflow_delivery_deadlettered_total{job="labs64io/auditflow"}
unless auditflow_delivery_deadlettered_total{job="labs64io/auditflow"} offset 10m) > 0
labels: { severity: critical }
annotations:
summary: "Deliveries of {{ $labels.tenant }}/{{ $labels.pipeline }} dead-lettered ({{ $labels.reason }})"
- alert: AuditFlowQuarantined
expr: sum(increase(auditflow_events_quarantined_total{job="labs64io/auditflow"}[10m])) > 0
labels: { severity: warning }
annotations:
summary: "Events quarantined: unparseable, or the tenant is not routable"
- alert: AuditFlowPublishRejected
expr: |
sum by (status) (rate(http_server_requests_seconds_count{job="labs64io/auditflow", uri=~"/audit/publish.*", status=~"4..", status!="429"}[10m]))
/ scalar(sum(rate(http_server_requests_seconds_count{job="labs64io/auditflow", uri=~"/audit/publish.*"}[10m]))) > 0.01
for: 10m
labels: { severity: warning }
annotations:
summary: "More than 1% of publishes answered {{ $labels.status }}: those events are dropped"
# Freshness: 99% of deliveries within 5 minutes (bucket le="300.0"), as error-budget burn rates.
- record: auditflow:delivery_late:ratio_rate5m
expr: 1 - (sum(rate(auditflow_delivery_latency_seconds_bucket{job="labs64io/auditflow", le="300.0"}[5m])) / sum(rate(auditflow_delivery_latency_seconds_count{job="labs64io/auditflow"}[5m])))
- record: auditflow:delivery_late:ratio_rate30m
expr: 1 - (sum(rate(auditflow_delivery_latency_seconds_bucket{job="labs64io/auditflow", le="300.0"}[30m])) / sum(rate(auditflow_delivery_latency_seconds_count{job="labs64io/auditflow"}[30m])))
- record: auditflow:delivery_late:ratio_rate1h
expr: 1 - (sum(rate(auditflow_delivery_latency_seconds_bucket{job="labs64io/auditflow", le="300.0"}[1h])) / sum(rate(auditflow_delivery_latency_seconds_count{job="labs64io/auditflow"}[1h])))
- record: auditflow:delivery_late:ratio_rate6h
expr: 1 - (sum(rate(auditflow_delivery_latency_seconds_bucket{job="labs64io/auditflow", le="300.0"}[6h])) / sum(rate(auditflow_delivery_latency_seconds_count{job="labs64io/auditflow"}[6h])))
- alert: AuditFlowFreshnessBudgetBurnFast
expr: auditflow:delivery_late:ratio_rate1h > (14.4 * 0.01) and auditflow:delivery_late:ratio_rate5m > (14.4 * 0.01)
for: 2m
labels: { severity: critical }
annotations:
summary: "Deliveries later than 5 minutes spend the freshness budget 14.4x too fast"
- alert: AuditFlowFreshnessBudgetBurnSlow
expr: auditflow:delivery_late:ratio_rate6h > (6 * 0.01) and auditflow:delivery_late:ratio_rate30m > (6 * 0.01)
for: 2m
labels: { severity: warning }
annotations:
summary: "Deliveries later than 5 minutes spend the freshness budget 6x too fast"
# Conservation: over six hours that ended three hours ago (retries run up to 3 h apart),
# every enqueued delivery is delivered, a duplicate, or dead-lettered.
- record: auditflow:deliveries_enqueued:increase6h_3h_ago
expr: sum by (pipeline) (increase(auditflow_pipeline_outcomes_total{job="labs64io/auditflow", outcome="ENQUEUED"}[6h] offset 3h))
- record: auditflow:deliveries_settled:increase6h_3h_ago
expr: |
sum by (pipeline) (increase(auditflow_delivery_outcomes_total{job="labs64io/auditflow", outcome=~"delivered|duplicate"}[6h] offset 3h))
+ (sum by (pipeline) (increase(auditflow_delivery_deadlettered_total{job="labs64io/auditflow"}[6h] offset 3h))
or 0 * sum by (pipeline) (increase(auditflow_delivery_outcomes_total{job="labs64io/auditflow"}[6h] offset 3h)))
- alert: AuditFlowDeliveriesUnaccounted
expr: |
(auditflow:deliveries_enqueued:increase6h_3h_ago - auditflow:deliveries_settled:increase6h_3h_ago) > 10
and (auditflow:deliveries_enqueued:increase6h_3h_ago - auditflow:deliveries_settled:increase6h_3h_ago)
/ auditflow:deliveries_enqueued:increase6h_3h_ago > 0.02
for: 30m
labels: { severity: warning }
annotations:
summary: "Pipeline {{ $labels.pipeline }}: deliveries neither delivered nor dead-lettered"
The counters of the conservation check drift by a fraction of a percent at the window’s edges, hence the 2% and 10-delivery thresholds.
Events lost before AuditFlow are invisible to it: a browser that never sends, or a publisher that gives up while AuditFlow is down. Count failed publishes in the publisher and alert there.
Cluster health
Alert on these whatever your setup. kube-prometheus-stack ships comparable rules (KubePodNotReady, KubeNodePressure, KubeHpaMaxedOut); on AWS, the collector’s k8s_cluster receiver provides the series for the first three.
| Alert | Fires when | Severity |
|---|---|---|
| Pods pending | A pod has been Pending for 30 minutes | warning |
| Node under pressure | A node reports memory, disk or PID pressure for 10 minutes; pods are being evicted | warning |
| Module at maximum replicas | An HPA has been at its maximum for 15 minutes and cannot scale out | warning |
| Container restarting | process_start_time_seconds changed more than once in 30 minutes | warning |
| Metrics missing | No scrape of a module for 15 minutes (absent(up{job="labs64io/<module>"} == 1)) | warning |
Who gets which alert
Give every rule a severity label, and route on it:
| Severity | Means | Send to |
|---|---|---|
critical | Events are being lost, or a service is down for its users | Someone who will wake up: a pager or phone, plus the team channel |
warning | Something will become a problem, or needs a look today | Team channel and mail |
flowchart TB
A["Alert"] --> W{"Watchdog?"}
W -->|yes| HB["Heartbeat receiver only"]
W -->|no| CH["Team channel"]
CH --> S{"severity = critical?"}
S -->|yes| PG["Pager"] --> ML["Mail"]
S -->|no| ML
Group by alertname and job, and repeat an unresolved alert every few hours rather than every few minutes. Watch one event in one place: if a CloudWatch alarm already watches a dead-letter queue, don’t add a Prometheus alert on the same events. Otherwise every incident arrives twice, and people learn to ignore both.
If an environment is switched off at night to save cost, mute its alerts for that window (Alertmanager mute_time_intervals), or every night pages someone.
Make sure alerts reach someone
A rule that fires into a mailbox no one reads, or through an alert manager that has stopped, protects nothing. Three checks close that gap:
| Check | How |
|---|---|
| Synthetic event | A CronJob gets a token and publishes one event through the public endpoint every 5 minutes. Alert when it fails or stops running, and when its events stop reaching a sink. This covers the gateway, identity provider, AuditFlow and the sinks, end to end. |
| Dead man’s switch | An alert that always fires (expr: vector(1), named Watchdog), routed every 10 minutes to an external heartbeat service. When the pings stop, that service alerts from outside your infrastructure. It is the only alert that still works when the alert manager, the notification service or the cloud account is broken. |
| Alarm drill | Before go-live, after changing alert routing, and twice a year: make one warning and one critical alert fire, and check that each arrives where it should. Nothing else proves that a subscription is confirmed and that a mail filter doesn’t swallow it. |
Dashboards to have
| Dashboard | Shows |
|---|---|
| Overview | Firing alerts, SLOs and error-budget burn per module, request rate and latency |
| AuditFlow event flow | Accepted, rejected, delivered, dead-lettered, quarantined and unaccounted events; delivery delay (p50, p95, share within 5 minutes); the synthetic event; sink circuit breakers |
| Gateway | Request rate, latency and status codes per route |
| Brokers and queues | Depth and age of the oldest message; dead-letter depth and age |
| Data stores | Connections, memory, CPU, backup status |
| Cluster | Nodes, pods pending, restarts, HPA replicas |
The charts’ per-module dashboards cover the request part of the overview. Build the rest from the recorded series (slo:*, auditflow:*), so a panel shows exactly what its alert evaluates.
Before you go live
- Metrics from every module arrive with
job="<namespace>/<module>", and the SLO rules are loaded. - The AuditFlow event-flow rules are loaded, with severities.
- Critical alerts reach a person out of hours, and warnings reach the team.
- The synthetic event runs, and the dead man’s switch is green at the heartbeat service.
- An alarm drill has been run, and every alert it raised arrived.
- Logs have a retention, and dashboards that show tenant data are restricted to the people allowed to see it.
Correlate an incident with X-Request-ID and the tenant. Keep dashboards that everyone can see free of event content: they show counts, not payloads.