Observability — metrics, health checks, and structured logging¶
The enterprise server exposes production observability so you can monitor certificate issuance, HSM health, and request behavior, and wire it into Prometheus / Grafana and a Kubernetes (or load-balancer) health-check setup.
Three things are provided:
- A Prometheus
/metricsendpoint (text exposition format). /healthz(liveness) and/readyz(readiness, including an HSM connectivity probe) endpoints.- Structured JSON request logging with a per-request correlation ID that is also stored on the access log and the tamper-evident audit events.
All three are always on — there is nothing to enable in config.yaml.
For OpenTelemetry distributed tracing (OTLP spans across the request,
CA signing, HSM, CRL/OCSP, pre-issuance gates, and store paths, correlated with
the request log via trace_id/span_id), see docs/operations/tracing.md. It
is opt-in and off by default.
Endpoints¶
| Path | Auth | Purpose |
|---|---|---|
GET /metrics |
none | Prometheus metrics in text exposition format |
GET /healthz |
none | Liveness: the process is up and serving HTTP |
GET /readyz |
none | Readiness: DB reachable and HSM/key-provider reachable |
These endpoints are unauthenticated by design: monitoring systems scrape them
before any user context exists, and they expose no secrets, key material, or
per-subject data. If your threat model requires it, restrict them at the network
layer (e.g. only allow your Prometheus/kubelet source ranges to reach
/metrics, /readyz), since the server is expected to sit behind a
TLS-terminating proxy anyway.
Liveness vs readiness¶
/healthzreturns200as long as the process can serve HTTP. It does not check the database or HSM, so a transient dependency outage does not cause an orchestrator to kill an otherwise-healthy process./readyzreturns200only when every checked dependency is healthy, otherwise503. Use it to gate traffic (readiness probe / load-balancer health check) so an instance that cannot issue certificates stops receiving requests.
/readyz response body (healthy):
{
"status": "ready",
"components": {
"database": { "status": "up" },
"hsm": { "status": "up" },
"leadership": { "status": "leader", "detail": "mode=postgres" }
}
}
The leadership component reports which replica runs the singleton background
jobs (see multi-replica coordination). It is
informational only: a "follower" replica is fully ready — it serves all
API traffic — so leadership never turns the probe 503. In single-node
deployments it reads "leader" with mode=static.
When a dependency is down the overall status becomes not_ready, the HTTP
status is 503, and the failing component carries an error:
{
"status": "not_ready",
"components": {
"database": { "status": "up" },
"hsm": { "status": "down", "error": "logging in: pkcs11: 0xA0: CKR_PIN_INCORRECT" }
}
}
The HSM probe goes through the key-provider (keyprovider.Prober): for the
PKCS#11 backend it loads the module, finds the configured token, opens a session,
and logs in with the user PIN — the exact steps a signing operation performs,
minus the key lookup. A healthy result therefore means the HSM can sign right
now. The software backend reports up when its keystore directory is accessible.
A provider with no probe capability is reported as skipped and does not fail
readiness.
The probe is bounded by a short timeout (3 s) so a hung backend cannot stall the health check.
Metrics¶
/metrics is the standard Prometheus text format. All series are prefixed
secsy_. Labels are kept low-cardinality (the HTTP route label is the matched
route pattern, e.g. GET /api/ca/{id}/issue, never the raw path with its
embedded IDs/serials).
| Metric | Type | Labels | Meaning |
|---|---|---|---|
secsy_http_requests_total |
counter | method, route, status |
HTTP requests handled |
secsy_http_request_duration_seconds |
histogram | method, route |
HTTP request latency |
secsy_http_requests_in_flight |
gauge | — | Requests currently being served |
secsy_certificates_total |
counter | operation (issue/renew/revoke), result (success/error/denied) |
Certificate lifecycle operations |
secsy_ocsp_requests_total |
counter | result (success/error/malformed) |
OCSP responder requests |
secsy_crl_requests_total |
counter | result |
CRL distribution requests |
secsy_hsm_operations_total |
counter | operation (sign/decrypt/generate/find/public_key/ping), result |
Key-provider operations |
secsy_hsm_operation_duration_seconds |
histogram | operation |
Key-provider (HSM) operation latency |
secsy_envelope_operations_total |
counter | operation (encrypt/decrypt), result |
HSM-backed envelope encryption operations |
secsy_authz_decisions_total |
counter | action, decision (allow/deny) |
RBAC authorization decisions |
secsy_event_stream_subscribers |
gauge | — | Operators currently connected to the live audit-event SSE feed (GET /api/events/stream) |
secsy_event_stream_connections_total |
counter | — | Total live audit-event SSE subscriptions opened since startup |
secsy_event_stream_dropped_total |
counter | — | Audit events dropped from a slow SSE subscriber's ring buffer (subscriber lagged; the feed favors liveness over completeness) |
secsy_component_up |
gauge | component (database/hsm) |
Last readiness-probe result (1 = up) |
secsy_leader_is_leader |
gauge | — | 1 on the replica leading the singleton background jobs, 0 on followers (see high availability) |
secsy_leader_transitions_total |
counter | to (leader/follower) |
Leadership gains/losses observed by this replica |
secsy_certificates_expiring |
gauge | severity (warning/critical/expired) |
Certs in each expiry window as of the last monitor scan |
secsy_certificate_auto_renewals_total |
counter | result (success/error) |
Certificates auto-renewed by the expiry monitor |
secsy_certificate_monitor_scans_total |
counter | result |
Expiry-monitor scan cycles |
secsy_certificate_monitor_last_scan_timestamp_seconds |
gauge | — | Unix time of the last completed monitor scan |
Histogram buckets are in seconds and span the sub-millisecond to multi-second range suited to HSM and HTTP operations.
Sample Prometheus scrape config¶
scrape_configs:
- job_name: secsy-pki
metrics_path: /metrics
scheme: https # the server runs behind TLS in production
# tls_config:
# ca_file: /etc/prometheus/secsy-ca.pem
static_configs:
- targets: ["pki.example.com:8443"]
# Optional: scrape more often than the default while validating.
scrape_interval: 15s
If you terminate TLS at a proxy and scrape plaintext internally, set
scheme: http and point targets at the internal address. To keep /metrics
private, restrict it at the proxy/network layer to your Prometheus source.
Kubernetes probes¶
livenessProbe:
httpGet: { path: /healthz, port: 8443, scheme: HTTPS }
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet: { path: /readyz, port: 8443, scheme: HTTPS }
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
With Prometheus scraping in-cluster, the pod annotations equivalent to the scrape config above are:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/port: "8443"
prometheus.io/scheme: "https"
Packaged dashboard & alerting rules¶
Ready-to-use observability assets ship in the repo (single source of truth in
the Helm chart, with convenience symlinks under deploy/observability/):
| Asset | Path |
|---|---|
| Grafana dashboard JSON | deploy/helm/secsy-pki/files/grafana-dashboard.json (also deploy/observability/grafana/secsy-pki-dashboard.json) |
| Prometheus alerting rules | deploy/helm/secsy-pki/files/prometheus-rules.yaml (also deploy/observability/prometheus/secsy-pki-rules.yaml) |
| Prometheus SLO recording rules + burn-rate alerts | deploy/helm/secsy-pki/files/prometheus-slo-rules.yaml (also deploy/observability/prometheus/secsy-pki-slo-rules.yaml) — see SLOs and error budgets |
The dashboard (uid: secsy-pki-overview) covers issuance rate/latency, HSM
session-pool saturation and queueing, OCSP/CRL request rates and derived cache
hit ratio, rate-limit 429/503 counts, and expiry-monitor + audit-export health.
It uses two template variables: a Prometheus datasource and a multi-select
job filter.
Import — standalone Prometheus + Grafana¶
- Grafana: Dashboards → New → Import → Upload JSON file and select the dashboard JSON, then pick your Prometheus datasource. (Or provision it via a file-provider entry.)
- Prometheus: add the rules file to
rule_files:and reload:
rule_files:
- /etc/prometheus/secsy-pki-rules.yaml
- /etc/prometheus/secsy-pki-slo-rules.yaml # SLO recording rules + burn-rate alerts
Validate before shipping — check both rule files, and run the SLO unit test:
promtool check rules deploy/observability/prometheus/secsy-pki-rules.yaml \
deploy/observability/prometheus/secsy-pki-slo-rules.yaml
promtool test rules deploy/helm/secsy-pki/files/prometheus-slo-rules.test.yaml
Deploy — Helm (Prometheus Operator / kube-prometheus-stack)¶
The chart renders an optional PrometheusRule and a Grafana-sidecar dashboard
ConfigMap, both off by default:
# values.yaml
serviceMonitor:
enabled: true # scrape /metrics (Task 19)
prometheusRule:
enabled: true
labels:
release: kube-prometheus-stack # MUST match your Prometheus ruleSelector
grafanaDashboard:
enabled: true
sidecarLabel: grafana_dashboard # whatever your Grafana sidecar watches
The rule bodies are embedded verbatim from files/prometheus-rules.yaml, so the
Helm and standalone deployments never drift. Add site-specific rules via
prometheusRule.additionalGroups without editing the shipped file. The
error-budget SLOs (files/prometheus-slo-rules.yaml) render as a second
PrometheusRule, <release>-slo, gated by prometheusRule.slo.enabled (default
true when prometheusRule.enabled) — see SLOs and error budgets.
Recommended alert thresholds¶
The shipped rules default to the values below. Tune them to your fleet size and CRL/monitor cadence; the rationale and response steps live in the operator runbook.
| Alert | Condition (default) | Severity | Tune when |
|---|---|---|---|
SecsyPKITargetDown |
scrape up == 0 for 3m |
critical | — |
SecsyPKIHSMProbeDown |
secsy_component_up{component="hsm"}==0 for 2m |
critical | — |
SecsyPKIHSMPoolExhausted |
secsy_hsm_guard_queue_depth > 0 for 10m |
warning | expected bursty queueing |
SecsyPKIHSMGuardShedding |
guard-reject rate > 0.1/s for 5m |
critical | — |
SecsyPKIHSMSignLatencyHigh |
p99 sign latency > 2s for 10m |
warning | slow network-HSM baseline |
SecsyPKICertificatesExpired |
secsy_certificates_expiring{severity="expired"} > 0 for 15m |
critical | — |
SecsyPKIExpiryBacklog |
…{severity="critical"} > 25 for 1h |
warning | scale to fleet size |
SecsyPKIMonitorStalled |
last scan older than 36h | warning | match monitor.intervalHours |
SecsyPKIAutoRenewFailing |
auto-renew error rate > 0 for 30m |
warning | — |
SecsyPKIOCSPErrorRateHigh |
OCSP error ratio > 5% for 10m |
warning | — |
SecsyPKICRLServingErrors |
CRL error rate > 0 for 10m |
warning | — |
SecsyPKICRLNotRegenerating |
no base-CRL signing in 25h while served | warning | match crl.baseValidityHours |
SecsyPKIRateLimitThrottleSpike |
throttled fraction > 30% for 10m |
warning | expected abuse baseline |
SecsyPKIAuditExportLagHigh |
lag > 5000 events for 15m |
warning | sink throughput |
SecsyPKIAuditExportStalled |
backlog + no ack in 30m | critical | — |
SecsyPKINoJobLeader |
fleet-wide max(secsy_leader_is_leader) == 0 for 10m |
warning | — |
SecsyPKILeadershipFlapping |
> 6 leadership transitions in 30m |
warning | — |
SecsyPKICanaryFailing |
any secsy_canary_failures_total increase in 15m, for 5m |
critical | — |
SecsyPKICanaryStalled |
no successful canary probe in 1h | warning | match canary.interval_minutes |
CRL/delta staleness caveat:
SecsyPKICRLNotRegeneratingis a best-effort cadence check — the metrics expose no CRLnextUpdatetimestamp. For an authoritative freshness SLO, additionally blackbox-probe the CDP URL and alert on the CRL'snextUpdate(see the runbook).
SLOs and error budgets¶
Beyond the operational threshold alerts above, the chart ships a set of
Google-SRE-style service-level objectives with multi-window,
multi-burn-rate alerts. They live in a separate single-source file,
files/prometheus-slo-rules.yaml (symlinked to
deploy/observability/prometheus/secsy-pki-slo-rules.yaml), and render as a
second PrometheusRule (<release>-slo) gated by prometheusRule.slo.enabled
(default true when prometheusRule.enabled).
Each SLO is defined as an SLI recording rule — the fraction of bad events (errors, or requests slower than the objective) over a rolling window — plus two burn-rate alerts:
- Fast / page (
severity: critical) — burn ≥ 14.4× the error budget over both the 1h and 5m windows. At 14.4× a 30-day budget is exhausted in ~2 days, so it pages. - Slow / ticket (
severity: warning) — burn ≥ 6× over both the 6h and 30m windows: a slower leak worth a ticket before it becomes a page.
Requiring a long and a short window prevents both false pages (a brief spike
that already recovered fails the long window) and slow detection (the short
window confirms the burn is still happening). SLI recording rules are named
job:slo_<sli>:ratio_rate<window> and computed for the four windows the alerts
consume (5m, 30m, 1h, 6h). Each ratio is 0/absent under healthy or
no-traffic conditions — the bad-event count is the numerator and the total is
clamped to a tiny positive floor — so an idle server never produces a false burn.
| SLO (objective) | SLI recording rule (…:ratio_rate1h) |
Fast alert — page | Slow alert — ticket |
|---|---|---|---|
| Certificate issuance ≥ 99.5% success | job:slo_issuance_errors |
SecsyPKIIssuanceErrorBudgetBurnFast |
SecsyPKIIssuanceErrorBudgetBurnSlow |
| OCSP responder ≥ 99.9% success | job:slo_ocsp_errors |
SecsyPKIOCSPErrorBudgetBurnFast |
SecsyPKIOCSPErrorBudgetBurnSlow |
| CRL distribution ≥ 99.9% success | job:slo_crl_errors |
SecsyPKICRLErrorBudgetBurnFast |
SecsyPKICRLErrorBudgetBurnSlow |
| ACME order-finalize ≥ 99% within 2.5s | job:slo_acme_finalize_latency |
SecsyPKIACMEFinalizeLatencyBudgetBurnFast |
SecsyPKIACMEFinalizeLatencyBudgetBurnSlow |
| HSM signing ≥ 99% within 1s | job:slo_hsm_sign_latency |
SecsyPKIHSMSignLatencyBudgetBurnFast |
SecsyPKIHSMSignLatencyBudgetBurnSlow |
| HSM signing ≥ 99.9% success | job:slo_hsm_sign_errors |
SecsyPKIHSMSignErrorBudgetBurnFast |
SecsyPKIHSMSignErrorBudgetBurnSlow |
Every burn alert carries slo: <name> and service: secsy-pki labels and a
runbook_url into SLO error-budget burn.
Freshness (OCSP pre-sign / static-publish staleness, CRL regeneration) is a
threshold objective, not an error-budget one — a staleness gauge has no
good/total events to burn — so its SLIs are surfaced as recording rules
(job:slo_ocsp_presign_freshness_seconds:max,
job:slo_publish_freshness_seconds:max,
job:slo_crl_base_regenerations:increase24h) and alerted by the threshold rules
in the operational ruleset (SecsyPKIOCSPPresignStale, SecsyPKIPublishStale,
SecsyPKICRLNotRegenerating).
Tuning. Every alert threshold is written burn_rate * error_budget (e.g.
14.4 * 0.005 for the 99.5% issuance budget). To change an objective, edit the
budget factor in that SLO's fast and slow expressions — the recording rules
are objective-independent. The ACME-finalize SLI matches
route="POST /acme/order/{id}/finalize" (the default acme.directory_path of
/acme); update the matcher if you relocate the directory. The latency
thresholds (le="2.5", le="1") must stay on a histogram bucket boundary
(DefBuckets: … 0.5, 1, 2.5, 5, 10).
Testing. The rules ship with a promtool unit test that drives each SLI to a
known burn rate and asserts the right alert fires — including that a slow-only
burn (between 6× and 14.4×) tickets but does not page:
Grafana dashboard notes¶
Prefer the packaged dashboard above. To build custom panels, the metrics are standard Prometheus types — start from these queries:
Issuance overview
- Issuance rate by result:
sum by (result) (rate(secsy_certificates_total{operation="issue"}[5m])) - Renewals / revocations:
sum by (operation) (rate(secsy_certificates_total{operation=~"renew|revoke"}[5m])) - Error ratio (single stat / gauge):
sum(rate(secsy_certificates_total{result="error"}[5m])) / sum(rate(secsy_certificates_total[5m]))
HSM health & latency
- HSM signing p95 latency:
histogram_quantile(0.95, sum by (le,operation) (rate(secsy_hsm_operation_duration_seconds_bucket{operation="sign"}[5m]))) - HSM error rate by operation:
sum by (operation) (rate(secsy_hsm_operations_total{result="error"}[5m])) - HSM up (state timeline / stat):
secsy_component_up{component="hsm"}
Revocation serving
- OCSP request rate by result:
sum by (result) (rate(secsy_ocsp_requests_total[5m])) - CRL fetches:
sum(rate(secsy_crl_requests_total[5m]))
API traffic
- Request rate by route/status:
sum by (route,status) (rate(secsy_http_requests_total[5m])) - Request p95 latency by route:
histogram_quantile(0.95, sum by (le,route) (rate(secsy_http_request_duration_seconds_bucket[5m]))) - In-flight requests:
secsy_http_requests_in_flight
Security / governance
- Authorization denials (spot brute-forced or misconfigured access):
sum by (action) (rate(secsy_authz_decisions_total{decision="deny"}[5m])) - Envelope encrypt/decrypt rate:
sum by (operation,result) (rate(secsy_envelope_operations_total[5m]))
Suggested alerts
| Alert | Expression (for) |
|---|---|
| HSM down | secsy_component_up{component="hsm"} == 0 for 1m |
| Database down | secsy_component_up{component="database"} == 0 for 1m |
| Issuance failures | rate(secsy_certificates_total{result="error"}[10m]) > 0 for 10m |
| HSM signing slow | histogram_quantile(0.95, sum by (le)(rate(secsy_hsm_operation_duration_seconds_bucket{operation="sign"}[5m]))) > 1 for 10m |
| Authz denial spike | sum(rate(secsy_authz_decisions_total{decision="deny"}[5m])) > 1 for 10m |
Structured request logging & correlation¶
Every HTTP request emits one JSON log line to stdout:
{"time":"2026-07-02T05:01:44.241Z","level":"info","msg":"http_request",
"request_id":"1160eabb-9106-4cc9-96e7-e14ef6aa66d3","method":"GET",
"route":"GET /api/ca/{id}/issue","path":"/api/ca/abc/issue","status":201,
"duration_ms":42.7,"bytes_out":1834,"remote_ip":"10.0.0.5","user_agent":"lego/4"}
Fields are deliberately limited to non-sensitive request metadata — method,
matched route, path (never the query string), status, latency, response size,
client IP, and user agent. Request/response bodies, the Authorization and
Cookie headers, basic-auth credentials, and query strings are never logged,
so no secrets or key material reach the log.
Request IDs¶
- A correlation ID is assigned to every request. If the client sends an
X-Request-IDheader with a safe value (≤128 chars,[A-Za-z0-9._-]), it is honored; otherwise a UUID is generated. Unsafe inbound values are rejected and replaced (no header/log injection). - The ID is echoed back in the
X-Request-IDresponse header. - The same ID is written to the access log (
GET /api/access-log) and to the tamper-evident audit events (GET /api/events) produced while serving the request, as arequest_idfield.
This lets you pivot from a request log line to the audit trail (and vice versa)
by a single ID. The request_id on audit events is correlation metadata only
and is deliberately excluded from the event hash chain, so it never affects
tamper-evidence and remains backward-compatible with logs written before this
field existed.
Consuming JSON logs: pipe stdout to your log shipper (Loki, Fluent Bit, etc.). With Grafana + Loki you can link a metric spike to the exact requests via
{job="secsy-pki"} | json | request_id="…".
See also¶
- RBAC, audit logging & config — the event log the request IDs correlate to.
- Certificate authority — the issuance/OCSP/CRL operations the metrics count.
- HSM configuration — the key-provider the readiness probe and HSM metrics measure. ```