Chronix

API Reference

Every Chronix endpoint: REST write and query, PromQL, Prometheus remote write/read, OTLP metrics, gRPC, Arrow Flight SQL, and the embedded Rust API, each with a runnable example.

On this page

Comprehensive reference for all Chronix server endpoints: REST, gRPC, Flight SQL, and the embedded Rust API.

Base URL: http://localhost:8086 (default)

OpenAPI Spec: A machine-readable OpenAPI 3.1 specification is available at GET /api/v1/openapi.json. Use this with Swagger UI, Redoc, or code generators for the most up-to-date API documentation.


Table of Contents


Errors

Every failing request answers the same JSON envelope, whatever produced it — a handler, a body that will not deserialise, a path no route matches, a method no route accepts:

{"error": "measurement not found: nope", "code": "NOT_FOUND"}

code is the machine-readable half; branch on it rather than on the message.

StatuscodeMeans
400BAD_REQUESTMalformed request — bad JSON, an unparseable parameter, invalid SQL
400CARDINALITY_EXCEEDEDThe write would exceed max_series_cardinality
400SCHEMA_ERRORA field's type conflicts with the measurement's schema
400PARTIAL_WRITESome points of the batch were rejected; the body names them
400FUTURE_TIMESTAMPA point's timestamp is beyond future_write_tolerance — usually a device clock
400INVALID_POINTA measurement, tag or field name the engine refuses
400SQL_ERROR, PROMQL_ERRORThe query did not plan
401UNAUTHORIZEDMissing or invalid credentials
403FORBIDDENAuthenticated, but not permitted
404NOT_FOUNDNo such measurement, trigger, namespace — or no such route
405METHOD_NOT_ALLOWEDThe path exists; this method does not
409CONFLICTThe resource already exists
413PAYLOAD_TOO_LARGEBody over max_body_size
415UNSUPPORTED_MEDIA_TYPEA JSON endpoint without Content-Type: application/json
422INVALID_BODYValid JSON, wrong shape — a required field is missing
429RATE_LIMITEDRate limit exceeded
500INTERNAL_ERROR, DATABASE_ERRORchronix's own fault. Logged in full server-side; the response says only that it happened
503BACKPRESSUREThe memtable is at capacity; the flush that clears it is already running. Carries Retry-After: 1
503OVERLOADEDA condition that needs an operator — a WAL poisoned by a failed fsync. Carries Retry-After: 60
503DATABASE_CLOSEDThe server is shutting down
504WRITE_TIMEOUTThe write did not finish within write_timeout. Its outcome is unknown — see below
504QUERY_TIMEOUTThe read exceeded sql_query_timeout_secs or query_timeout_secs
507STORAGE_FULLNo space left on the data volume. Carries Retry-After: 5

The PromQL endpoints are the one exception, deliberately: they answer Prometheus's own error shape, {"status":"error","errorType":…,"error":…}, because every Prometheus client branches on that instead.

Which errors are redacted

A 5xx caused by chronix's own machinery is redacted to "DATABASE_ERROR: an internal error occurred", with the detail in the server log. A 5xx that describes the deployment is shown in full — a full disk, a full memtable, a poisoned WAL, a query that ran out of time — because each has a remedy and none discloses anything.

504 WRITE_TIMEOUT means unknown, not no

write_timeout bounds how long the server waits, not how long the write takes: a durable write is not abandoned half-way, so when the deadline fires the write is still running and will probably land.

Retry it. A point is identified by its series and its timestamp, so writing it twice stores it once. Concurrent writes are bounded by admission control, which answers 503 BACKPRESSURE.


Health & Metrics

MethodPathDescription
GET/healthLiveness probe. Returns 200 OK when the process is running.
GET/readyReadiness probe. 200 when the database is open and accepting writes; 503 with {"ready": false, "reason": …} when it is not. Point a Kubernetes readinessProbe here.
GET/metricsPrometheus-format metrics scrape endpoint. Path is configurable. All four Prometheus metric types (counter, gauge, histogram, summary) are fully parsed, including per-bucket and per-quantile data.

Example

curl http://localhost:8086/health
# {"status":"ok"}

curl http://localhost:8086/ready
# {"status":"ready","uptime_secs":123}

Write Endpoints

POST /api/v1/write

Write one or more time-series points in native JSON format.

Request Body:

[
  {
    "measurement": "cpu",
    "tags": {"host": "srv1", "region": "us-east"},
    "fields": {"usage": 72.5, "count": 42},
    "timestamp": 1700000000000000000
  }
]
  • measurement (string, required): Target measurement name.
  • tags (object, optional): String key-value pairs for series grouping.
  • fields (object, required): Numeric/boolean/string field values, or {"decimal": "…"} for an exact one — see below.
  • timestamp (i64, optional): Nanosecond Unix epoch. Defaults to server time.

Exact decimal fields

A JSON number is parsed as a double by serde_json and by every client library there is, so writing 1234.5678 loses the digits before the server ever sees them. An exact field therefore travels as digits in a string:

{
  "measurement": "meter",
  "tags": {"device": "main"},
  "fields": {"z1nb_q": {"decimal": "1234.5678"}},
  "timestamp": 1700000000000000000
}

A JSON number inside the wrapper is refused, not converted.

Reading the point back gives the same shape, so a point you read is a point you can write. A SQL result cell is a plain string of digits, with decimal(38, 4) in the column metadata beside it.

The column's scale is fixed when the column is created, and a value needing more fractional digits is refused, naming both. Declare it first when the first value might not carry the digits you mean to keep:

curl -XPOST 'http://localhost:8086/api/v1/measurements/meter/schema/fields' \
  -H 'Content-Type: application/json' \
  -d '{"name":"z1nb_q","type":"decimal","scale":4}'

See Data Model.

Query parameters: backfill=true writes points outside the out-of-order window — see Backfilling history.

Response: 204 No Content

A malformed body answers 400 naming the field and its position in the batch — invalid write batch: points[1]: unknown field \measurment``.

POST /write, POST /api/v2/write, POST /api/v1/write/influx

Write data in InfluxDB Line Protocol format.

Content-Type: text/plain. Bodies may be gzipped (Content-Encoding: gzip), which is what Telegraf sends by default.

cpu,host=srv1,region=us-east usage=72.5,count=42i 1700000000000000000

Query parameters: precision=ns|us|ms|s sets the unit of every timestamp in the body (default ns); backfill=true writes outside the out-of-order window. db, bucket, org and rp are accepted and ignored — chronix is one database per process and scopes by the X-Namespace header.

Backfilling history

Live ingestion is held to ±ooo_shard_tolerance shards of the newest admitted write. A point older than that is refused, and the refusal names the timestamp, the window and the remedy:

{"error": "partial write: 0 accepted, 1 rejected: Memtable error: timestamp 1600000000000000000 is outside the out-of-order window [1788613200000000000, 1788631200000000000] (+/-2 shard(s) of 3600s around the newest admitted write); import history with a backfill write instead",
 "code": "PARTIAL_WRITE"}

It is an explicit opt-in, not an automatic fallback: the window is what bounds the number of open memtables.

# JSON, line protocol, remote write and OTLP all take the same parameter.
curl -X POST 'localhost:8086/api/v1/write?backfill=true' \
  -H 'Content-Type: application/json' \
  -d '{"measurement":"cpu","fields":{"usage":1.0},"timestamp":1600000000000000000}'
