---
name: curlplot
description: Set up monitoring with Curlplot (https://curlplot.dev), ad-hoc metrics over plain HTTP with no signup. Use when someone wants to track numbers from their servers, cron jobs, CI, scripts, apps or devices (disk space, job durations, failures, deploys, response times, sensor readings) and see them charted, without running Prometheus or Grafana.
---

# Curlplot

Curlplot records metrics sent as plain HTTP requests and charts them. There is no
signup and no SDK: the first write to an account name claims it with the key in the URL.

```
curl -s "https://curlplot.dev/m/{acct}/{key}/g/disk_free_gb/41.5?host=web1"
```

Your job is to set this up for the user: choose an account and key with them, work out
what to measure, add the writes where they belong, check that data arrives, and give them
their dashboard link.

## 1. Account and key

- **Account**: a name the user picks, matching `^[a-zA-Z_][a-zA-Z0-9_.:-]{0,63}$`. Suggest one
  (their project or team name), but let them decide.
- **Key**: generate one, don't let the user reuse a password. It must be 8–128 URL-safe
  characters (`A-Z a-z 0-9 . _ ~ -`):
  ```
  python3 -c 'import secrets; print(secrets.token_urlsafe(24))'
  # or: openssl rand -hex 24
  ```
- The **first write claims the name** and stores that key for good. A later write with a
  different key gets `403`. If the name is taken, pick another.
- **There is no key recovery or rotation.** Tell the user plainly, and make sure they have
  saved the key somewhere durable (a password manager) before you rely on it.
- If they already have an account, ask for the account name and where the key is stored.
  Don't ask them to paste the key into chat if a secret store or environment variable will do.

## 2. Keep the key out of public places

The key is the whole credential and it lives in the URL. Build the write URL from a
secret, and keep it out of anything that is committed or shared:

- Put the base URL `https://curlplot.dev/m/{acct}/{key}` in one place: an environment
  variable (for example `CURLPLOT_URL`), a CI secret, or a config file excluded from git.
  Code and scripts read it from there.
- In GitHub Actions, ask the user whether they want it as a repository secret,
  `${{ secrets.CURLPLOT_URL }}`, or whether straight in code is okay.
- If you put the URL in issues, PR descriptions, commit messages or chat, then
  mask the key. If it leaks, anyone can write to the account, and the only fix
  is a new account.

## 3. Decide what to measure

Ask the user what they care about if it isn't clear. A few well-chosen metrics beat many.
Common picks:

| Where | Metrics |
|---|---|
| Linux host | `disk_used_pct`, `mem_avail_mb`, `load1`, `failed_units`, `apt_upgradable`, labelled `host=` |
| Cron jobs, backups | counters `backup_ok` / `backup_failed`, timing `backup_ms`, labelled `job=` |
| CI | counters `ci_success` / `ci_failure`, timing `ci_ms`, labelled `repo=`; an event per deploy |
| Web app | counters for business events (`signups`, `orders`, `errors`), timings for slow paths |
| HTTP checks | timing `http_ms`, counter `http_fail`, labelled `site=` |
| Containers | gauges `containers_running`, `containers_unhealthy` |
| Devices, IoT | gauges like `cpu_temp`, `humidity`, labelled `room=` |

## 4. API

Writes accept `GET` or `POST`. Prefer **`POST`**, especially for counters: link
prefetchers, mail scanners and retries can repeat a `GET`.

| Request | Meaning |
|---|---|
| `/m/{acct}/{key}/g/{metric}/{value}` | **Gauge**: a value at a point in time (temperature, free disk, queue length) |
| `/m/{acct}/{key}/c/{metric}[/{n}]` | **Counter**: adds `n` (default 1). Charted as the total per time step. |
| `/m/{acct}/{key}/t/{metric}/{ms}` | **Timing** in milliseconds, `>= 0` |
| `/m/{acct}/{key}/e/{name}/{text}` | **Event**: URL-encoded text up to 500 characters, drawn as a marker on every chart (deploys, incidents) |
| `?host=web1&env=prod` | Query parameters become labels, at most 8, values up to 128 characters. `acct` is reserved. |
| `?ts=1767225600` | Timestamp in unix seconds or milliseconds. Default now. Up to 30 days back. |

Responses: `204` stored (empty body), `400` invalid input, `403` wrong key, `429` over a
limit. Errors have a JSON body: `{"error": "…"}`.

Rules:

- Metric names and label keys match `^[a-zA-Z_][a-zA-Z0-9_.:-]{0,63}$`. Use `snake_case` and put
  the unit in the name (`_ms`, `_gb`, `_pct`).
- A metric's type is fixed by its first write. Writing `c` to an existing gauge is a `400`.
- Values are plain numbers (`41.5`, `-3`, `1e6`). Not `NaN`, not `inf`.

Reads, for checking your work:

| Request | Returns |
|---|---|
| `GET /s/{acct}/{key}` | `{"metrics": [...]}` with each metric's type, last value and when it was last seen |
| `GET /q/{acct}/{key}/{metric}?from=-24h&to=now` | `{"t": [...], "v": [...], "events": [...]}`. Optional `&step=60` (seconds) |
| `GET /d/{acct}/{key}` | The dashboard. Logs a browser in with a cookie and redirects to `/d/{acct}`. |

## 5. Limits

The free tier enforces these per account. Design around them:

- **50 active series.** A series is a metric plus one combination
  of label values, so `http_ms` with `site=a` and `site=b` is two. It stays active until
  30 days without writes. **Never use unbounded values as labels**: no user
  IDs, request IDs, URLs with parameters, IPs or timestamps. Labels are for a handful of
  hosts, jobs, sites or environments.
- **10,000 samples per day** (UTC). That is about one write every 9
  seconds around the clock. Sending once a minute from a cron job is plenty for most things.
  For high-volume events, count in-process and send the total periodically.
- **100 requests/second** sustained, bursts of 200.
- **30 days retention.**

Over a limit you get `429` with the reason. Nothing is dropped silently.

## 6. Write it so it can't hurt

Metrics must never break or slow down what they measure:

- Short timeout (2 s), ignore errors, don't retry in a loop.
- In shell: `curl -s -o /dev/null --max-time 5 -X POST "$CURLPLOT_URL/c/backup_ok?job=home" || true`
- In a script that must exit non-zero on failure, don't let the metric call change the exit code.
- In app code, use a small helper. Python, standard library only:

```python
import os, urllib.parse, urllib.request

U = os.environ.get("CURLPLOT_URL", "")

def pp(kind, metric, value="", **labels):
    if not U:
        return
    url = f"{U}/{kind}/{metric}"
    if value != "":
        url += "/" + urllib.parse.quote(str(value), safe="")
    if labels:
        url += "?" + urllib.parse.urlencode(labels)
    try:
        urllib.request.urlopen(urllib.request.Request(url, method="POST"), timeout=2)
    except Exception:
        pass   # metrics must never take the app down
```

  JavaScript:

```js
const U = process.env.CURLPLOT_URL;
const pp = (kind, metric, value = "", labels = {}) =>
  U && fetch(`${U}/${kind}/${metric}${value === "" ? "" : "/" + encodeURIComponent(value)}?${new URLSearchParams(labels)}`,
             { method: "POST", signal: AbortSignal.timeout(2000) }).catch(() => {});
```

  In a hot path, don't block on the request: send it in the background or batch counts.

Examples:

```sh
# crontab, every minute: disk usage. Set the variable at the top of the crontab
# (crontab -e) and keep the file out of git. In a crontab, % must be written \%.
CURLPLOT_URL=https://curlplot.dev/m/{acct}/{key}
* * * * * curl -s -o /dev/null -m 5 -X POST "$CURLPLOT_URL/g/disk_used_pct/$(df --output=pcent / | tail -1 | tr -dc 0-9)?host=$(hostname)"

# wrap a job: success/failure counter and duration
t0=$(date +%s)
if ./backup.sh; then r=ok; else r=failed; fi
curl -s -o /dev/null -m 5 -X POST "$CURLPLOT_URL/c/backup_$r?job=nightly"
curl -s -o /dev/null -m 5 -X POST "$CURLPLOT_URL/t/backup_ms/$(( ($(date +%s) - t0) * 1000 ))?job=nightly"

# deploy marker
curl -s -o /dev/null -m 5 -X POST "$CURLPLOT_URL/e/deploy/$(printf 'v1.4 shipped' | jq -sRr @uri)"
```

```yaml
# GitHub Actions, last step of a job
      - name: Report to Curlplot
        if: always()
        env:
          U: ${{ secrets.CURLPLOT_URL }}
        run: |
          [ -n "$U" ] || exit 0
          curl -s -o /dev/null -m 5 -X POST "$U/c/ci_${{ job.status }}?repo=${{ github.event.repository.name }}" || true
```

More recipes (host health, Docker, TLS expiry, Home Assistant, MicroPython, PowerShell) are
on https://curlplot.dev/.

## 7. Verify and hand over

1. Send one write and check for `204`.
2. Wait a few seconds (new points show up within about 2–4 s), then
   `GET https://curlplot.dev/s/{acct}/{key}` and confirm the metric is listed with the right type.
3. Tell the user:
   - their dashboard: `https://curlplot.dev/d/{acct}/{key}`. Opening it logs that browser in.
     Treat the link like the key.
   - to share charts read-only, create a `/p/…` link from the dashboard. Status badges
     come from the same share links: `https://curlplot.dev/badge/{token}/{metric}.svg`.
   - which metrics you added, where they are sent from, and how often.
   - that the key can't be recovered, so it must be saved.

Curlplot has no alerting yet. If the user needs to be woken up when something breaks,
say so and suggest pairing it with an alerting tool.
