Skip to main content
RunBook Academy

← All runbooks in Observability

low riskinformational~30 min

Runbook: Add a Scrape Target

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The endpoint answers from the Prometheus host, not from your workstation: curl -sf http://TARGET/metrics | head -20. The scrape is made by the server, so only the server's view of the network decides whether this works
  • · The exposition body parses: curl -sf http://TARGET/metrics -o /tmp/new.prom && promtool check metrics /tmp/new.prom. One malformed line fails the entire scrape, not just that line
  • · The series count you are about to ingest is known: grep -cv "^#" /tmp/new.prom, multiplied by the number of targets in the job. This is the number that has to fit in the head block
  • · Current head size and headroom are known: query prometheus_tsdb_head_series and compare it against the documented budget for this server. At roughly 3-8 KiB of resident memory per active series on 2.55.x, a 200k-series addition is a memory decision, not a config edit
  • · The job the target belongs in has been decided from its SLA class, not from its exporter type: scrape_interval, scrape_timeout and sample_limit are per job, and they are the only place those decisions can be recorded
  • · It is known whether the running server was started with --web.enable-lifecycle. Without it, POST /-/reload returns 403 and the reload has to be a SIGHUP
  • · The configuration file currently on disk is committed and passing CI, so the reload you are about to trigger carries only your change and not somebody else's half-finished edit

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Confirm the endpoint from the Prometheus host and capture the body: curl -sf http://TARGET/metrics -o /tmp/new.prom — if this fails, stop; Prometheus will fail in exactly the same way and tell you less about it
  2. 2Validate the body against the exposition contract: promtool check metrics /tmp/new.prom. Fix the exporter before adding the job; a scrape that fails to parse produces up of 0 and no partial data
  3. 3Count the series and size the limit: grep -cv "^#" /tmp/new.prom. Set sample_limit at roughly three times the steady-state count so an exporter upgrade trips a guard rather than an eviction
  4. 4Edit the configuration in the repository, never on the server. Add the target to an existing job if it shares that job's interval and SLA; create a new job if it does not
  5. 5Set the job's safety nets explicitly: sample_limit, and body_size_limit where the exporter is not one you control. The defaults are 0, which means unlimited
  6. 6Validate offline before anything is reloaded: promtool check config /etc/prometheus/prometheus.yml must print SUCCESS
  7. 7Confirm the target resolves as you expect: promtool check service-discovery /etc/prometheus/prometheus.yml JOBNAME prints the final label set and the scrape URL for every target the job will produce
  8. 8Deploy the file to the server through the normal configuration path, and confirm the file on the server is the file you reviewed
  9. 9Reload: curl -sf -X POST http://localhost:9090/-/reload, or systemctl reload prometheus where the unit sends SIGHUP
  10. 10Prove the reload was accepted, do not assume it: prometheus_config_last_reload_successful must be 1. On a parse failure Prometheus keeps the previous configuration running and reports success to nobody
  11. 11Confirm the target is being scraped: read health and lastError for the new instance in /api/v1/targets?state=active
  12. 12Confirm the data is the data you wanted: query one metric name from the new target and check that instance and job are the values you intended
  13. 13Record the addition where the cardinality budget lives, with the measured series count. An addition nobody wrote down is an addition nobody can subtract later

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓up for the new instance is 1, and lastError for it is empty
  • ✓prometheus_config_last_reload_successful is 1 after the reload, not merely 1 from before it
  • ✓scrape_duration_seconds for the new target is comfortably below the job's scrape_timeout; a value pinned at the timeout is a failure waiting for its first slow minute
  • ✓scrape_samples_scraped for the new target matches the count you measured by hand, within the noise of a live exporter
  • ✓No series from the new target carries an exported_instance or exported_job label — that is the signature of an exporter setting identity labels that belong to Prometheus
  • ✓prometheus_tsdb_head_series grew by approximately the number you predicted, and no more
  • ✓Every target that was up before the reload is still up: count(up == 0) has not increased

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Revert the commit in the configuration repository rather than editing the server: git revert --no-edit HEAD, then redeploy and reload. The state you roll back to is then reproducible
  • ↶Re-validate before reloading the reverted file: promtool check config /etc/prometheus/prometheus.yml
  • ↶If the reload was rejected, nothing was applied: the previous configuration is still running and the only damage is that every other pending change in the file is also still waiting. Fix the syntax and reload again
  • ↶If the addition pushed the head over its budget, removing the job stops further ingestion but does not free the memory already committed. Series already in the head persist until they age out of it, so plan for a restart if the server is under pressure
  • ↶If the target was removed again, expect its series to receive stale markers and vanish from queries within the lookback window rather than flatlining — a gap in a dashboard after a rollback is the expected behaviour, not a second fault

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the team that owns the target if curl from the Prometheus host cannot reach the endpoint but the owner insists it is up: that is a network path or a firewall question, not a Prometheus one
  • · Escalate to the observability platform owner before adding a job whose measured series count is a material fraction of the head budget. That is a capacity decision with a bill attached, and it is not the requester's to make
  • · Escalate immediately if the reload was rejected and you cannot identify the offending block within a few minutes: unrelated changes from other people are queued behind it, and one of them may be an alert fix
  • · Escalate to security if the only way to make the scrape work is insecure_skip_verify: true — that turns off certificate verification for the traffic, and the decision belongs to whoever owns the trust boundary, in writing

