Observability and triage¶
This page maps symptoms to the component that decides the behaviour. It lists the health signals each component exposes, where to read them, and how to triage cross-layer failures. User actions: end-user troubleshooting. Platform procedures: sys-admin troubleshooting. The UI surfaces (Site view tabs, administration pages) are in 18-hub-web-interface.
This document uses the same path shortcuts as
22: hub/.../ is hub/src/ecmwf/sites/hub/,
core/.../ is core/src/ecmwf/sites/core/.
Hub health endpoints¶
Platform health: GET /hub/api/v2/health/¶
Platform administrator credentials only (/hub/api/v1/ mirrors the route).
The endpoint always answers HTTP 200; the health verdict is in the
body, never in the status code. It checks three subsystems and returns
JSON {status, message, data} with data.redis, data.kubernetes, and
data.nfs:
redis: cache round-trip probe (a get of a non-existent cache key), with the configured URL andresponse_time, orstatus: NOKwith the error.kubernetes: API reachability probe, with the namespace andresponse_time.nfs: a latency probe on the platform data directory. Thecheck_storage_and_elapsed_timehelper (core/.../misc/storage_utils.py) submitsos.listdirto a dedicated single-worker daemon-thread executor and returns(elapsed, timed_out). It measures read latency, not write staleness. The timeout ishub.health_timeout_seconds(default 5.0);?deep=truewaits unbounded for diagnostics. A timeout reportstimed_out: trueand the status set byhub.health_timeout_status(WARNby default — informational;ERRORflips the overall health).mountcarries the/proc/mountsline for/sites-data, ornull.
With Accept: text/plain or Accept: application/nagios, the same data
returns as a Nagios plugin string
(core/.../service/opsview.py):
<message> - Redis = <S>, Kubernetes = <S>, NFS = <S> | redis_response_time=<t>, kubernetes_response_time=<t>, nfs_response_time=<t>, nfs_timed_out=<b>
Monitoring systems that key on HTTP status cannot use this endpoint; key
on the body or the Nagios prefix instead. overall_sites_health is the
status-code-friendly alternative below.
Sites health: GET /hub/api/v2/sites/health/¶
Platform administrator credentials only. The Hub probes every enabled
and operationally monitored Site (spec.configuration.settings.monitored)
in parallel through the site health path below, with a shared 45-second
pool timeout. The body lists {url, status, response} per Site. If any
monitored Site is unhealthy, the endpoint deliberately returns
HTTP 500 with Some operational sites are currently down! — the only
Hub API endpoint that fails on purpose, so a plain status-code monitor can
watch it. Unmonitored Sites never affect it.
Read-only state¶
POST /hub/api/v2/read-only/ (platform administrator) toggles the state in
the sites-platform-state ConfigMap and answers System ReadOnly set to
<bool>.. There is no GET endpoint; read the state with
kubectl get cm sites-platform-state, or observe the badge on the
administration index page and the flash on the lists
(22). The ConfigMap also holds the in-band
platform configuration; never edit either key by hand
(13).
Route status¶
HTTPRoute resources report controller acceptance in
status.parents conditions; a networking Ingress reports through
status.loadBalancer and status.conditions instead. Check them when
traffic does not reach the
Hub or a Site (14). The Hub pod itself has no HTTP
readiness probe; an initializer failure shows in the init container
(08 — Hub pod initialisation).
Site health signals¶
| Signal | Path | Decides |
|---|---|---|
| Go API health | GET /<space>/<site>/s/api/v2/health/ (through the proxy) |
503 when storage is at 100% or ≥98%, or when the Site's own loopback probe of s/healthz fails; 200 otherwise. Usage at 80/90/95% only changes the message text. The body reports storage (total/free/used, used_percent, free_percent) and nginx stub-status data, with Cache-Control: no-cache, no-store (site/.../applications/api/api/health.go) |
| Proxy stub status | GET /<space>/<site>/s/healthz |
NGINX stub_status; access_log off; allowed to loopback (the Site API probe) and to trusted_ips; denied to all other sources (proxy/nginx/health.conf) |
| Site metrics | GET /s/metrics (through the proxy) |
Prometheus text rendered by the Lua metrics module. The location exists only when the metrics integration is configured; with metrics disabled, metrics.conf renders empty and is not included (proxy/nginx/metrics.conf, proxy/app_config.py). The client IP is the last (rightmost) X-Forwarded-For address (set_real_ip_from 0.0.0.0/0, real_ip_recursive off) and must be a trusted IP |
| Hub-side site health | GET /hub/api/v2/sites/<space>/<site>/health/ |
Site administrators only. Proxies the Go health payload with the Site master token and adds data.retention_date_expired. An archived Site short-circuits with OK and The site '<path>' is archived. The route is deliberately excluded from audit logging |
| Site status | GET /hub/api/v2/sites/<space>/<site>/status/ |
Site administrators only. Accept: application/json returns the SiteStatus fields; Accept: text/html returns the hub/conditions.html fragment the Site view re-renders. Result cached while conditions are started |
The Site metrics families are sites_http_requests_total
(labels site_path, status, method, handler), request- and
response-size and duration histograms, sites_http_authenticated_requests_total
(labels site_path, user, handler; the user label is truncated at 32
characters), sites_http_4xx_total, and sites_http_5xx_total
(proxy/lua/metrics.lua). The handler label maps the path prefix to
api, admin, webdav, or web. Requests to /s/metrics and
/s/healthz are not counted. The proxy log_format main line is what the
analytics integrations parse (17). In that line, the
request and referer fields contain REDACTED in place of the signed URL
signature value and the session URL token. The Site API request log also
redacts the signature value, and the OAuth2 Proxy request log does not
record the request URI. Use the proxy access log to find the path of a request.
Pod probes¶
| Workload | Readiness | Liveness |
|---|---|---|
| Hub Deployment | none | tcpSocket on the Hub port: start delay 30 s, timeout 5 s, period 30 s, failure threshold 5 (helm/ecmwf-sites/templates/deployment-hub.yaml) |
| Operator Deployment | none | none — a wedged operator is not restarted by the kubelet; detect stalls from stale Site status and from operator logs |
| Redis Deployment | redis-cli ping: start delay 10 s, timeout 5 s, period 30 s, failure threshold 5 |
tcpSocket: start delay 40 s, timeout 5 s, period 30 s, failure threshold 5 |
| Site proxy, API, admin, WebDAV, S3, custom containers | — | tcpSocket on the container port |
| Site oauth2-proxy container | — | HTTP GET /ping on port 4180 |
Status written to the Site resource¶
The operator writes a condensed status into the Site Custom Resource Definition (CRD)
(core/.../kubernetes/utils.py:resolve_pod_status): a single condition
(the first failed condition, or the last true one, with
last_transition_time), all_steps_started, phase (a synthetic
Disabled phase stands in when the Site is archived; container problems
override the pod phase), start_time, message, reason, and
containers (one item per waiting, terminated, or running-but-unready
container, with name, kind, reason and message; an unready running
container reports kind: running with reason: NotReady). The top-level
message and reason exist
on the model but the operator's pod-status writer never fills them, so they
stay empty on current resources. All fields are plain text, with no HTML.
The Hub template escapes them and adds the formatting. The field site_enabled is
not stored; the Hub data access object (DAO) injects it when reading, because enabled is a
spec field (09). Nothing in the
control loop consumes the stored status beyond
SiteStatus.are_all_pods_running(); it exists for display and for
kubectl get site -o yaml.
Logs and audit¶
- Operator logs prefix events with
[EVENT],[CREATE],[UPDATE],[RESUME], and[PATCH](persistent volume (PV) patch path,operator/.../site_create.py). Update summaries log changed field paths at INFO and the full diff at DEBUG;operator/.../logging_policy.pymasks secret-bearing values and keeps key names only. - Hub audit entries use a dedicated log level and the format
username=<user>;action=<action>;resource=<detail>. The@audit.log_accessdecorator additionally recordsaction=accesswithmethod,endpoint, sanitized formdata,remote_addr, andreferrer(core/.../misc/audit.py). Six high-frequency API routes omit the decorator and produce noaction=accesslines: the Site status poll and the user, group, site-owner, site-group, and application lookups (08 — Application assembly). AMaskingFilterandsanitize_structmask tokens, cookies, and secret-shaped strings before anything is written (core/.../misc/logging_filter_utils.py). - Hub and operator pod logs are readable without kubectl:
GET /hub/api/v2/logs/?name=<sites-hub|sites-operator>&limit=<n>(platform administrator; defaults to the Hub pods). - Site pod logs:
GET /hub/api/v2/sites/<space>/<site>/logs/?name=<APPLICATION>&limit=<n>(Site administrator). The response maps container name to a log block; multi-pod apps prefix chunks with[pod-name]:. The Operations tab console calls this endpoint (22). - Bulk upgrade failures are searchable in the Hub logs:
Bulk forced upgrade failednames each Site the bulk action could not update; each forced upgrade writes an auditaction=upgradeline (12).
Platform metrics¶
GET /hub/metrics/ serves Prometheus text only while the metrics
integration is active; otherwise it answers 503. The exporter
(core/.../integrations/metrics/prometheus.py) renders
siteshub_sites_total and siteshub_http_requests_total; the request
counter is an after_request hook registered when the provider is
configured (hub/.../server.py).
The endpoint label of siteshub_http_requests_total is the Flask route
template (request.url_rule.rule), for example
/hub/api/v2/sites/<space>/<name>, and never the request path. A request
that matches no route (404, or 405 for a method that is not allowed) gets
the fixed label <unmatched>. The number of label values is therefore
limited by the number of Hub routes, and a client cannot add time series
with random paths. The routes that end in /healthz, /metrics, or /ping
are not counted.
The path is blocked at the edge in both routing modes: the ingress minion
adds location = /hub/metrics/ { deny all; }, and the gateway HTTPRoute
redirects /hub/metrics* to the 403 error page. Monitoring must scrape the
sites-hub Service directly.
Inside the cluster, the NetworkPolicy limits the Hub port to the routing,
monitoring, and metrics-gateway namespaces (SEC-29). The endpoint has no
authentication by default, because Prometheus scrapes it without a
credential; the payload contains only site totals by state and type, and
request counts by method, route template, and status class, with no site
names, request paths, users, or secrets. Set hub.metrics_scrape_token (env
HUB_METRICS_SCRAPE_TOKEN) to require a bearer token; when it is set, the
route answers 401 without the token, and serviceMonitor.hub.bearerTokenSecret
points the scrape at the Secret key that holds it. The generated metrics are
cached for a few seconds, so the full site listing runs at most once per
scrape interval.
When serviceMonitor.enabled is set, the chart creates Prometheus Operator
ServiceMonitors: the Site monitor scrapes Service port 4180 at
/s/metrics (the oauth2-proxy container lets that route through
authentication), and the Hub monitor scrapes port 8080 at
/hub/metrics/. Site scraping needs the monitoring namespace in
networkPolicy.monitoringNamespace and the scraper IP in trusted_ips.
Integration probes¶
The Dependencies page renders the provider probe results and refreshes them in a background thread behind a non-blocking lock, so the page never waits on an integration. The provider contract and the graceful-degradation rules are in 13 — Probes and status. Probe state also gates UI features (the analytics sections and the request modals, 22).
Triage table¶
| Symptom | Deciding component | First check |
|---|---|---|
| Hub does not load | Routing or Hub pods | Hub pods; route resources and their status |
| Hub health check reports failure but HTTP status is 200 | Health body, not status | Read the JSON body or the Nagios prefix; the endpoint never signals by status code |
nfs sub-status WARN / timed_out: true |
Platform data mount latency | Re-run with ?deep=true to measure the real latency; decide with hub.health_timeout_status; check the NFS/SMB server |
GET /hub/api/v2/sites/health/ returns 500 |
One or more monitored Sites | The body lists each Site URL with its status; follow the failing Site below |
Site URL 302 → /hub/error/page/410/ |
Operator route | Site is archived (spec.configuration.enabled: false) — owner toggle or retention job |
| Site URL or API 404 on a public site | Go API public router | Path missing or .htpasswd-hidden (10); they are indistinguishable by design |
401/403 JSON from /s/api/... |
Lua auth chain | Token type/expiry; role (site-admin vs staff); chain order in 10 |
| Site health JSON answers 503 | Site storage or proxy | storage.used_percent (≥98% or 100%) or the nginx section; probe s/healthz from the pod |
| 405 on a Hub write | Read-only mode | sites-platform-state key read-only; POST /hub/api/v2/read-only/ toggles it |
Upload 503 with Retry-After |
Go upload buffer | Storage stall; treat as retryable (11) |
| 507 on upload | Quota / volume | Site usage via health endpoint; persistent volume claim (PVC) size equals quota |
| 504 on a Site URL | Ingress or gateway | The Site proxy maps 403/404/405/410/500/502/503 to Hub error pages, not 504 — a 504 never originates from the Site proxy path; check ingress/gateway timeouts |
| Change not visible on a site | Operator | Operator logs; did a restart-triggering property change? were pods restarted? |
Site stuck in Pending/Unknown phase |
Pods | Site status.containers[] carries the waiting/terminated reason and message per container (status.reason itself stays empty); kubectl describe pod; operator [EVENT] lines |
Site status stops changing |
Operator Deployment | The operator has no probes; check its pod logs and restart it manually |
/s/metrics 404 or absent |
Metrics integration | Disabled integrations render no metrics.conf location; also check the scraper IP against trusted IPs (last XFF address) |
| ServiceMonitor scrapes nothing | Chart values | serviceMonitor.enabled, namespace selector, networkPolicy.monitoringNamespace, trusted_ips; the Hub target must bypass the edge and use the Service |
| Site unreachable after a routing-mode switch | Upgrade order | Site not upgraded yet — its route still belongs to the old mode (14) |
| Route exists but traffic fails (gateway mode) | Gateway controller | kubectl get httproutes -o yaml → status.parents conditions; networkPolicy.gatewayNamespace |
Hub or job logs 403 Forbidden from the Kubernetes API |
role-based access control (RBAC) | ServiceAccount role bindings; chart-managed RBAC (02) |
Common failure modes¶
- CRD drift. The chart installs the Site CRD but never upgrades it. The
Hub or operator rejects a schema the CRD does not know. Apply
helm show crds oci://... | kubectl apply -f -before upgrading (sys-admin: upgrades). - Missing Secrets.
sites-secrets,oidc-secrets, or the referenced TLS Secret missing in the release namespace causes CrashLoops or routing failures. - RBAC changes outside the chart. Hand-edited Roles are out of sync with
the chart-managed bindings; compare against
helm/.../templates/rbac-*.yaml. - Stale branding or configuration on a site. Expected until the site's next update (16).
- Released PersistentVolumes. After deletion the PV survives as
Released; clean it from the Hub administration pages. It never rebinds automatically. - Site not upgrading. A site already at the platform application
versions can do no work on an ordinary upgrade. Use Force Upgrade All
Sites or
POST /hub/api/v2/sites/upgrade/?force=trueto regenerate configuration and routes (14). To force one site, usePOST /hub/api/v2/sites/<space>/<site>/upgrade/?force=true. Search the Hub logs forBulk forced upgrade failedto find sites that a bulk upgrade did not update. - Monitoring against the wrong signal. The Hub health endpoint always
returns 200; the Site CRD
statusis display data, not a control signal; and/hub/metrics/is 503 whenever the metrics integration is off. Wire status-code monitors toGET /hub/api/v2/sites/health/and to the Service endpoints, not to the browser routes.
Escalation checklist¶
Escalate to the platform team when the Hub is down for everyone, the operator is not reconciling, data loss is suspected, or 5xx errors repeat. Include: space and site name, timestamps, exact endpoint and status code, and relevant log or event snippets.
Behaviour requirements¶
| ID | Requirement | Implemented in | Verified by |
|---|---|---|---|
| OBS-01 | The site health endpoint reports 503 only for storage pressure or a failed proxy probe, never for content errors. | site/.../api/api/health.go |
— |
| OBS-02 | Operator log summaries never contain Secret values. | operator/.../logging_policy.py |
operator/tests/test_secret_logging.py |
| OBS-03 | GET /hub/api/v2/health/ answers HTTP 200 even when unhealthy; the verdict is in the body, and the Accept header selects JSON or the Nagios plugin format. |
hub/.../app/api/common.py (overall_health), core/.../service/opsview.py |
core/tests/test_storage_utils.py (Nagios strings) |
| OBS-04 | The nfs sub-status measures the elapsed time of a directory listing on a bounded thread; the timeout is hub.health_timeout_seconds, ?deep=true bypasses it, and hub.health_timeout_status decides WARN versus ERROR. |
core/.../misc/storage_utils.py, hub/.../app/api/common.py |
core/tests/test_storage_utils.py |
| OBS-05 | GET /hub/api/v2/sites/health/ returns HTTP 500 only when an enabled, operationally monitored Site is unhealthy. |
hub/.../app/api/common.py (overall_sites_health), core/.../misc/requests_utils.py (health_response_from_site) |
— |
| OBS-06 | The s/healthz stub-status location always allows loopback and the configured trusted IPs, and denies every other source. |
site/.../proxy/nginx/health.conf |
site/tests/test_proxy_app_config.py |
| OBS-07 | With the metrics integration disabled, metrics.conf renders empty and the proxy configuration contains no /s/metrics location. |
site/.../proxy/nginx/metrics.conf, site/.../proxy/app_config.py |
site/tests/test_proxy_app_config.py |
| OBS-08 | The Lua metrics module renders the sites_http_* families with a handler label and never counts /s/metrics or /s/healthz requests. |
site/.../proxy/lua/metrics.lua |
— |
| OBS-09 | When serviceMonitor.enabled, the chart creates ServiceMonitors that scrape Sites at /s/metrics on port 4180 and the Hub at /hub/metrics/ on port 8080. The Hub scrape can send a bearer token: set serviceMonitor.hub.bearerTokenSecret to the Secret key that holds HUB_METRICS_SCRAPE_TOKEN. |
helm/ecmwf-sites/templates/servicemonitor.yaml |
core/tests/test_helm_configuration.py (test_service_monitor_hub_bearer_token; creation and labels only; ports and paths are not asserted) |
| OBS-10 | Public requests to /hub/metrics are denied at the edge in both routing modes (ingress deny; gateway redirect to the 403 page); monitoring scrapes the Hub Service directly. |
helm/ecmwf-sites/templates/ingress-minion.yaml, helm/.../httproute-hub.yaml |
— (the templates carry the rule; no test asserts it) |
| OBS-11 | /hub/metrics/ returns Prometheus text only while the metrics integration is active; otherwise it returns 503. |
hub/.../app/hub/views.py (metrics), core/.../integrations/metrics/prometheus.py |
— |
| OBS-12 | Audit entries carry username, action, and resource; access entries add method, endpoint, sanitized data, and remote address; secret-shaped values are masked before writing. |
core/.../misc/audit.py, core/.../misc/logging_filter_utils.py |
— |
| OBS-13 | GET /hub/api/v2/logs/ returns Hub or operator pod logs for platform administrators (name=sites-hub or name=sites-operator). |
hub/.../app/api/common.py (hub_logs) |
— |
| OBS-14 | Site pod logs are readable per application by Site administrators through GET …/logs/?name=&limit=, keyed by container and prefixed per pod. |
hub/.../app/api/common.py (sites_logs), core/.../kubernetes/sites_dao.py |
— |
| OBS-15 | The operator stores a condensed Site status (single condition, all_steps_started, phase, start_time, message, reason); site_enabled is injected on read and never stored; no control flow depends on the stored status beyond are_all_pods_running(). |
core/.../kubernetes/utils.py (resolve_pod_status), core/.../kubernetes/sites_dao.py, core/.../model/site.py |
— |
| OBS-16 | The Hub-side site health endpoint adds retention_date_expired to the site payload, short-circuits archived sites with OK, and is excluded from audit logging. |
hub/.../app/api/common.py (sites_health), hub/.../app/api/v2/views.py |
— |
| OBS-17 | The operator Deployment defines no readiness or liveness probes; a stalled operator is not restarted automatically. | helm/ecmwf-sites/templates/deployment-operator.yaml |
— |
| OBS-18 | The endpoint label of siteshub_http_requests_total is the matched route template, or <unmatched> when no route matches, so client-chosen paths cannot create new time series; routes that end in /healthz, /metrics, or /ping are not counted. |
core/.../integrations/metrics/prometheus.py (http_requests_inc) |
core/tests/test_prometheus_metrics.py |