SurfaceHow to ask
POST /api/v1/write?backfill=true
POST /write, /api/v2/write, /api/v1/write/influx?backfill=true
POST /api/v1/prom/write?backfill=true
POST /v1/metrics, /api/v1/otlp/metrics?backfill=true
gRPC WriteWriteRequest.backfill = true
Arrow Flight DoPutFlightData.app_metadata = {"backfill": true}
Python SDKclient.write(points, backfill=True)

Admission, the cardinality budget, the schema and the future-timestamp bound are unchanged.

Flight SQL has no field for a write mode, so the flag travels in app_metadata. Put it on any message of the stream; the first non-empty one decides. An unrecognised key is refused, not ignored.

A backfill below a rollup's watermark marks those buckets for re-materialisation (Data Model).

Response: 204 No Content when every line was stored.

If some lines fail to parse, the good ones are still stored and the response is 400 with the counts:

{"error": "partial write: line 2: …", "written": 2, "rejected": 1}

That is InfluxDB's behaviour, and the wording matters: Telegraf only treats a failure as permanent when it recognises the message, and retries anything else for ever — so failing the whole batch means the good lines never land. A body in which nothing parses is a plain 400 naming the first failure.

Escape semantics

Chronix implements the Line Protocol escape rules in full:

SectionEscapableNotes
Measurement,
Tag key / tag value, = " is an ordinary character here
Field key, =
String field value" \

Field value suffixes

WrittenType
72.5float (f64) — the default for a bare number
42isigned integer
42uunsigned integer
t / true / f / falseboolean
"text"string
1234.5678dexact decimal — a Chronix extension

d is a Chronix extension — Influx has no exact type, so no line written for Influx changes meaning here. The digits are parsed as digits.

meter,device=main z1nb_q=1234.5678d 1700000000000000000

Two consequences worth calling out:

  • \\ is a literal backslash. path="C:\\" is a value of C:\ followed by a real closing quote. Escape detection counts consecutive backslashes, so an escaped backslash before a delimiter does not swallow the rest of the line.
  • = is allowed in tag keys and values (escaped, per the spec). Series identity uses reserved control-character separators rather than =, so lines InfluxDB accepts are accepted here. See Series Identity.

\0 and \x01 are reserved as identity separators and are rejected in measurement names, tag keys and tag values.

Batch size limit: Both the JSON /write and /write/influx endpoints enforce the same max_write_batch_size limit (default: 100,000 points). Requests exceeding this limit are rejected with 400 Bad Request.

Write timeout: All write endpoints enforce a configurable write_timeout_secs deadline (default: 30 s). If the write does not complete within the deadline, the server responds with 504 Gateway Timeout. Set to 0 to disable. This applies uniformly to HTTP JSON, Line Protocol, gRPC Write/StreamWrite, Flight SQL DoPut, Prometheus remote write, and OTLP metrics ingestion.


Query Endpoints

POST /api/v1/chronix/query

Query time-series data using the native query plan format.

Moved from /api/v1/query, which now serves the Prometheus instant query — that is the path every Prometheus client derives from a base URL, and a server that answers something else there cannot be used by one. /api/v1/chronix/query/explain and /api/v1/chronix/sql moved with it.

Request Body:

{
  "measurement": "cpu",
  "range": {"start": 1700000000000000000, "end": 1700100000000000000},
  "tags": {"host": "srv1"},
  "fields": ["usage"],
  "limit": 100,
  "offset": 0
}

The time bounds live under range, and every key is checked: an unknown one is a 400 rather than a field quietly dropped, because a dropped start answers the whole measurement as though it had understood.

limit is bounded by the server's sql_max_rows. A larger one is refused before the scan; naming none is refused after it if the result would exceed the ceiling. The response is a bare array with nowhere to carry a truncated flag, so this endpoint refuses rather than returning a prefix.

Response — a JSON array of rows. Each row separates the tags that identify the series from the fields that carry values, and a string field stays a field:

[
  {
    "timestamp": 1700000000000000000,
    "tags": {"host": "srv1"},
    "fields": {"usage": 72.5}
  }
]

POST /api/v1/chronix/sql

Execute a SQL query via DataFusion.

Request Body:

{"query": "SELECT * FROM cpu WHERE host = 'srv1' ORDER BY _time DESC LIMIT 10"}

Response — column metadata plus positional rows, so a client can build a frame without re-deriving the schema:

{
  "columns": [{"name": "_time", "data_type": "Timestamp(ns)"},
              {"name": "usage", "data_type": "Float64"}],
  "rows": [[1700000000000000000, 72.5]],
  "row_count": 1,
  "truncated": false
}

truncated is true when sql_max_rows cut the answer short, and is always present — an aggregate over a truncated scan is a wrong number, not a partial one. Add a LIMIT, narrow the range, or raise server.sql_max_rows.

Arrow Flight SQL has no field for the flag, so it refuses a result over the ceiling with RESOURCE_EXHAUSTED.

How a value is encoded. Every Arrow type a query can produce has a JSON form, and the column's data_type says how to read it back:

Arrow typeJSON
Int*, UInt*number — exact, including past 2⁵³
Float*number; NaN and the infinities become null (JSON has no literal)
Decimal*string of digits ("30.10") — exact
Utf8, LargeUtf8, Utf8Viewstring
Binary, FixedSizeBinarybase64 string
Timestamp, Date32, Date64, Time32, Time64, Durationinteger, in the unit data_type names — Date32 is days, Timestamp(ns) is nanoseconds
Intervalobject: months, days, nanoseconds
List, FixedSizeListarray
Structobject keyed by field name
Maparray of {"key": …, "value": …}
Nullnull

Read over Arrow Flight SQL to keep the Arrow types themselves, NaN included.

When a query fails, 400 carries the reason for anything the caller can fix — a division by zero, an impossible cast, an unknown time zone, an unsupported date_trunc granularity. 500 means a fault in the server.

Time predicates. The timestamp column is _time, typed TIMESTAMP(nanosecond). Three forms compare against it:

SELECT * FROM cpu WHERE _time >= 1700000000000000000;
SELECT * FROM cpu WHERE _time >= timestamp '2023-11-14T22:13:20Z';
SELECT * FROM cpu WHERE _time > now() - INTERVAL '1 hour';

An integer literal is read in the column's unit, so the epoch nanoseconds the write API took compare equal to themselves. BETWEEN and IN accept them too.

All three forms prune — the bounds reach the scan, so only overlapping segments are read. EXPLAIN shows what the scan will read:

EXPLAIN SELECT * FROM cpu WHERE _time >= 1700000000000000000;
ChronixExec: measurement=cpu, time=[1700000000000000000..9223372036854775807], filters=0, limit=None

A full [-9223372036854775808..9223372036854775807] range means the filter did not reach the scan and the whole measurement is being read.

A decimal literal is a decimal. 0.05 is DECIMAL, not DOUBLE, as in PostgreSQL — so comparison, BETWEEN, IN, GROUP BY and arithmetic on an exact column all stay exact.

A DOUBLE column is unaffected: Float64 op Decimal128 yields Float64, so usage * 1.5 is a float. The exponent form is not an escape hatch (1.5e0 is a decimal too) — write CAST(1.5 AS DOUBLE) for a literal that must be a float whatever it meets.

