Metrics (custom time-series)
Metrics are a lightweight way to send arbitrary numeric values (cost of an LLM request, queue length, RPS, account balance, sensor temperature) to Notifly and get time series with charts and threshold alerts.
You create a metric source — it receives a public token with the M prefix —
and POST numbers with arbitrary names to POST /metric/<token>. Metrics are
created automatically on first arrival: nothing needs to be declared in advance.
Each point is expanded into three pre-aggregated buckets (1m, 1h, 1d),
which are then returned for charts.
Useful for:
- tracking AI costs (
llm_cost_total,tokens_total) directly from code; - business metrics (orders, signups, revenue) without a separate BI system;
- IoT / telemetry (temperature, humidity, battery) with cheap HTTP ingest;
- lightweight replacement for Prometheus push-gateway for pet projects and cron scripts.
Creating a metric source
Section titled “Creating a metric source”Via the dashboard
Section titled “Via the dashboard”- Open app.notifly.ru → Metrics.
- Click “Create source”, fill in:
- Name — for display, e.g. “LLM-расходы” or “Prod Telemetry”.
- Channel — where to send alerts for metrics from this source.
- After creating, copy the public token (
M…) — you’ll need it for ingest.
Via the REST API
Section titled “Via the REST API”curl -X POST "$NOTIFLY_URL/metric-source" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <client-token>" \ -d '{ "name": "LLM-расходы", "channelId": 12345 }'The response contains the source object with a public token (prefix M):
{ "id": 7, "token": "M7c2a8f3b1e0d4a6c8e9f2b1d", "channelId": 12345, "name": "LLM-расходы", "created": "2026-06-23T10:11:12Z", "metricCount": 0, "appName": "Marketing"}The full ingest URL is ${NOTIFLY_URL}/metric/M7c2a8f3b1e0d4a6c8e9f2b1d.
Sending points: POST /metric/:token
Section titled “Sending points: POST /metric/:token”Public endpoint without authorization (the token in the URL is the authentication).
Accepts three request body formats (application/json) — choose whichever is convenient.
Format 1 — single value
Section titled “Format 1 — single value”{ "name": "queue_depth", "value": 42 }Format 2 — batch values
Section titled “Format 2 — batch values”A map “name → value”. Update several metrics in one request:
{ "values": { "llm_cost_total": 0.42, "tokens_total": 1230, "queue_depth": 17 }}The values field accepts two JSON forms: you can send an object (as above)
or an array [{"name":"cost","value":0.42}, {"name":"tokens","value":1230}] —
elements without name are ignored, and on duplicate names the last value wins.
Format 3 — points with explicit time points
Section titled “Format 3 — points with explicit time points”For sending historical points or backfills. Each point can carry its own ts:
{ "points": [ { "name": "temperature", "value": 21.4, "ts": 1748174400 }, { "name": "temperature", "value": 21.7, "ts": 1748174460 }, { "name": "humidity", "value": 55 } ]}The ts field is Unix time as a number, seconds or milliseconds (auto-detected by magnitude):
- Unix seconds — integer
< 1e12, e.g.1748174400; - Unix milliseconds — integer
>= 1e12, e.g.1748174400000.
If ts is omitted, the server’s current time (UTC) is used.
All three formats can be combined in one request — points from name/value,
values and points are merged into a single list.
Response and quota
Section titled “Response and quota”The endpoint returns 204 No Content on success. If the body contains no points
(e.g. {}) or the token is unknown — it also returns 204 (silently ignored).
On exceeding the daily event quota — 429. Invalid JSON, malformed metric name
or non-numeric value yield 400.
Ingest limits
Section titled “Ingest limits”| Limitation | Value |
|---|---|
| Maximum points in one request | 500 |
| Maximum unique metrics per source | 100 (points beyond the limit are silently dropped) |
| Metric name | regex ^[a-zA-Z0-9_\-\.]{1,64}$ — Latin letters, digits, _, -, ., up to 64 chars |
| Value | finite number (not NaN, not ±Inf) |
A request with a malformed metric name or non-numeric value is entirely rejected
with 400 and a clear error description.
Language examples
Section titled “Language examples”# curl — single valuecurl -X POST "$NOTIFLY_URL/metric/M7c2a8f3b1e0d4a6c8e9f2b1d" \ -H "Content-Type: application/json" \ -d '{"name":"llm_cost_total","value":0.0042}'
# curl — batchcurl -X POST "$NOTIFLY_URL/metric/M7c2a8f3b1e0d4a6c8e9f2b1d" \ -H "Content-Type: application/json" \ -d '{"values":{"orders":12,"revenue":3490.5}}'# Pythonimport requestsrequests.post( "https://notifly.ru/metric/M7c2a8f3b1e0d4a6c8e9f2b1d", json={"values": {"llm_cost_total": 0.0042, "tokens_total": 1230}}, timeout=5,)// JavaScript / Nodeawait fetch("https://notifly.ru/metric/M7c2a8f3b1e0d4a6c8e9f2b1d", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "queue_depth", value: 42 }),});// Gobody := strings.NewReader(`{"name":"queue_depth","value":42}`)http.Post("https://notifly.ru/metric/M7c2a8f3b1e0d4a6c8e9f2b1d", "application/json", body)Metric type: gauge vs counter
Section titled “Metric type: gauge vs counter”Each metric has a type that affects default visualization:
| Type | Meaning | How it’s drawn |
|---|---|---|
gauge | an instantaneous value that fluctuates up and down (temperature, queue length, balance) | uses last / avg of the bucket |
counter | a monotonically increasing counter (total requests, cumulative cost) | emphasizes sum over the period |
Type is auto-detected by name when the metric is created (convention similar to Prometheus):
if the name ends with _total, _count, _sum or _counter — it’s a counter, otherwise gauge.
For example llm_cost_total → counter, queue_depth → gauge.
If auto-detection is wrong, you can override the type manually —
PATCH /metric-source/:id/metrics/:mid:
curl -X PATCH "$NOTIFLY_URL/metric-source/7/metrics/31" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <client-token>" \ -d '{"type":"gauge"}'Only gauge and counter are accepted — otherwise 400.
Storage and time series
Section titled “Storage and time series”Each point upon arrival is immediately expanded into three buckets — 1m, 1h,
1d. Each bucket accumulates sum, count, min, max and last
(the most recent value by time). Buckets have different retention TTLs:
| Bucket | Granularity | Retention |
|---|---|---|
1m | per-minute | 24 hours |
1h | per-hour | 30 days |
1d | per-day | 365 days |
Reading a series — GET .../series
Section titled “Reading a series — GET .../series”curl "$NOTIFLY_URL/metric-source/7/metrics/31/series?bucket=1h&from=2026-06-22T00:00:00Z&to=2026-06-23T00:00:00Z" \ -H "X-Notifly-Key: <client-token>"Parameters:
| Parameter | Value | Default |
|---|---|---|
bucket | 1m, 1h or 1d | 1h |
from, to | interval bounds, RFC3339 | last 24 hours |
The response is an array of buckets; the frontend decides what to plot (last for gauge,
sum for counter):
[ { "ts": "2026-06-22T10:00:00Z", "sum": 4.2, "count": 60, "min": 0.01, "max": 0.21, "last": 0.07 }, { "ts": "2026-06-22T11:00:00Z", "sum": 3.9, "count": 58, "min": 0.01, "max": 0.18, "last": 0.05 }]If the requested bucket is empty (for example, 1d hasn’t accumulated yet), but
per-minute data exists, Notifly aggregates on the fly from 1m buckets.
Threshold alerts
Section titled “Threshold alerts”An alert is a rule “if the metric value satisfies the condition — send a notification to the source channel”. It is evaluated on each incoming point (based on the final metric value in the request, so a batch of hundreds of points doesn’t create duplicate notifications).
Alert fields:
| Field | Description |
|---|---|
metricName | the metric name the rule applies to (required) |
operator | comparison operator: >, >=, <, <=, == |
threshold | threshold value (number) |
title | notification title; if empty — generated automatically (⚠️ <metric> <op> <threshold>) |
cooldownMinutes | minimum interval between notifications for this rule; 0 → 60 minutes |
The channel is not set on the alert — it is always taken from the metric source
(the source’s channelId), even if you send channelId in the body, it will be ignored.
# Alert: LLM costs exceeded $50 in a daycurl -X POST "$NOTIFLY_URL/metric-source/7/alerts" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <client-token>" \ -d '{ "metricName": "llm_cost_total", "operator": ">", "threshold": 50, "title": "LLM-расходы превысили $50", "cooldownMinutes": 360 }'When triggered, the source channel will receive a standard push notification with text like:
llm_cost_total = 51.3
Source: LLM-расходы
Threshold: > 50REST API
Section titled “REST API”The public ingest endpoint does not require authorization (token M… in the URL).
All other endpoints require a client (or MCP) token; write operations require write permission.
| Method & path | Authorization | Purpose |
|---|---|---|
POST /metric/:token | public (token M) | send points (three formats) |
GET /metric-source | client-token | list sources |
POST /metric-source | client-token (write) | create source |
PUT /metric-source/:id | client-token (write) | update (name, channel) |
DELETE /metric-source/:id | client-token (write) | delete source |
GET /metric-source/:id/metrics | client-token | list source metrics |
PATCH /metric-source/:id/metrics/:mid | client-token | change type (gauge/counter) |
DELETE /metric-source/:id/metrics/:mid | client-token (write) | delete metric |
GET /metric-source/:id/metrics/:mid/series | client-token | time series (buckets) |
GET /metric-source/:id/alerts | client-token | list alerts |
POST /metric-source/:id/alerts | client-token (write) | create alert |
PUT /metric-source/:id/alerts/:aid | client-token (write) | update alert |
DELETE /metric-source/:id/alerts/:aid | client-token (write) | delete alert |
Related features: Active monitors, Heartbeat, MCP for management via an AI assistant.