curlplot

Ad-hoc metrics over HTTP.

No signup. A generous free tier. The first write to an account name claims it with the key you used.

A gauge. How long this page took to load, in ms:

BASE=https://curlplot.dev
curl "$BASE/m/your-account/pick-a-key/g/page_load_ms/500"

An event. Text drawn as a marker on every chart, here your browser and OS:

BASE=https://curlplot.dev
curl "$BASE/m/your-account/pick-a-key/e/visit/Firefox%20on%20Linux"

Run sends the real request; the point shows up in the chart below. Use your own name and key for real data, and see it at https://curlplot.dev/d/your-account/pick-a-key.

Live: your-account / page_load_ms

gauge · last hour

    The gauge the example above writes to, with every event on your-account as a dashed line. It's public, so this shows everyone's runs.

    How keys work

    Endpoints

    Write

    GET|POST /m/{acct}/{key}/g/{metric}/{value}Gauge: records a value at a point in time
    GET|POST /m/{acct}/{key}/c/{metric}[/{n}]Counter: adds n, default 1. Charted as the total per time step.
    GET|POST /m/{acct}/{key}/t/{metric}/{ms}Timing, in milliseconds
    GET|POST /m/{acct}/{key}/e/{name}/{text}Event: URL-encoded text, drawn as a marker on every chart
    ?host=nas&room=atticQuery parameters become labels (max 8)
    ?ts=1767225600Sets the timestamp (unix seconds or milliseconds)

    Read

    GET /d/{acct}/{key}Your dashboard: logs this browser in with a cookie and redirects to /d/{acct}
    GET /d/{acct}Your dashboard once logged in. Otherwise it asks for the key.
    GET /q/{acct}/{key}/{metric}?from=-1h&to=nowSeries as JSON: {"t":[…],"v":[…],"events":[…]}. from defaults to the last hour. Optional &step=60 (10, 15, 30, 60, 120, 300 … seconds) sets the resolution.
    GET /s/{acct}/{key}Your metrics, with last value and when each was last seen
    GET /p/{token}Read-only shared view (create one from the dashboard)
    GET /badge/{token}/{metric}.svgStatus badge

    Responses: 204 stored (empty body) · 400 invalid input · 403 wrong key · 429 over a limit, with the reason in a JSON body. Names (account, metric, label key) must match ^[a-zA-Z_][a-zA-Z0-9_.:-]{0,63}$. The label name acct is reserved.

    Let an agent set it up

    If you use a coding agent (Claude Code, Codex, Cursor and others), point it at https://curlplot.dev/skill.md. It's a skill file with everything the agent needs: picking an account and key, keeping the key out of git, what to measure, the limits, and how to check that data arrives.

    Read https://curlplot.dev/skill.md and set up Curlplot monitoring for this project.

    To install it as a Claude Code skill, save it as .claude/skills/curlplot/SKILL.md.

    Examples

    Type your account name and key and every snippet below uses them, ready to copy. Nothing is sent until you run one.

    Shell basics
    U=https://curlplot.dev/m/your-account/pick-a-key
    curl -s "$U/g/disk_free_gb/$(df -BG --output=avail / | tail -1 | tr -dc 0-9)?host=$(hostname)"
    curl -s "$U/c/backups_ok"
    curl -s "$U/e/deploy/$(printf 'v1.4 shipped' | jq -sRr @uri)"

    POST works too

    curl -s -X POST "https://curlplot.dev/m/your-account/pick-a-key/c/signups/3"
    Linux host health

    Drop this in /usr/local/bin/pp-health and run it from cron every minute. It sends six gauges per host.

    #!/bin/sh
    U=https://curlplot.dev/m/your-account/pick-a-key
    H="host=$(hostname)"
    read l1 l5 l15 _ < /proc/loadavg
    mem=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo)
    disk=$(df --output=pcent / | tail -1 | tr -dc 0-9)
    up=$(cut -d' ' -f1 /proc/uptime)
    temp=$(( $(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null || echo 0) / 1000 ))
    for kv in load1=$l1 load5=$l5 mem_avail_mb=$mem disk_used_pct=$disk uptime_s=$up cpu_temp=$temp; do
      curl -s -o /dev/null "$U/g/${kv%%=*}/${kv#*=}?$H"
    done
    * * * * * /usr/local/bin/pp-health

    Failed systemd units

    curl -s "https://curlplot.dev/m/your-account/pick-a-key/g/failed_units/$(systemctl --failed --no-legend | wc -l)?host=$(hostname)"

    Pending apt upgrades (Debian/Ubuntu)

    curl -s "https://curlplot.dev/m/your-account/pick-a-key/g/apt_upgradable/$(apt list --upgradable 2>/dev/null | grep -c upgradable)?host=$(hostname)"
    Cron jobs and backups

    Count successes and failures separately, and record how long the job took.

    #!/bin/sh
    U=https://curlplot.dev/m/your-account/pick-a-key
    t0=$(date +%s%3N)
    if restic backup /home; then
      curl -s "$U/c/backup_ok?job=home"
    else
      curl -s "$U/c/backup_failed?job=home"
    fi
    curl -s "$U/t/backup_ms/$(( $(date +%s%3N) - t0 ))?job=home"

    Every 5 minutes: 1-minute load average

    */5 * * * * curl -s "https://curlplot.dev/m/your-account/pick-a-key/g/load1/$(cut -d' ' -f1 /proc/loadavg)?host=$(hostname)" >/dev/null
    GitHub Actions

    Paste this into a workflow to try it right away. The write URL is inline here; anyone who can read the workflow or its logs can write to your account, so if that matters, move it to a repository secret named CURLPLOT_URL and use U: ${{ secrets.CURLPLOT_URL }} instead. GitHub then masks it in logs.

          - run: echo "T0=$(date +%s)" >> "$GITHUB_ENV"   # first step of the job
    
          # … your build and test steps …
    
          - name: Report to Curlplot
            if: always()
            env:
              U: https://curlplot.dev/m/your-account/pick-a-key
            run: |
              curl -s "$U/c/ci_${{ job.status }}?repo=${{ github.event.repository.name }}"
              curl -s "$U/t/ci_ms/$(( ($(date +%s) - T0) * 1000 ))?repo=${{ github.event.repository.name }}"

    Gives you ci_success and ci_failure counters plus a ci_ms timing, labelled by repository.

    Mark deploys on every chart

          - name: Deploy marker
            run: curl -s "https://curlplot.dev/m/your-account/pick-a-key/e/deploy/$(printf '%s' "${{ github.sha }}" | cut -c1-7)"
    Python

    Standard library only.

    import time, urllib.request
    U = "https://curlplot.dev/m/your-account/pick-a-key"
    t0 = time.monotonic()
    do_the_work()
    urllib.request.urlopen(f"{U}/t/job_ms/{(time.monotonic() - t0) * 1000:.0f}?job=nightly")

    A small helper that never breaks your program

    import urllib.parse, urllib.request
    
    U = "https://curlplot.dev/m/your-account/pick-a-key"
    
    def pp(kind, metric, value="", **labels):
        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(url, timeout=2)
        except Exception:
            pass   # metrics must never take the app down
    
    pp("g", "queue_len", 17, worker="a")
    pp("c", "emails_sent")
    pp("e", "deploy", "v1.4 shipped")

    With requests

    import requests
    requests.get("https://curlplot.dev/m/your-account/pick-a-key/g/room_temp/21.4", params={"room": "office"}, timeout=2)
    Docker

    Running containers

    curl -s "https://curlplot.dev/m/your-account/pick-a-key/g/containers_running/$(docker ps -q | wc -l)?host=$(hostname)"

    Unhealthy containers

    curl -s "https://curlplot.dev/m/your-account/pick-a-key/g/containers_unhealthy/$(docker ps -q --filter health=unhealthy | wc -l)?host=$(hostname)"
    Uptime and HTTP checks

    Response time from wherever the check runs, and a counter for failures.

    #!/bin/sh
    U=https://curlplot.dev/m/your-account/pick-a-key
    SITE=https://example.com
    ms=$(curl -s -o /dev/null -w '%{time_total}' --max-time 10 "$SITE" | awk '{print int($1*1000)}')
    code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$SITE")
    curl -s "$U/t/http_ms/$ms?site=example"
    [ "$code" = 200 ] || curl -s "$U/c/http_fail?site=example&code=$code"

    TLS certificate days left

    end=$(echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
    curl -s "https://curlplot.dev/m/your-account/pick-a-key/g/cert_days/$(( ($(date -d "$end" +%s) - $(date +%s)) / 86400 ))?site=example"
    Home and IoT

    Raspberry Pi temperature

    curl -s "https://curlplot.dev/m/your-account/pick-a-key/g/cpu_temp/$(vcgencmd measure_temp | tr -dc 0-9.)?host=pi"

    MicroPython (ESP32, Pico W)

    import urequests
    r = urequests.get("https://curlplot.dev/m/your-account/pick-a-key/g/humidity/%.1f?room=attic" % sensor.humidity())
    r.close()

    Home Assistant rest_command

    rest_command:
      curlplot:
        url: "https://curlplot.dev/m/your-account/pick-a-key/g/{{ metric }}/{{ value }}?room={{ room }}"
    Routers and network
    # busybox wget needs to be built with TLS support for https
    wget -q -O /dev/null "https://curlplot.dev/m/your-account/pick-a-key/g/wan_rx_bytes/$(cat /sys/class/net/eth0/statistics/rx_bytes)?host=router"

    Ping latency

    ms=$(ping -c 3 -q 1.1.1.1 | awk -F/ '/^rtt|^round-trip/ {print int($5)}')
    curl -s "https://curlplot.dev/m/your-account/pick-a-key/t/ping_ms/$ms?target=cloudflare"
    JavaScript and Node.js
    const U = "https://curlplot.dev/m/your-account/pick-a-key";
    const pp = (kind, metric, value = "", labels = {}) =>
      fetch(`${U}/${kind}/${metric}${value === "" ? "" : "/" + encodeURIComponent(value)}?${new URLSearchParams(labels)}`)
        .catch(() => {});   // never let metrics break the app
    
    pp("c", "orders");
    pp("t", "render_ms", Math.round(performance.now()), { page: "home" });
    PowerShell (Windows)
    $U = "https://curlplot.dev/m/your-account/pick-a-key"
    $free = [math]::Round((Get-PSDrive C).Free / 1GB, 1)
    Invoke-RestMethod "$U/g/disk_free_gb/$($free)?host=$($env:COMPUTERNAME)" | Out-Null

    Limits

    Active series per account50 (a series is a metric plus its labels; it stays active until 30 days with no writes)
    Samples per account per day10 000 (resets at UTC midnight)
    Retention30 days
    Request rate per account100/s sustained, burst 200
    Labels per series8, values up to 128 characters
    Chart resolutionat most 2000 points per response

    Writing a new series past the limit returns 429 with the reason. Nothing is dropped without telling you.