Catalog. SHOW TABLES, SHOW COLUMNS FROM <measurement>, DESCRIBE <measurement> and the information_schema views list the measurements the request's namespace holds data for.

What is refused. The endpoint is read-only: DDL, DML, COPY and statements such as SET and PREPARE are rejected before execution, so a refused statement has no side effect. EXPLAIN and EXPLAIN ANALYZE are permitted; the plan they wrap is held to the same rules, so EXPLAIN ANALYZE INSERT … is refused.

SQL Trigger WHEN Clause Syntax

SQL-based signal triggers support compound conditions with AND, OR, and parenthesized grouping in WHEN clauses:

-- Simple condition
CREATE TRIGGER high_cpu ON cpu_usage
  WHEN value > 90
  DELIVER webhook('https://alerts.example.com');

-- Compound AND/OR with parentheses
CREATE TRIGGER complex_alert ON cpu_usage
  WHEN (value > 90 AND host = 'prod-srv1') OR (value > 95)
  DELIVER webhook('https://alerts.example.com')
  COOLDOWN INTERVAL '5m';

The parser supports arbitrary nesting of AND/OR operators with explicit parentheses for precedence control. Trigger names are validated against SQL injection patterns — only alphanumeric characters and underscores are accepted.

A quoted right-hand side compares a tag, and an unquoted one compares a numeric field:

-- Only the production hosts, and never the canary.
CREATE TRIGGER prod_cpu ON cpu_usage
  WHEN value > 90 AND env = 'production' AND role <> 'canary'
  DELIVER webhook('https://alerts.example.com');

Without one, a trigger fires for every series of its measurement. Only = and <> apply to a tag.

The full grammar:

CREATE TRIGGER <name> ON <measurement> WHEN <condition>
  [DELIVER <channel>[, <channel>…]] [COOLDOWN INTERVAL '<duration>']
SHOW TRIGGERS
DROP TRIGGER <name>
ALTER TRIGGER <name> ENABLE | DISABLE

There is no WHERE, SEVERITY or ACTION clause.

DELIVER channels

Two: log and webhook('https://…'). Anything else — nats(…), mqtt(…), DELIVER TO log — is a parse error naming the channels that exist.

Each webhook URL is its own channel, so two triggers can deliver to two endpoints. Every delivery is a CloudEvents envelope, signed per the Standard Webhooks v1 scheme with triggers.webhook_signing_secrets; with none configured, DELIVER webhook(…) is refused at creation. The URL must be https, carry no userinfo, and name neither an internal host nor a non-routable address — see Webhook URL SSRF Protection.

Delivery does not block evaluation. Each channel owns a bounded queue and a worker, so an unreachable webhook delays only itself. A queue that fills drops the oldest waiting signal and counts it in chronix_signal_delivery_dropped_total{channel} — a non-zero rate there means alerts are being lost. Shutdown waits up to five seconds for the queues to drain.

A trigger may watch a rollup tier. A rollup's target measurement publishes the same change events any write does, and the WHEN clause names an aggregate column as readily as a raw field:

CREATE TRIGGER hot_avg ON cpu_1m WHEN usage_avg > 90 DELIVER log;

Trigger Endpoints

Served only when the server configuration has a [triggers] section; otherwise they answer 404. Every statement is scoped to the caller's namespace: a trigger sees only its own tenant's series, two tenants may use the same trigger name, and neither can list, drop or disable the other's.

POST /api/v1/triggers

Runs one trigger DSL statement.

curl -X POST localhost:8080/api/v1/triggers \
  -H 'Content-Type: application/json' \
  -d '{"query": "CREATE TRIGGER hot_cpu ON cpu WHEN value > 90 DELIVER log"}'

400 with the parser's message when the statement is refused — including a DELIVER webhook(…) with no signing secret configured.

GET /api/v1/triggers

The caller's triggers, under the names they were created with.

{
  "triggers": [
    { "name": "hot_cpu", "measurement": "cpu", "enabled": true, "delivery": ["log"] }
  ]
}

GET /api/v1/triggers/{name}

One trigger, without listing them all.

{ "name": "hot_cpu", "measurement": "cpu", "enabled": true, "delivery": ["log"] }

A name the caller does not own is a 404, which is also the answer when another tenant owns it — a tenant must not be able to learn that.

DELETE /api/v1/triggers/{name}

Drops one of the caller's triggers. A name the caller does not own is a 400, which is also the answer when another tenant owns it.

GET /api/v1/signals

Recently fired signals for the caller, newest last.

[
  {
    "event_id": "9f1c…",
    "trigger_name": "hot_cpu",
    "measurement": "cpu",
    "tags": { "host": "h1" },
    "timestamp": 1609459200000000000,
    "severity": "warning",
    "value": 99.0
  }
]

PromQL Endpoints

Prometheus-compatible query API, served at the paths a Prometheus client derives from a base URL. Point a Grafana Prometheus data source at http://chronix.example.com:8086 and nothing else needs configuring.

MethodPathDescription
GET/POST/api/v1/queryInstant query
GET/POST/api/v1/query_rangeRange query
GET/POST/api/v1/labelsList all label names
GET/POST/api/v1/label/{name}/valuesValues for a given label
GET/POST/api/v1/seriesFind series matching label matchers
GET/api/v1/metadataMetric metadata (types, help text)
GET/api/v1/status/buildinfoVersion, probed when a data source is saved
GET/api/v1/rules, /api/v1/alerts, /api/v1/query_exemplarsEmpty but well-formed — chronix has no rules or exemplars

