Follow

TYPO3 etcd exporter

Prometheus metrics for the deployment status stored in etcd

The TYPO3 deployments kept their state in etcd — stopped, deploying, deployed, installing, installed, starting, started. Useful, but invisible unless you query etcd by hand.

This exporter reads that state per project and stage, including required versus running services, and exposes it as Prometheus metrics — so dashboards and alerts could finally answer "is the deploy done, and is everything up?".


FactDetail
Sourceetcd keys for project + stage
OutputPrometheus metrics
TracksDeployment status, required vs. running services
StackNode (express, node-etcd, prom-client)
StartedFebruary 2018

What's in it

Status as a number

Each deployment state maps to an ordered value, so alerts can fire on "stuck before installed".

One endpoint, all projects

Environment-driven configuration keeps it reusable across projects and stages.

In detail

What is actually inside it

Required vs. running

TYPO3_SERVICES and TYPO3_REQUIRED_SERVICES are read separately, so the exporter can tell "this container is still starting" from "this deployment is missing a service it declared".

Environment-shaped

PROJECTKEY, STAGE, and an etcd endpoint built from MACHINEID: one exporter container serves every project and stage without per-project code.

Ordered states as the design

Mapping an enum to a monotonically increasing number is a small decision that makes dashboards and alert rules dramatically simpler.

DetailValue
Sourceetcd keys per project + stage
OutputPrometheus metrics (express + prom-client)
Statesstopped → deploying → deployed → installing → installed → starting → started
StartedFebruary 2018

Technical notes

The decisions behind it

Why expose etcd state through Prometheus at all?
Because the deployment state was already correct — it just was not visible. The exporter did not change how deploys worked; it made them observable, which is what stopped people polling manually.
What did alerts look like?
Rules like "status stuck below installed for 15 minutes" — possible because the status is a scale rather than a set of labels.

Reading the state

One key per service, one endpoint per machine

The state lives in etcd as one path per service, and exactly three environment values appear in it:

/ruhmesmeile/projects/typo3/${TYPO3_STAGE}/${TYPO3_PROJECTKEY}/status/${service}/current

The value of that key is the state name. The TYPO3_STAGE segment started as a hardcoded review literal and became an environment variable in commit 6f086fc5. The endpoint is derived as well: https://${MACHINEID}.etcd.services.ruhmesmeile.local:2379. An earlier revision targeted ${MACHINEID}.ttt.roles.addresses.services.ruhmesmeile.local and was changed because the hostname has to be one the client certificate was issued for; the certificate is read at process start from three fixed paths — /etc/ssl/etcd/ca.pem, /etc/ssl/etcd/calculonc.pem, /etc/ssl/etcd/calculonc-key.pem — so a single project's certificate name sits inside the exporter that serves every project.

TYPO3_SERVICES and TYPO3_REQUIRED_SERVICES do different jobs. A service in TYPO3_SERVICES gets a watcher on current plus one initial GET, and therefore a metric. A service that is only required is seeded in etcd instead: current = stopped is written with prevExist: false, then a sibling stopped marker key is touched. That precondition is why a project whose keys already exist logs a 412 on every exporter restart instead of having its state reset. One asymmetry is easy to miss: a service listed only in TYPO3_REQUIRED_SERVICES gets an etcd path but no series, because the export loop iterates TYPO3_SERVICES alone.

StateValue
`stopped`0
`deploying`1
`deployed`2
`installing`3
`installed`4
`starting`5
`started`6

The metric contract

Two series, no project label

prom-client gets exactly two series. typo3_current_status is a Gauge labelled service only, set to the ordinal from the table above. typo3_status_counter is a Counter labelled service and status. The scale is a public contract rather than an internal detail: the deploying and deployed states were added on 2018-03-03 and every later value shifted up by two, so work that had encoded installing = 1 was afterwards reading deploying. Ordering is what makes the numbers cheap to alert on; it is also what makes insertions expensive.

The increment is conditional, so the counter means less than its name suggests. Before counting, the exporter fetches the sibling key .../status/<service>/<state> and only proceeds if that key exists — the marker keys are the deploy side's record that a state was reached, and the exporter creates none of them itself apart from the stopped seed for required services. The counter therefore counts status changes the pipeline left a marker for, not transitions observed; and because it lives in the process, it restarts at zero with the container while the etcd keys survive. Its help text still reads Counter for number of status changes, timestamped, although the timestamp was dropped on 2018-03-20: the marker key's value used to be passed as a timestamp argument (set(value, ts) / inc(1, ts), seconds × 1000) and the leftover callback parameter is still named timestamp, now unused.

Neither metric carries the project, stage or machine. Series identity comes from the Prometheus target, which forces a scrape config per deployment; the payoff is that alerts stay one line. Because the scale is monotone and the pipeline walks it in order, "is this deploy progressing?" is a comparison rather than a state check: typo3_current_status{service="typo3"} < 4 held for fifteen minutes covers every stall between deploying and installed without naming them. The cost is that 0 / stopped is both the healthy idle value and the value a deploy that never got anywhere sits at, so no instantaneous rule can separate idle from broken — only duration can.

Where it bites

Failure modes of a 116-line exporter

What happens when etcd returns a state the scale does not know?
STATUS[getStatusName(value)] is undefined, and Gauge.set(undefined) in prom-client 10.2.2 throws TypeError: Value is not a valid number: undefined. The throw happens inside an etcd callback, there is no try/catch and no uncaughtException handler, so the exporter process exits and the container restarts — taking every counter with it. The only guard is getStatusName(): a value containing stopped or installing is folded back onto those two names, everything else is looked up verbatim.
Does the watcher survive an etcd outage?
node-etcd's watcher retries on its own: on a connection error it emits reconnect and re-watches after 2^attempts * 300 ms plus jitter, resuming from the last seen modifiedIndex, and the 401 resync path handles an index the cluster no longer has. The exporter subscribes only to change, so none of that is visible — during a reconnect the gauge silently keeps its last value, and a dead watch looks exactly like a steady deployment. node-etcd also emits error for an unexpected response, and an error event with no listener on an EventEmitter is a thrown exception, which is another restart.
Is shutdown graceful?
No. The SIGTERM handler calls server.close(...), but server is never assigned — app.listen(PORT, HOST) discards its return value — so the handler dies on ReferenceError: server is not defined before watchers.forEach(... stop()) runs, the intended process.exit(0) is never reached and the process ends with exit code 1. The same file has no defaults for its configuration: process.env.TYPO3_SERVICES.split(',') runs at load, so an unset variable is a boot-time crash, and the value "typo3, mail" produces a service name with a leading space whose key lookups all fail and are only logged.