Adding a scrape target is four lines of YAML. It is also an edit to the single file that decides what an entire organisation can see, applied by a reload that either takes the whole file or none of it. That asymmetry is the reason this has a runbook: the change is trivial and the way it fails is not.

When this runbook applies

  • A new exporter, appliance or application endpoint needs to be scraped.
  • An existing job needs another instance added to its target list.
  • A target was removed during an incident and is being put back.

When it does not

  • The target already exists and is down. That is a scrape failure, not an addition. up of 0 means Prometheus knows about the target and cannot scrape it; this runbook is for the case where Prometheus has never heard of it.
  • The job exists but has no targets at all. A keep regex that matches nothing produces a job whose up series is absent rather than 0. Adding another target to that job changes nothing, because relabelling will drop it too.
  • The exporter does not exist yet. Deploying the exporter and telling Prometheus about it are two changes. Doing them together means that when it does not work you have two suspects instead of one.

What this change actually costs

Filed as low risk, and that is honest — but only because of the pre-checks. Four things are being spent:

SpentHow muchWhen you find out
Head memoryRoughly 3-8 KiB per active series on 2.55.xAt the next memory ceiling, not today
Load on the targetOne HTTP GET per interval, foreverWhen the target owner notices
Reload availabilityThe whole file, atomicallyImmediately, if the syntax is wrong
Identity labelsjob and instance for the new seriesWhen two targets collide under one name

The reload is the interesting one. On a parse failure Prometheus keeps the previous configuration running: the server does not fall over, which is exactly why the failure is easy to miss. What it does do is hold back every other change sitting in that file — including, on a bad day, somebody’s alert-threshold fix. Your five-character typo is now their outage.

Step 1 — Prove the endpoint before you tell Prometheus about it

Run this from the Prometheus host. Not from your laptop, not from the target itself. The scrape is made by the server, so the server’s view of the network is the only one that counts.

Read-only / Safedoes it answer, and is it exposition format
# Substitute your own values before running:
TARGET=192.0.2.41:9100

curl -sf "http://$TARGET/metrics" -o /tmp/new.prom
head -12 /tmp/new.prom
promtool check metrics /tmp/new.prom

curl -sf fails with a non-zero exit rather than printing an HTTP error page, so a 404 from a wrong metrics_path is caught here rather than surfacing later as a scrape error nobody reads.

promtool check metrics parses the body against the exposition contract. This matters more than it looks: a scrape is parsed line by line and a single malformed line fails the whole scrape. There is no partial ingestion. An exporter that is 99% correct produces up of 0 and no data at all, which is indistinguishable from a dead host.

Step 2 — Count what you are about to ingest

Read-only / Safethe number that has to fit in the head
grep -cv '^#' /tmp/new.prom