/api/v1/prom/* is an alias for every query and discovery path, for deployments that name it explicitly. Chronix's own JSON query API lives under /api/v1/chronix/*, because /api/v1/query is where every Prometheus client looks.

POST bodies are application/x-www-form-urlencoded, which is what Grafana sends by default. time, start and end accept RFC 3339 or a Unix timestamp; step accepts a duration (15s, 1m30s) or a bare number of seconds. An unparseable value is a 400 rather than a silent default.

Errors carry Prometheus's status codes, because clients branch on them: 400 for bad_data, 422 for execution, 503 for timeout. The body is {"status":"error","errorType":…,"error":…} — not the envelope the rest of the API uses, for the same reason. The discovery endpoints answer in the same envelope, which is what Grafana branches on.

limit, and truncation

/query, /query_range, /series, /labels and /label/{name}/values accept limit: a non-negative integer bounding the number of results, where 0 (or absent) means no limit. On the query endpoints it bounds the number of series, as upstream does, and leaves a scalar or a string alone.

Two ceilings can cut an answer — the request's limit and the server's prom_series_limit — and either way the response says so, in the field Prometheus uses:

{"status": "success",
 "data": [{"__name__": "cpu_usage", "host": "a"}],
 "warnings": ["results truncated due to limit"]}

warnings is absent when nothing was cut, so its presence carries information. A limit above prom_series_limit does not raise it: an operator's ceiling is not a client's to lift.

A parse error is bad_data, and that includes the two things upstream also catches in its parser: an unknown function name, and a selector whose every matcher would be satisfied by an absent label ({}, {host=~".*"}). {host="a"} is legal and reaches every metric carrying that label.

/query and /query_range answer {"status":"success","data":{"resultType":…,"result":…}}. /labels, /label/{name}/values and /series answer a bare array under data, as Prometheus does, with label names and values sorted.

All three accept start and end (defaulting to the last hour) and repeated match[] series selectors. Every matcher in a selector is applied, not only __name__:

GET /api/v1/label/dc/values?match[]={__name__="cpu_usage",host="a"}
→ {"status":"success","data":["eu"]}

Repeated match[] parameters are a union, as in Prometheus. A selector that constrains no metric name is skipped rather than treated as "everything", since it cannot be answered without scanning every measurement — but a __name__ regex does constrain it, so match[]={__name__=~".+"} is answered here exactly as it is by /query.

/label/__name__/values lists the metric names holding data in the window, and /metadata describes each one — the two calls Grafana makes to populate a metric browser. Every name they offer is a selector that answers.

Metric names

Chronix stores a measurement with many fields; PromQL addresses a metric, which carries one value per sample. A metric is one (measurement, field) pair:

Written asPromQL metric
cpu,host=a usage=42,load=0.7cpu_usage, cpu_load
temperature,room=hall value=21temperature
Prometheus remote write / OTLP up{job="api"}up

The rule is <measurement>_<field>, except that a field named value gives the measurement name alone — which is how the Prometheus remote-write and OTLP paths store a sample, so a scraped metric round-trips under its own name.

Two consequences worth knowing:

  • A bare measurement name is not a metric. cpu selects nothing when the measurement's fields are usage and load; cpu_usage selects the series. Ask /api/v1/label/__name__/values — or Grafana's metric browser — for the names that exist.
  • Adding a field never renames an existing metric. A name depends only on its own (measurement, field) pair, so writing cpu,host=a temp=61 for the first time leaves cpu_usage and cpu_load exactly where they were.

A vector may not hold two series with the same label set, and a query that would produce one is an error — vector cannot contain metrics with the same labelset, as in Prometheus. rate({__name__=~"cpu.+"}[5m]) is the usual way to hit it: rate drops __name__, and cpu_usage and cpu_load then have identical labels. Aggregate or select one metric instead.

Semantics

The evaluator targets Prometheus 3.x. Two of its differences from 2.x are visible in returned data:

  • Range selectors and the lookback window are left-open, right-closed: foo[5m] at time t selects samples in (t − 5m, t]. A sample landing exactly on the older boundary belongs to the previous window, so evenly spaced samples yield a constant count per range.
  • . in a matcher regex matches newline. Matchers remain fully anchored, so {job=~"api"} matches only the exact value.

A range query returns exactly one point per step per series: every function over a range vector stamps the step's evaluation timestamp, so the matrix is step-aligned and strictly increasing. NaN ranks last in topk, bottomk, sort and sort_desc.

Modifiers. offset 5m shifts back, offset -5m shifts forward, and @ <unix seconds> / @ start() / @ end() pin an evaluation instant — @ replaces the evaluation time and the offset is then subtracted. Both attach to a selector or a subquery and to nothing else, so sum(m) offset 5m is a parse error naming the rule.

Subqueries. expr[5m:1m] evaluates expr at each point of an absolute step grid; expr[5m:] uses the engine's 1-minute interval rather than the outer step. Every sample carries its step timestamp, the window is left-open on the grid, and a subquery is a range vector — so rate(x[5m:15s]) extrapolates over five minutes exactly as rate(x[5m]) does.

A comparison keeps the vector element's value, whichever side the scalar is written on: 2 < some_metric is the metric's value, not 2. A duplicate on the "one" side of a group_left is an error rather than a cross product. last_over_time keeps __name__ while every other _over_time and timestamp() drop it. deriv and idelta drop a series with fewer than two points rather than emitting NaN.

The 3.x-only functions double_exponential_smoothing, mad_over_time, sort_by_label and sort_by_label_desc are available without a feature flag (upstream gates them behind --enable-feature=promql-experimental-functions). Two notes on their behaviour:

  • double_exponential_smoothing(v, sf, tf) requires 0 < sf < 1 and 0 < tf < 1 — a value on the boundary is an error, not a clamp — and returns nothing for a range holding fewer than two samples.
  • sort_by_label orders naturally, so pod-2 precedes pod-10, with the full label set as the tie-break.

The eight date functions — year, month, day_of_month, day_of_week, day_of_year, days_in_month, hour, minute — read the sample value as a Unix timestamp in seconds and answer in UTC, so hour() < 9 means the same thing wherever the server runs. Called with no argument they use vector(time()), and all of them drop __name__.

The native-histogram family — histogram_count, histogram_sum, histogram_avg, histogram_stddev, histogram_stdvar and histogram_fraction — reads a stored distribution, and histogram_quantile dispatches: a series carrying native histograms answers from its own buckets, a classic le-bucketed one takes the original path. The rest refuse a float series by name — histogram_count of a gauge is not the gauge.

A stored native histogram also answers the classic names — foo_bucket with cumulative counts ending at le="+Inf", foo_count and foo_sum — so a dashboard written before native histograms existed needs no change. Those names are resolvable but not listed by /api/v1/label/__name__/values: listing them would make {__name__=~".+"} return the same observations four times.

The le values are the boundaries the histogram has. A custom-bucket histogram — what an OTLP explicit-bucket or classic histogram becomes — carries the boundaries the instrumentation chose, so an existing le="0.5" matches. An exponential one carries powers of 2^(2^-schema), so le="0.5" matches nothing: histogram_quantile(0.99, foo_bucket) and a heatmap over the whole series both work, but a hand-pinned boundary returns an empty result rather than the nearest bucket.

Not implemented: the variadic histogram_quantiles, limitk, limit_ratio, and everything else still behind an upstream feature flag. A query using one of these returns an error rather than a wrong answer.

Instant Query

curl 'http://localhost:8086/api/v1/query?query=cpu_usage{host="srv1"}&time=1700000000'

Range Query

curl 'http://localhost:8086/api/v1/query_range?query=cpu_usage&start=1700000000&end=1700100000&step=60'

Label Values

# List all metric names
curl http://localhost:8086/api/v1/label/__name__/values

# List all values for the "host" tag
curl http://localhost:8086/api/v1/label/host/values

Response Format:

All PromQL endpoints return the standard Prometheus response envelope:

{
  "status": "success",
  "data": {
    "resultType": "vector|matrix|scalar|string|labels|series|metadata",
    "result": [...]
  }
}

Wire Protocol Endpoints

POST /api/v1/prom/write

Prometheus Remote Write endpoint. Accepts snappy-compressed protobuf, in either remote-write version.

# Used automatically by Prometheus with remote_write config:
# remote_write:
#   - url: http://chronix:8086/api/v1/prom/write
#     send_native_histograms: true
#     # Optional; omit for 1.0.
#     protobuf_message: io.prometheus.write.v2.Request
Content-TypeVersion
absent, or application/x-protobuf1.0
application/x-protobuf;proto=prometheus.WriteRequest1.0
application/x-protobuf;proto=io.prometheus.write.v2.Request2.0

Anything else is 415 Unsupported Media Type, with both supported message sets named in the body so a sender can fall back on its own.

A 2.0 write answers with what it actually stored:

HeaderMeaning
X-Prometheus-Remote-Write-Samples-Writtenfloat samples stored
X-Prometheus-Remote-Write-Histograms-Writtennative histograms stored
X-Prometheus-Remote-Write-Exemplars-Writtenalways 0 — Chronix stores no exemplars

A 1.0 write answers none of them, because 1.0 does not define them.

Native histograms are carried by both versions and stored in the metric's own column, so the name you query is the name you exported. A float sample and a histogram under one metric name is a type conflict and is refused by name: a metric that is a gauge on Monday and a distribution on Tuesday is two metrics. A histogram staleness marker is dropped like a float one.

Non-finite samples are skipped, not rejected. Chronix refuses to store NaN and ±Inf — a NaN in storage poisons every aggregate that reads it — but Prometheus sends a staleness marker, which on the wire is a NaN, every time a scrape target disappears. Returning 400 for the batch would stall the remote-write queue indefinitely, because Prometheus retries rather than drops. Such samples are counted in chronix_wire_non_finite_samples_skipped_total and the rest of the batch is written; the endpoint still returns 204.

POST /api/v1/prom/read

Prometheus Remote Read endpoint. Accepts snappy-compressed protobuf.

POST /api/v1/otlp/metrics

The same rule applies: an OTLP summary quantile with nothing observed behind it is NaN, and a gauge may report NaN or ±Inf at any time. Gauge and sum data points carrying a non-finite value are skipped; a histogram or summary keeps the fields that are finite rather than losing the whole point.

OpenTelemetry OTLP metrics ingestion, in protobuf (application/x-protobuf) or JSON. Accepts gauges, sums, summaries, and both histogram shapes.

curl -X POST http://localhost:8086/api/v1/otlp/metrics \
  -H 'Content-Type: application/json' \
  -d '{"resourceMetrics": [...]}'
InstrumentStored as
Gaugefield gauge
Sumfield value
Exponential histogramfield value, a native histogram at the matching schema
Explicit-bucket histogramfield value, a native histogram with custom boundaries (schema −53)
Summaryfields count, sum, quantile_<q>

Both histogram shapes answer histogram_quantile and the rest of the histogram_* family, and both answer the classic foo_bucket / foo_count / foo_sum names.

An OTLP sum the exporter left unset stays absent rather than being stored as zero — an unset sum means the instrument recorded negative events, not that the observations summed to nothing.


Annotations & Dashboards

GET /api/v1/annotations

Query past signal/alert events as Grafana-compatible annotations.

Response:

[
  {"text": "Signal event in _signals", "time": 1700000000000, "tags": ["_signals"]}
]

GET /api/v1/annotations/stream

Server-Sent Events (SSE) stream of real-time signal events. Driven by CDC EventBus subscription — wakes instantly on write/delete events with a 30-second fallback timer. Connect from Grafana or any SSE client for live annotation updates.

curl -N http://localhost:8086/api/v1/annotations/stream
# data: {"text":"Signal event in _signals","time":1700000000000,"tags":["_signals"]}

GET /api/v1/cdc/stream

Real-time Change Data Capture (CDC) event stream via Server-Sent Events. Subscribes to the in-process EventBus and delivers mutation events (writes, deletes, drops) as JSON-encoded SSE frames. This is the primary integration point for external consumers that need real-time change notifications without polling.

Query Parameters:

ParameterTypeDescription
measurementstringComma-separated list of measurements to filter on
event_typestringComma-separated event types: point_written, series_deleted, measurement_dropped

SSE Event Format:

Each SSE frame includes an event: field matching the CDC event type, enabling client-side event dispatch:

# Stream all events
curl -N http://localhost:8086/api/v1/cdc/stream

# Filter by measurement
curl -N "http://localhost:8086/api/v1/cdc/stream?measurement=cpu,mem"

# Filter by event type
curl -N "http://localhost:8086/api/v1/cdc/stream?event_type=point_written"

# Combined filters
curl -N "http://localhost:8086/api/v1/cdc/stream?measurement=cpu&event_type=point_written"

Example SSE output:

event: point_written
data: {"PointWritten":{"measurement":"cpu","tags":{"host":"srv1"},"fields":{"usage":{"F64":0.85}},"timestamp":1700000000000000000,"seq":42}}

event: series_deleted
data: {"SeriesDeleted":{"measurement":"mem","tags":{"host":"srv2"},"series_hash":12345,"seq":100}}

event: measurement_dropped
data: {"MeasurementDropped":{"measurement":"old_metric","seq":200}}

JavaScript client example:

const source = new EventSource('/api/v1/cdc/stream?measurement=cpu');
source.addEventListener('point_written', (e) => {
  const event = JSON.parse(e.data);
  console.log('New point:', event.PointWritten);
});
source.addEventListener('series_deleted', (e) => {
  console.log('Series deleted:', JSON.parse(e.data));
});

GET /api/v1/dashboards/export

Export all bundled Grafana dashboard JSON definitions.

Response: JSON array of complete Grafana dashboard model objects.

CLI equivalent:

chronixd --export-dashboards ./grafana/

Drop-in client compatibility

Every route below is one a stock client derives on its own. None of them needs a proxy, a path rewrite, or a non-default setting.

Grafana

Add a Prometheus data source pointing at the server:

URL: http://chronixd:8086

That is the whole configuration. Grafana appends /api/v1/query, /api/v1/query_range, /api/v1/labels, /api/v1/series and /api/v1/label/<name>/values itself, POSTs them as application/x-www-form-urlencoded (its default httpMethod), and probes /api/v1/status/buildinfo when you press Save & test. All of those are served, on GET and POST alike. time, start and end accept RFC 3339 or a Unix timestamp; step accepts 15s, 1m30s or a bare number of seconds.

Errors carry the status codes Prometheus uses — 400 for bad_data, 422 for execution, 503 for timeout — because Grafana branches on them.

Telegraf

[[outputs.influxdb]]
  urls = ["http://chronixd:8086"]
  database = "telegraf"

# or, for the v2 client
[[outputs.influxdb_v2]]
  urls = ["http://chronixd:8086"]
  bucket = "metrics"
  organization = "acme"

Both post gzipped bodies by default, to /write and /api/v2/write respectively; both are served and decompressed. The precision parameter is honoured (ns, us, ms, s), and db, bucket, org and rp are accepted and ignored — chronix is one database per process and scopes by the X-Namespace header.

A batch containing a malformed line stores the good lines and answers 400 {"error": "partial write: …", "written": N, "rejected": M}, which is what Telegraf recognises as a permanent failure. Failing the whole batch instead makes it retry for ever.

OpenTelemetry Collector

exporters:
  otlphttp:
    endpoint: http://chronixd:8086

The exporter appends /v1/metrics and gzips by default; both are served.

Prometheus remote write and read

remote_write:
  - url: http://chronixd:8086/api/v1/prom/write
    send_native_histograms: true
    # Optional; omit for remote write 1.0, which also carries histograms.
    protobuf_message: io.prometheus.write.v2.Request

remote_read:
  - url: http://chronixd:8086/api/v1/prom/read

Remote read returns native histograms as well as float samples, so a federating Prometheus reading Chronix as long-term storage gets the distribution back rather than an empty series.

Remote read applies all four matcher types — =, !=, =~ and !~ — with regexes anchored the way Prometheus anchors its own, and a __name__ regex selecting measurements. An absent label reads as the empty string, so job!="a" also selects series that have no job.

A write whose points are rejected — a timestamp outside the out-of-order window, or a series over the cardinality limit — answers 400 naming the counts, not 204. Both rejections are deterministic, so 400 is correct: Prometheus treats it as permanent and drops the batch, where a 503 would have it retry the same doomed batch for ever.

Management Endpoints

MethodPathDescription
GET/api/v1/measurementsList all measurements with schema info
GET/api/v1/measurements/{name}/schemaGet schema for a measurement
DELETE/api/v1/measurements/{name}Drop an entire measurement
POST/api/v1/measurements/{name}/restoreUndo a pending drop, inside the soft_delete_ttl_secs grace period
POST/api/v1/deleteDelete points matching predicates
GET/api/v1/rollupsList rollup configurations, each with its watermark and any pending repairs
POST/api/v1/rollupsCreate a rollup configuration
DELETE/api/v1/rollups/{name}Delete a rollup configuration
POST/api/v1/rollups/{name}/refreshRecompute a range now (?start=&end=, nanoseconds)
POST/api/v1/export/parquetExport data as Apache Parquet
GET/api/v1/connectorsList active ingestion connectors

Connectors

GET /api/v1/connectors reports each running connector's counters:

[
  {
    "name": "kafka-main",
    "connector_type": "kafka",
    "status": "Running",
    "metrics": {
      "messages_total": 128034,
      "points_total": 512136,
      "decode_errors": 2,
      "lag": 417,
      "throughput": 842.6
    }
  }
]

status reports what the connector's task is doing:

StatusMeaningchronix_connector_up
RunningConnected and receiving1
IdleConnected, nothing arriving1
ReconnectingCannot reach the broker; retrying with backoff0
FailedRetrying, and naming the error — {"failed": "<reason>"}. Not terminal0
StoppedNot started, or stopped0

Alert on chronix_connector_up, not on /ready: a connector that cannot reach its broker does not stop this node answering queries, so readiness does not fail on it.

lag is messages behind the source's newest offset, and it is null for a source that has no such measure — MQTT, which pushes rather than being polled. Kafka's figure comes from the watermarks the last fetch response already carried, so reading it costs no broker round trip, and it is also exported as the chronix_kafka_consumer_lag gauge. throughput is points per second since the connector started; for a windowed rate, take rate() over the chronix_*_points_total counters.

Rollups

A rollup is declared by a bucket width and, optionally, the time zone its calendar is read against and an origin that moves the boundary:

curl -X POST http://localhost:8086/api/v1/rollups \
  -H 'content-type: application/json' \
  -d '{
        "name": "energy_monthly",
        "source_measurement": "energy",
        "target_measurement": "energy_monthly",
        "every": "1mo",
        "timezone": "Europe/Berlin",
        "aggregations": ["first", "last"],
        "group_by_tags": ["meter"]
      }'

The unit decides what a bucket means. Sub-day units (ns, us, ms, s, m/min, h) are a fixed span that never varies — an hour bucket is always an hour, and in a zone they are aligned to that zone's standard offset so the boundaries stay evenly spaced across a daylight-saving change. Super-day units (d, w, mo, y) follow the local calendar: a day runs from local midnight to local midnight, which is 23 or 25 hours on a transition day, and a month is 28, 29, 30 or 31 days. The month is mo; m is always the minute.

Without a timezone a 1d tier buckets on UTC midnight — which is 02:00 local in Berlin in summer and 01:00 in winter, so a "daily" total is a day's worth of somebody else's day, and the two halves of the year do not agree with each other. Set the zone whenever the buckets are meant to line up with what a person calls a day.

origin moves the boundary off midnight on the 1st, for a period that does not start there — "origin": "2024-01-15" is a billing month that runs from the 15th, "origin": "2024-01-01 06:00:00" a shift day that starts at six. Write it as a date, a local date and time (read in timezone), or an RFC 3339 instant. What matters is where it falls in the cycle, not how far away it is: for a one-unit width that is its time of day or day of month, and for a multi-unit width it also picks which unit opens a bucket — 3mo anchored on a January gives calendar quarters. A monthly tier must start on day 1–28: a boundary on the 29th, 30th or 31st is missing from some months, so it is refused rather than clamped.

The same origin is the fourth argument to time_bucket() in SQL, and TimeBucket::with_origin on the native query API.

A rollup's listing reports where it has got to and whether it is behind:

curl http://localhost:8086/api/v1/rollups
{
  "items": [
    {
      "name": "energy_15m",
      "source_measurement": "energy",
      "target_measurement": "energy_15m",
      "every": "15m",
      "timezone": null,
      "origin": null,
      "aggregations": ["Avg", "Max"],
      "materialised_until": 1700000000000000000,
      "pending_repairs": [[1699900000000000000, 1699903600000000000]]
    }
  ],
  "total": 1, "offset": 0, "limit": 100
}

every is the width, spelled the way it was written, so what this endpoint reports can be typed straight back into a create request. timezone is the calendar it is read against, or null for UTC, and origin the instant the boundaries are aligned to, or null for midnight on the 1st. materialised_until is the exclusive end of the newest bucket aggregated so far. pending_repairs are bucket ranges below that watermark whose input changed afterwards — a backfill, an import, or a delete — and which the next maintenance pass will recompute. A non-empty list is why retention is holding raw data: the tier does not yet agree with the raw data, so the raw data stays. It empties on its own; if it does not, check the logs for rollup materialisation failed and the chronix_rollup_failures_total metric.

To recompute a range immediately — after an out-of-band restore, say, which the engine cannot observe:

curl -X POST 'http://localhost:8086/api/v1/rollups/energy_15m/refresh?start=1699900000000000000&end=1699903600000000000'
{ "rollup": "energy_15m", "points_written": 96 }

The range is aligned outward to whole buckets. Recomputing is idempotent: the target's existing aggregates over the range are replaced, not merged with, and any tier fed by this one is marked for repair in turn.

Delete Points

curl -X POST http://localhost:8086/api/v1/delete \
  -H 'Content-Type: application/json' \
  -d '{"measurement": "cpu", "tags": {"host": "old-srv"}, "start": 0, "end": 1700000000000000000}'

Response:

{ "deleted": 42, "segments_skipped": 0, "complete": true }
FieldMeaning
deletedNumber of series tombstoned
segments_skippedSegments that could not be scanned and may still hold matching data
completetrue when segments_skipped == 0

A delete names a time interval. start and end bound what is removed; points outside them are untouched. Omitting them does not mean "delete this series forever" — the upper bound is resolved per series to the newest timestamp that series currently holds, so the delete covers the data that exists and a point written afterwards re-creates the series. Re-ingesting into an interval that is still tombstoned stays masked until compaction materialises the delete, which is the same rule Prometheus and InfluxDB apply.

A delete survives a restart. Tombstones are persisted to the catalog manifest and fsynced before the response is returned.

A delete can be partial. A segment that cannot be opened or read — a corrupt file, or a segment tiered to remote storage — is skipped rather than aborting the whole operation, so deleted alone cannot distinguish "nothing matched" from "some data was never scanned".

Callers acting on an erasure obligation (GDPR requests, § 14a evidence records) must treat a non-zero segments_skipped as a failed delete and retry; a chronix_delete_segments_skipped_total counter is exported for alerting. The gRPC DeleteResponse carries the same segments_skipped field, and /api/v1/delete_batch reports it per item.

Export Parquet

curl -X POST http://localhost:8086/api/v1/export/parquet \
  -H 'Content-Type: application/json' \
  -d '{"measurement": "cpu"}'

Response:

{ "rows_written": 120000, "bytes_written": 1048576, "truncated": false }
FieldMeaning
rows_writtenRows written to the file
bytes_writtenSize of the finished file
truncatedtrue when a configured size budget cut the export short

Tag columns are dictionary-encoded (ParquetExportConfig::dictionary_tags, on by default). Tags are low-cardinality by construction, so this is the dominant factor in export size when a gateway uploads a window to a fleet backend.

Exports can be size-bounded. ParquetExportConfig::max_bytes stops the writer before the file exceeds a budget and reports truncated: true, rather than filling a device's flash. The bound is checked at row-group boundaries, so the file may exceed it by up to one row group; the resulting file is always a valid, readable Parquet file. Treat truncated: true as "this export is incomplete", not as an error.


Namespace Endpoints

Namespace management. Namespace definitions are durably persisted to disk and survive process restarts.

Data isolation is separate and off by default: set multi_tenancy = true to have every write stamped with the request's namespace and every read confined to it. X-Namespace selects the namespace for HTTP; gRPC and Flight SQL use x-namespace metadata. Without multi_tenancy, the header still selects the namespace for authorization and rate limiting, but every request sees all data. See Security.

Authorization: the namespace a request may act in comes from its credential. An API key carries a namespaces list, a JWT carries a namespaces claim, and naming a namespace outside that list is answered 403 — the header alone is not authority. An empty list means unrestricted, which is why a server with multi_tenancy = true refuses to start while a key has one.

When a Cedar authorization engine is also configured, the namespace middleware additionally checks that the principal is permitted to act on the target namespace (Chronix::Namespace resource type). Policies can restrict users to specific namespaces:

permit(
  principal in Chronix::Role::"team_a",
  action,
  resource == Chronix::Namespace::"team_a_ns"
);
MethodPathDescription
POST/api/v1/namespacesCreate a namespace
GET/api/v1/namespacesList all namespaces
GET/api/v1/namespaces/{name}Get namespace details
DELETE/api/v1/namespaces/{name}Delete a namespace
GET/api/v1/namespaces/{name}/usageGet resource usage vs quotas

Create Namespace

curl -X POST http://localhost:8086/api/v1/namespaces \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "production",
    "max_series_count": 1000000,
    "max_ingestion_rate": 50000,
    "max_storage_bytes": 107374182400,
    "max_measurements": 500,
    "max_models": 100
  }'

Check Usage

curl http://localhost:8086/api/v1/namespaces/production/usage

Admin — Cluster Management

Every admin endpoint requires two things: a credential marked admin, and — when an authorization engine is configured — a policy permitting the capability that route group asks for, on Chronix::System. The capabilities are separate so a backup credential cannot mint API keys:

Route groupCapability
/api/v1/admin/auth/keys*ManageKeys
/api/v1/admin/backup*, /api/v1/admin/restoreManageBackups
/api/v1/admin/log-levelManageConfig
/api/v1/admin/analytics/*ManageModels
/api/v1/namespaces*ManageNamespaces
/api/v1/admin/nodes*, /heartbeatManageNodes
/api/v1/admin/regions*ManageRegions
/api/v1/admin/routing, /health, /topologyViewCluster
/api/v1/admin/rebalanceManageCluster

action in Chronix::Action::"Admin" grants all of them at once — Admin is an action group, so it is in, not ==.

MethodPathDescription
POST/api/v1/admin/nodesRegister a cluster node
GET/api/v1/admin/nodesList all cluster nodes with status
DELETE/api/v1/admin/nodesDeregister a cluster node
POST/api/v1/admin/heartbeatNode heartbeat (keep-alive)
POST/api/v1/admin/regionsCreate a data region
PUT/api/v1/admin/regions/{id}/stateUpdate region state
GET/api/v1/admin/routingGet the cluster routing table
GET/api/v1/admin/healthCluster-wide health status
POST/api/v1/admin/rebalanceTrigger cluster rebalancing
POST/api/v1/admin/nodes/{id}/decommissionGracefully decommission a node
PUT/api/v1/admin/log-levelChange server log level at runtime

Register Node

curl -X POST http://localhost:8086/api/v1/admin/nodes \
  -H 'Content-Type: application/json' \
  -d '{"node_id": 10, "addr": "10.0.0.10:8086", "mode": "data"}'

Cluster Health

curl http://localhost:8086/api/v1/admin/health
# {"status":"healthy","total_nodes":3,"healthy_nodes":3,...}

Runtime Log-Level

Change the server's tracing log level at runtime without restart. Uses a reload::Layer so the change takes effect immediately.

# Set log level to debug
curl -X PUT http://localhost:8086/api/v1/admin/log-level \
  -H 'Content-Type: application/json' \
  -d '{"level": "debug"}'
# {"previous": "info", "current": "debug"}

# Restore to info
curl -X PUT http://localhost:8086/api/v1/admin/log-level \
  -d '{"level": "info"}'
FieldTypeDescription
levelstringOne of trace, debug, info, warn, error

Response: 200 OK with {"previous": "<old>", "current": "<new>"} Error: 400 Bad Request for invalid level strings.

Backup & Restore

Create Backup

POST /api/v1/admin/backup

Request Body:

FieldTypeDescription
target_dirstringBackup target directory, resolved inside backup_root

Response: 200 OK — a BackupManifest with version, created_at, wal_sequence, segments, file_count and total_bytes.

The backup is a checkpoint taken after a flush: every write acknowledged before the request is in it, and one accepted while it runs is not. Backing up into a directory that already holds a checkpoint replaces it.

Verify a Backup

POST /api/v1/admin/backup/verify

Request Body:

FieldTypeDescription
backup_dirstringBackup directory, resolved inside backup_root

Response: 200 OK — the backup's BackupManifest.

Errors: 400 if the directory is not a backup, the manifest is corrupt, or a segment is missing or the wrong size, with the file named. Reads only.

Restore from Backup

POST /api/v1/admin/restore

Request Body:

FieldTypeDescription
backup_dirstringBackup source directory, resolved inside backup_root
target_dirstringRestore target directory; must not exist

Response: 200 OK — the backup's BackupManifest.

Errors: 400 if the target exists, the manifest is missing or corrupt, or the backup is incomplete — every segment its catalog names is checked for presence and size before anything is copied. The restored directory is a complete database that can be opened on any machine.


Admin — Analytics Model Management

MethodPathDescription
GET/api/v1/admin/analytics/modelsList all trained models
GET/api/v1/admin/analytics/models/{measurement}/{name}Get model details
DELETE/api/v1/admin/analytics/models/{measurement}/{name}Delete a model
POST/api/v1/admin/analytics/retrainTrigger model re-training

List Models

curl http://localhost:8086/api/v1/admin/analytics/models
# {"models": [{"name":"ses_model","measurement":"cpu","model_type":"Ses",...}]}

Retrain

curl -X POST http://localhost:8086/api/v1/admin/analytics/retrain \
  -H 'Content-Type: application/json' \
  -d '{"measurement": "cpu"}'
# {"accepted": true, "models_queued": 2}

When the disk fills

A write that cannot be made durable answers 507 Insufficient Storage; gRPC answers RESOURCE_EXHAUSTED with the same text.

{"error": "no space left on the data volume: WAL error: WAL I/O error: No space left on device (os error 28)",
 "code": "STORAGE_FULL"}

507 is a 5xx, so retrying clients keep retrying — the condition usually clears when a log rotates or retention runs.

Nothing is lost. The WAL rewinds to its last durable offset, so writes resume once space returns without a restart, and a record the caller was told had failed is never written later. A flush that cannot write its segment leaves the memtable frozen for the next attempt. Reads keep working and /ready stays 200: this is back-pressure, not a reason to leave the load balancer.


Authentication

When authentication is configured, every endpoint outside auth.exempt_paths requires Authorization: Bearer <token>, which may be an API key or a JWT.

MethodPathDescription
POST/api/v1/admin/auth/keysCreate a new API key
GET/api/v1/admin/auth/keysList all API keys
DELETE/api/v1/admin/auth/keys/{name}Revoke an API key

These endpoints, like namespace management and backup/restore, require a credential carrying the admin capability.

Create Key

curl -X POST http://localhost:8086/api/v1/admin/auth/keys \
  -H 'Authorization: Bearer <admin-key>' \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "grafana-reader",
        "expires_at": 1793491200,
        "namespaces": ["tenant-a"]
      }'

expires_at is a Unix timestamp in seconds and is optional. namespaces is required when multi_tenancy = true, because a key naming none may act in every tenant.

The raw key is returned once; only its Argon2 hash is stored.


gRPC API

The gRPC service runs on a separate port (default 4243). The proto definition is in crates/chronixd/proto/chronix.proto.

ChronixService

MethodTypeDescription
WriteUnaryWrite a batch of points
StreamWriteClient-streamingStream points with server-side batching and dedup
QueryServer-streamingQuery data and stream Arrow-format results
GetSchemaUnaryGet measurement schema
ListMeasurementsUnaryList all measurements with schemas
DeleteUnaryDelete points matching predicates
DropMeasurementUnaryDrop an entire measurement
ServerInfoUnaryServer metadata (version, uptime, stats)
ExecuteSqlUnaryExecute SQL queries via DataFusion (streaming internally)

Write timeout: gRPC Write and StreamWrite enforce the same write_timeout_secs deadline. Timeouts surface as gRPC status DEADLINE_EXCEEDED.

Example (grpcurl)

grpcurl -plaintext -d '{"measurement":"cpu","points":[...]}' \
  localhost:4243 chronix.ChronixService/Write

Flight SQL API

Arrow Flight SQL service runs on a separate port (default 4244). Compatible with any Flight SQL client (e.g., Grafana Flight SQL plugin, JDBC/ODBC drivers, DBeaver).

Query Resource Limits

Flight SQL queries enforce the same resource limits as the REST and gRPC interfaces:

SettingDefaultDescription
sql_query_timeout_secs30Maximum seconds for SQL query execution including result streaming
sql_max_rows100000Maximum rows returned by a single SQL query
prom_series_limit10000Maximum label-sets returned by /api/v1/series

Queries exceeding the timeout are cancelled. Row limits are enforced at the batch level — once the cumulative row count reaches the limit, the final batch is truncated via batch.slice() and no further batches are sent. This avoids materializing the entire result set in memory.

Supported Commands

CommandDescription
CommandStatementQueryExecute a SQL query and retrieve results
CommandStatementUpdateBulk write via DoPut (Arrow RecordBatch)
CommandGetCatalogsList catalogs (returns "chronix")
CommandGetTableTypesList table types (returns "TABLE")
CommandGetTablesList measurements as tables

Example (Python)

from adbc_driver_flightsql import dbapi

conn = dbapi.connect("grpc://localhost:4244")
cursor = conn.cursor()
cursor.execute("SELECT * FROM cpu WHERE host = 'srv1' LIMIT 10")
print(cursor.fetchall())

Embedded Rust API

Use Chronix as an embedded library without running a server.

use chronix::prelude::*;

// Open database
let config = ChronixConfig::builder()
    .data_dir("/var/lib/chronix")
    .build()?;
let db = Chronix::open(config)?;

// Write
let key = SeriesKey::new("cpu", [("host", "srv1")])?;
let fields = [("usage", FieldValue::F64(72.5))].into();
db.insert(Point::new(key, fields, timestamp_ns())?)?;

// Batch write (single frozen check, single atomic size update)
let points = vec![point1, point2, point3];
db.insert_batch(points)?;

// Query
let plan = db.query()
    .measurement("cpu")
    .tag("host", "srv1")
    .field("usage")
    .time_range(start_ns, end_ns)
    .build()?;
let batch: RecordBatch = db.execute(&plan)?;

// SQL
let ctx = chronix::sql::create_session_context(Arc::new(db));
let df = ctx.sql("SELECT avg(usage) FROM cpu GROUP BY host").await?;

// Analytics
use chronix::chronix_analytics::forecast::{ForecastModel, SesModel};
let mut model = SesModel::new(Some(0.3));
model.fit(&timestamps, &values)?;
let forecast = model.predict(10)?;

Key Types

TypeDescription
ChronixMain database handle — thread-safe, Arc-shareable
ChronixConfigDatabase configuration (builder pattern)
PointA single write point with series key, fields, timestamp
SeriesKeyMeasurement name + tag set — identifies a unique series
FieldValueEnum: F64, I64, U64, Bool, String
QueryBuilderFluent query construction
QueryPlanCompiled query plan for execution

Error Responses

All REST endpoints return errors in a consistent format:

{
  "error": "NOT_FOUND",
  "message": "measurement 'nonexistent' not found"
}
HTTP StatusError CodeDescription
400BAD_REQUESTInvalid request body or parameters
401UNAUTHORIZEDMissing or invalid authentication
403FORBIDDENCedar authorization denied (admin or namespace)
404NOT_FOUNDResource not found
429RATE_LIMITEDNamespace quota exceeded
500INTERNALServer-side error

See Also

Undoing a drop

DELETE /api/v1/measurements/{name} is the most destructive single call in this API, and by default it is irreversible.

Set [database] soft_delete_ttl_secs and it stops being: the drop marks the measurement pending, a background pass hard-deletes it once the deadline passes, and until then it can be brought back with its data.

A measurement pending drop is invisible everywhere a genuinely dropped one would be — SHOW TABLES, /api/v1/measurements, PromQL discovery, and every query answer nothing for it — while its data is untouched, so a restore before the deadline is instant and loses nothing. The pending state survives a restart, so a restart between the drop and the restore neither un-drops it nor forgets the deadline.

The deadline is measured from the wall clock capped by the newest timestamp the database holds — the same reference retention uses — so a clock that jumps forward cannot close the window and destroy the data it was protecting. The trade is the same one retention makes: a database that has stopped receiving data stops reclaiming this disk, and a DELETE without soft_delete_ttl_secs is how to reclaim it immediately.

curl -X DELETE http://localhost:8086/api/v1/measurements/power   # 204
curl -X POST http://localhost:8086/api/v1/measurements/power/restore   # 204

404 means there is nothing to undo — the measurement was never dropped, the grace period has passed, or soft_delete_ttl_secs is not configured, and the message says which. Under multi-tenancy a drop removes this namespace's series rather than the measurement (the schema is shared between tenants), so there is no pending drop and the answer is also 404.

Improve this page on GitHub