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 → Monitoring → Metrics.
- Click “Create metric source”, fill in:
- Name — for display, e.g. “LLM costs” or “Prod Telemetry”.
- Notification channel — where to send alerts for metrics from this source (your own channel or a shared one with send permission). You can leave it as ”—”: points will be accepted, but alerts will have nowhere to go.
- After creating, the token (
M…) and ready-made integration examples are shown in the source’s row — you’ll need the token 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: <api-token>" \ -d '{ "name": "LLM costs", "channelId": 12345 }'The response contains the source object with a public token (the M prefix and
40 hex characters):
{ "id": 7, "token": "M7c2a8f3b1e0d4a6c8e9f2b1d4c7e0a9b2f5d8c1e", "channelId": 12345, "name": "LLM costs", "created": "2026-06-23T10:11:12Z", "metricCount": 0, "appName": "Marketing"}The full ingest URL is ${NOTIFLY_URL}/metric/M7c2a8f3b1e0d4a6c8e9f2b1d4c7e0a9b2f5d8c1e.
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 (points are not written, the owner
receives a quota notification); a 429 with a Retry-After header is also
possible when requests are too frequent. Invalid JSON, a malformed metric name, a
non-numeric value or more than 500 points yield 400.
Ingest limits
Section titled “Ingest limits”| Limitation | Value |
|---|---|
| Maximum points in one request | 500 |
| Request body size | up to 64 KiB |
| Maximum unique metrics per source | 100 (points of new metrics 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/M7c2a8f3b1e0d4a6c8e9f2b1d4c7e0a9b2f5d8c1e" \ -H "Content-Type: application/json" \ -d '{"name":"llm_cost_total","value":0.0042}'
# curl — batchcurl -X POST "$NOTIFLY_URL/metric/M7c2a8f3b1e0d4a6c8e9f2b1d4c7e0a9b2f5d8c1e" \ -H "Content-Type: application/json" \ -d '{"values":{"orders":12,"revenue":3490.5}}'# Pythonimport requestsrequests.post( "https://api.notifly.ru/metric/M7c2a8f3b1e0d4a6c8e9f2b1d4c7e0a9b2f5d8c1e", json={"values": {"llm_cost_total": 0.0042, "tokens_total": 1230}}, timeout=5,)// JavaScript / Nodeawait fetch('https://api.notifly.ru/metric/M7c2a8f3b1e0d4a6c8e9f2b1d4c7e0a9b2f5d8c1e', { 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://api.notifly.ru/metric/M7c2a8f3b1e0d4a6c8e9f2b1d4c7e0a9b2f5d8c1e", "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 via the API —
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: <api-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 |
In the dashboard
Section titled “In the dashboard”A source’s page shows its metrics (last value, time, number of points) and a chart
of the selected metric for a period from 10 minutes to a year (10m … 1y; 10m
and 1h have a Live mode). Tick two or more metrics to get a shared chart for
comparison.
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: <api-token>"Parameters:
| Parameter | Value | Default |
|---|---|---|
bucket | 1m, 1h or 1d | 1h |
from, to | interval bounds, RFC3339 | last 24 hours |
An unknown bucket yields 400; unparseable from/to are silently replaced
with the defaults.
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”. Thresholds are static only: there is no
anomaly detection, no comparison with a previous period and no rate-of-change
rules. The rule is evaluated whenever points arrive — against the final (latest by
ts) value of the metric 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, otherwise 400) |
operator | comparison operator: >, >=, <, <=, == (required) |
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 taken from the metric source (the
source’s channelId); a channelId sent in the body is ignored. The channel is
fixed in the rule when it is created and saved: if you later change the source’s
channel, re-save the alerts. Alerts of a source without a channel are not
delivered.
In the dashboard an alert is added on the source’s page (“Add alert”), and the metric name is picked from the metrics that have already arrived. Via the API a rule can be created in advance, for a metric that doesn’t exist yet.
# 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: <api-token>" \ -d '{ "metricName": "llm_cost_total", "operator": ">", "threshold": 50, "title": "LLM costs exceeded $50", "cooldownMinutes": 360 }'When triggered, the source channel receives a regular message with text like the following (the text is currently generated in Russian: “Источник” — source, “Порог” — threshold):
llm_cost_total = 51.3
Источник: LLM costs
Порог: > 50The message priority is the channel’s default priority. Like any channel message, it is escalated via the configured delivery methods, obeys quiet hours and arrives silently during an active maintenance window of the channel.
REST API
Section titled “REST API”The public ingest endpoint does not require authorization (the source token in the URL).
All other endpoints require an API token (X-Notifly-Key); write
operations require write permission.
| Method & path | Authorization | Purpose |
|---|---|---|
POST /metric/:token | public (source token) | send points (three formats) |
GET /metric-source | API token | list sources |
POST /metric-source | API token (write) | create source |
PUT /metric-source/:id | API token (write) | update (name, channel) |
DELETE /metric-source/:id | API token (write) | delete source |
GET /metric-source/:id/metrics | API token | list source metrics |
PATCH /metric-source/:id/metrics/:mid | API token | change type (gauge/counter) |
DELETE /metric-source/:id/metrics/:mid | API token (write) | delete metric |
GET /metric-source/:id/metrics/:mid/series | API token | time series (buckets) |
GET /metric-source/:id/alerts | API token | list alerts |
POST /metric-source/:id/alerts | API token (write) | create alert |
PUT /metric-source/:id/alerts/:aid | API token (write) | update alert |
DELETE /metric-source/:id/alerts/:aid | API token (write) | delete alert |
Sending points and managing sources, metrics and alerts are also available in the
Go and Python SDKs (PushMetric/PushMetrics, push_metric/push_metrics).
Related features: Active monitors, Heartbeat.