That is series per target. Multiply by the number of targets the job will have. Then compare against what the server is already holding:

# active series in the head block right now
prometheus_tsdb_head_series

# series created per hour - churn, which costs more than level
rate(prometheus_tsdb_head_series_created_total[1h])

A single node exporter is a rounding error. A per-container or per-request-path exporter across a large fleet is not, and the moment to discover that is now, on a workstation, rather than at the next memory ceiling. If the addition is a material fraction of the head budget, this stops being a configuration change and becomes a capacity conversation with an owner.

Step 3 — Decide which job it belongs to

The instinct is one job per exporter type. The useful rule is one job per (exporter class, SLA class), because scrape_interval, scrape_timeout and sample_limit are per job and nowhere else. The same node exporter belongs in a 15s job on infrastructure you page on, and in a 2m job with a longer timeout on a fleet behind slow links. Put them in one job and you have paid the high-resolution price for everything, or accepted the low-resolution answer for the things that page you.

If the new target fits an existing job’s interval, timeout and limits, add it to that job’s target list. If it does not, it needs its own job, and the reason belongs in a comment next to it.

Step 4 — Write the job, with its limits

scrape_configs:
  - job_name: node-core           # owner: platform; 15s because these page
    scrape_interval: 15s
    scrape_timeout: 10s           # must stay below the interval
    metrics_path: /metrics
    scheme: http
    static_configs:
      - targets:
          - 192.0.2.41:9100
          - 192.0.2.42:9100
        labels:
          site: lon-2
    sample_limit: 5000            # ~3x measured scrape_samples_scraped
    body_size_limit: 20MB

The limits are the part people skip. Their defaults are 0, which means unlimited: a bet that every exporter you will ever run stays well-behaved forever. Exporters add collectors in minor releases. Applications add label values under incident load. Set sample_limit from the number you measured in step 2, with headroom, and know what it does when it fires: the entire scrape is rejected and up goes to 0. The safety net and the outage look identical from the outside, which is why the limit needs a comment saying where its value came from.

Leave honor_labels and honor_timestamps at their defaults unless you can name the reason. With honor_labels: false (the default), an exporter that stamps its own instance has that value stored as exported_instance and Prometheus keeps the real one. Set it to true and two hosts claiming the same instance will append to the same series, with no error logged anywhere, because nothing failed.

Step 5 — Validate offline

Read-only / Safethe cheapest gate in the pipeline
promtool check config /etc/prometheus/prometheus.yml
promtool check service-discovery /etc/prometheus/prometheus.yml node-core

promtool check config links against the same loader the daemon uses at reload time, so what it accepts, the daemon accepts. It catches unknown keys, wrong types, missing required keys and duplicate blocks in about two hundred milliseconds. Expect:

SUCCESS: /etc/prometheus/prometheus.yml is valid prometheus config

What it does not do is connect to anything. A typo in a hostname is valid schema, and an expression that always returns empty is valid syntax. That is what step 1 was for.

promtool check service-discovery is the one people do not know about. It prints the final label set and the scrape URL for every target the named job resolves to — which is the difference between “the config parses” and “the config will scrape the thing I meant”.

Step 6 — Reload, and prove the reload happened

Configuration changeapply, then verify the apply
PROM=http://localhost:9090

curl -sf -X POST "$PROM/-/reload"
curl -sfG "$PROM/api/v1/query" --data-urlencode 'query=prometheus_config_last_reload_successful'

POST /-/reload requires the server to have been started with --web.enable-lifecycle. Without it the endpoint returns 403 and the reload is a SIGHUP instead — usually systemctl reload prometheus, if the unit is wired for it.

Step 7 — Verify the target, not the configuration

Read-only / Safehealth and lastError are the ground truth
PROM=http://localhost:9090
JOB=node-core

curl -sf "$PROM/api/v1/targets?state=active" \
| jq -r --arg job "$JOB" '.data.activeTargets[]
    | select(.labels.job == $job)
    | [.labels.instance, .health, .lastError] | @tsv'

Read lastError verbatim. It is the highest-signal field in the whole subsystem and it names the stage that failed:

lastError containsStageWhat to change
context deadline exceededScrape timeoutThe exporter is slow, or scrape_timeout is too tight
server returned HTTP status 404Pathmetrics_path does not match the exporter
server gave HTTP response to HTTPS clientSchemescheme: https against a plaintext exporter
x509: certificate signed by unknown authorityTLSca_file missing the issuing CA
401 UnauthorizedAuthCredential file wrong, or rotated without a reload
exceeded sample limitLimitsYour sample_limit fired; decide which side is wrong

Then confirm the data, not just the plumbing:

# is the new instance up
up{job="node-core", instance="192.0.2.41:9100"}

# did the exporter try to name itself
{exported_instance!=""}

# does the sample count match what you measured by hand
scrape_samples_scraped{job="node-core"}

A target that is up with the wrong instance label is worse than a target that is down, because it looks finished.

Step 8 — Come back in an hour

Adding a target has a second-order effect that does not show up in the first minute. Check that the head grew by roughly what you predicted and that churn did not change shape:

prometheus_tsdb_head_series
rate(prometheus_tsdb_head_series_created_total[1h])
scrape_series_added{job="node-core"}

Sustained scrape_series_added — new series on every scrape — is the memory profile that kills Prometheus servers. It means the exporter is generating short-lived label values, and raw scrape volume is not the problem. If you see it, the fix is metric_relabel_configs on the label that is churning, or a conversation with whoever instrumented it.

Holding is a valid outcome

If step 2 says the addition is a significant fraction of the head budget, and the platform owner is not available, do not add it and then watch it. Record the measured series count, name the owner who has to approve the capacity, and set a time to come back. A target that was added at 17:00 on a Friday because it seemed small is the same target that is being hunted at 02:00 on Saturday when the server starts swapping.

Rollback

Configuration changerevert in the repository, not on the server
git revert --no-edit HEAD
promtool check config /etc/prometheus/prometheus.yml
curl -sf -X POST "http://localhost:9090/-/reload"

Reverting in the repository rather than editing the file in place is the whole point of configuration-as-code: the state you roll back to is the state CI validated, and it will still be there after the next deploy overwrites the server.

Two things rollback does not do. It does not free the memory the series already committed — those live in the head until they age out of it, so a server already under pressure may still need a restart. And it does not make the dashboards look untouched: a removed target’s series get stale markers and disappear from queries within the lookback window rather than flatlining at their last value. The gap is expected. It is not a second fault.

Common failures

SymptomCauseCheck
Target absent from /targets entirelyRelabelling dropped it, or the job never loadedpromtool check service-discovery
up is 0 immediatelyPath, scheme, TLS or authlastError
up flips to 0 after an exporter upgradesample_limit firedscrape_samples_scraped against the limit
Behaviour does not match the fileReload never applied/api/v1/status/config vs the file
Series appear under exported_instanceExporter sets its own identity labelsLeave honor_labels false; fix the exporter
Two hosts’ data interleaved on one serieshonor_labels: true with colliding identitiesSet it back to the default
Whole job silently missing, no up of 0A keep regex matched nothingabsent(up{job="node-core"}) as a standing alert

That last row deserves the standing alert. A job with zero targets produces no up series to be 0, so nothing fires; the dashboard simply shows a gap, and humans rationalise gaps as dashboard problems. An absent() guard per job is the cheapest insurance in the stack.

Escalation

Escalate when the answer is not yours to give:

  • curl from the Prometheus host cannot reach the endpoint but the owner says it is up. Network path, not Prometheus.
  • The measured series count is a material fraction of the head budget. Capacity decision, with a bill.
  • The reload is rejected and the offending block is not obvious within a few minutes. Other people’s changes are queued behind yours.
  • The only configuration that works needs insecure_skip_verify: true. That is a decision to accept interception on the traffic, and it belongs to whoever owns the trust boundary, in writing.

References

  1. Prometheus configuration: scrape_config
  2. Prometheus management API (reload and health endpoints)
  3. Prometheus HTTP API: targets
  4. Exposition formats