All pages
HTTP API
The v2 API is three POST endpoints that take JSON event envelopes:
/api/v2/events for organization events and reads, authenticated with an
organization API key; /api/v2/experiments/{experimentId}/events for one run's
lifecycle, metrics and annotations, authenticated with that experiment's token;
and /api/v2/query/series, which streams series as NDJSON. Every event has a
messageId, every reply names it in correlationId, and a failed event is a
syvain.v2.error reply inside an HTTP 200. The collector, the
Python API client, DuckDB and the
command line are built on it; use it directly from any other language.
Base URL and authentication
The API is served at https://metrics.041.io, the default of every published
client. The older https://metrics.syvain.com serves the same API for clients
that still name it.
Send the credential as a bearer token and JSON bodies with their content type:
POST /api/v2/events HTTP/1.1
Host: metrics.041.io
Authorization: Bearer ak_...
Content-Type: application/json| endpoint | credential | accepts |
|---|---|---|
POST /api/v2/events | organization API key ak_... | folder events, experiment.open, experiment.tokenRefresh, reads |
POST /api/v2/experiments/{experimentId}/events | experiment token ik_... from experiment.open | lifecycle, metric, annotate, tokenRevoke |
POST /api/v2/query/series | organization API key | one query.series or query.experimentseries |
GET /api/v2/auth/status | organization API key | nothing; returns the key's organization |
POST /api/v2/feedback | none | {kind, message, replyTo?, context?}; a bug report or an idea |
Keys and tokens are explained in organizations and keys.
GET /api/openapi.json is the OpenAPI document with every request and reply
schema; API events lists every event with its data and reply.
curl -sS https://metrics.041.io/api/v2/auth/status -H "authorization: Bearer $O41_METRICS_API_KEY"{
"data": {
"auth": { "kind": "api_key" },
"organization": { "id": "org_...", "name": "My Lab" }
},
"error": null
}The envelope
A request event:
{
"messageId": "7f0c1b9e-2f4e-4d37-9a51-6f1f6f0b8e11",
"event": "syvain.v2.folder.create",
"data": { "name": "sweeps", "parentFolderId": null }
}Its reply:
{
"messageId": "0d9a...",
"correlationId": "7f0c1b9e-2f4e-4d37-9a51-6f1f6f0b8e11",
"event": "syvain.v2.folder.created",
"data": { "name": "sweeps", "folderId": "019e...", "parentFolderId": null }
}messageIdis a nonempty string of at most 200 characters; use a UUID.correlationIdis optional in a request. In every reply it equals the request'smessageId, and the reply has a newmessageIdof its own.eventnames are case-sensitive and carry thesyvain.v2.prefix.- Every required field must be present, including empty objects and arrays.
Unknown fields, in the envelope or in
data, are rejected.
Batches
Both /events endpoints take one envelope or an array of 1 to 1,000. One
envelope gets one reply object; an array gets an array of replies in request
order.
- The whole body is validated before anything runs; one malformed event rejects the request with an HTTP error.
- Events run in order, but a batch is not a transaction: writes that succeeded stay when a later event fails.
- In an experiment batch every
messageIdmust be unique; a duplicate rejects the whole request with HTTP 400 and codeduplicate_message_idbefore any event runs.tokenRevokemust be the last event. - Every
experimentIdin an experiment batch must equal the one in the URL; a mismatch is a 409 event error. syvain.v2.query.experimentsandsyvain.v2.query.revisions, which take up to 500 ids, andsyvain.v2.query.catalogs, which takes up to 100, must be the only event in their request.
Errors
Errors come in two layers; read both the HTTP status and the body.
A request-level failure (authentication, malformed JSON, a body that fails validation) is an HTTP error status with this body:
{
"data": null,
"error": {
"message": "Authentication is required.",
"details": "code=unauthorized; request_id=..."
}
}An event that fails inside an accepted request is a reply in place of its result, with HTTP 200:
{
"messageId": "...",
"correlationId": "7f0c1b9e-2f4e-4d37-9a51-6f1f6f0b8e11",
"event": "syvain.v2.error",
"data": {
"code": "folder_already_exists",
"message": "...",
"status": 409,
"retryable": false,
"details": { "folderId": "019e..." }
}
}status is the HTTP status the event would have had. retryable is true for
429 and 5xx. A retryable event error may also carry retryAfterMs, an integer
number of milliseconds to wait before retrying it. A metric refused because its
experiment's write buffer is full carries one, for example. A retryable
request-level failure that knows its delay carries a Retry-After header in
seconds. A request-level failure can also happen after earlier events of the
same request were applied.
Retries
messageId correlates replies; it is not a general idempotency key.
- A metric is deduplicated by its
messageIdwithin the experiment. Retry a metric with the samemessageIdand the same payload, and it is stored once. - Every other write has side effects when repeated. A repeated
experiment.openreplaces description and meta and issues another token; a repeatedannotateadds a second annotation; a repeateddonemovesdoneAtMs;folder.createandtokenRefreshalso change state. - Retry connection failures, HTTP 429 and 5xx, and event errors with
retryable: true, with backoff. Wait at leastretryAfterMsorRetry-Afterwhen the reply gives one. After a timeout on a write that is not a metric, read the state before sending it again. - In a partly failed metric batch, committed metrics have
metricRecordedreplies; resend only the failed ones, with their original ids.
Recording a run
The sequence: open the experiment with the organization key, keep experimentId
and experimentToken, send lifecycle, metrics and annotations to the experiment
endpoint with the token, end with done or error, and revoke the token.
HOST=https://metrics.041.io
KEY=$O41_METRICS_API_KEY
uuid() { uuidgen | tr '[:upper:]' '[:lower:]'; }
opened=$(jq -n --arg id "$(uuid)" '{
messageId: $id, event: "syvain.v2.experiment.open",
data: {slug: "curl-run-001", description: "Recorded with curl", meta: {lr: 0.0003, seed: 7}}
}' | curl -sS "$HOST/api/v2/events" \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' --data-binary @-)
EXP=$(jq -r .data.experimentId <<<"$opened")
TOKEN=$(jq -r .data.experimentToken <<<"$opened")
now=$(( $(date +%s) * 1000 ))
jq -n --arg exp "$EXP" --argjson now "$now" \
--arg a "$(uuid)" --arg b "$(uuid)" --arg c "$(uuid)" --arg d "$(uuid)" '[
{messageId: $a, event: "syvain.v2.experiment.start", data: {experimentId: $exp}},
{messageId: $b, event: "syvain.v2.experiment.metric",
data: {experimentId: $exp, timestampMs: $now, step: 100, metricName: "loss", metricValue: 2.31, meta: {split: "train"}}},
{messageId: $c, event: "syvain.v2.experiment.metric",
data: {experimentId: $exp, timestampMs: $now, step: 100, metricName: "loss", metricValue: 2.47, meta: {split: "valid"}}},
{messageId: $d, event: "syvain.v2.experiment.annotate",
data: {experimentId: $exp, annotation: "checkpoint saved", meta: {path: "s3://bucket/curl-run-001/step-100.pt", step: 100}}}
]' | curl -sS "$HOST/api/v2/experiments/$EXP/events" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' --data-binary @- \
| jq -c '.[] | select(.event == "syvain.v2.error")'
jq -n --arg exp "$EXP" --arg a "$(uuid)" --arg b "$(uuid)" '[
{messageId: $a, event: "syvain.v2.experiment.done", data: {experimentId: $exp}},
{messageId: $b, event: "syvain.v2.experiment.tokenRevoke", data: {experimentId: $exp}}
]' | curl -sS "$HOST/api/v2/experiments/$EXP/events" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' --data-binary @-The metric fields and their limits:
| field | rule |
|---|---|
timestampMs | integer Unix milliseconds, 0 to 2^53 - 1 |
step | integer, 0 to 2^53 - 2 |
metricName | printable ASCII, 1 to 256 bytes |
metricValue | finite number |
meta | flat string -> string: at most 32 keys, 128 bytes per key, 512 per value, 4,096 bytes of canonical JSON |
error takes error, a message of 1 to 8,000 characters, and meta, a JSON
object. annotate takes annotation, 1 to 16,000 characters, and meta, at
most 64 KiB of compact JSON. Every limit is on limits and fair use.
start sets the status to running, done to done and error to error,
each at server time. None of them revokes the token, and events after done are
still accepted.
Tokens expire 24 hours after they are issued, at the reply's expiresAtMs.
Before then, send syvain.v2.experiment.tokenRefresh to /api/v2/events with
the organization key and {experimentId, experimentToken}; the reply carries a
new token, and the old one keeps working for at most 30 seconds. An expired or
revoked token cannot be refreshed; open the slug again.
Reading
Send reads to /api/v2/events with the organization key.
| event | data | reply |
|---|---|---|
syvain.v2.folders.read | {} | every folder as a flat list of folderId, name and parentFolderId |
syvain.v2.experiments.list | optional folderId, slug, slugPrefix, experimentIds, cursor | one page of up to 1000 experiments in slug order and nextCursor; see below |
syvain.v2.foldertree.read | {}; optional includeIndex, folderId | the whole tree: root folders, each with child folders and experiments, and root experiments; see below |
syvain.v2.query.experiment | experimentId or experimentSlug | the record: status, description, meta, error, timestamps |
syvain.v2.query.catalog | experimentId or experimentSlug | every series name with the values of each metadata key |
syvain.v2.query.annotations | experimentId or experimentSlug | annotations, newest first; optional inclusive from/to timestamps and limit from 1 to 100,000 |
syvain.v2.query.experiments | experimentIds, up to 500 | records, missingExperimentIds, and with onError: "report" also failedExperiments |
syvain.v2.query.revisions | experimentIds, up to 500 | experiment and series revision counters, for cache checks, with status and lifecycle times |
syvain.v2.query.catalogs | experimentIds, up to 100 | catalogs, one per experiment as in query.catalog, and failed, with each experiment that failed |
Find a folder or an experiment with folders.read and experiments.list.
experiments.list reads the experiments directly in a folder (folderId), at
the root (folderId: null), below a folder (recursive: true) or in the whole
organization (no folderId), narrowed by an exact slug, a slugPrefix or up
to 500 experimentIds. Send the same data with cursor: nextCursor until
nextCursor is null; a page can be short or even empty before the last one.
It must be the only event in its request. foldertree.read returns every
experiment of the organization in one reply, which is slow for large
organizations; use it only when you need the whole tree.
includeIndex: true adds index to every experiment in the tree: status, both
revisions, and created, updated, started, done and last-event times, as
query.revisions returns them. It is null for an experiment that changed
since query.revisions last read it, such as one logging metrics; read those
with query.revisions. Filtering or revalidating a whole organization this way
costs one tree read plus one revisions read per 500 changed experiments, and
wakes no idle experiment. folderId returns only that folder and its
subfolders.
Identity-taking reads accept exactly one of experimentId and experimentSlug.
An unknown slug is experiment_not_found; a catalog or series read by an id
that does not exist, or belongs to another organization, returns no series.
Catalogs are read from what each experiment has published, so listing a whole
folder stays fast: use query.catalogs for many experiments. A running
experiment lists a new series once its first points are published, usually
within a minute. Each catalog has asOfMs, when it was published (null before
the first), and sealed: cache a catalog only while sealed is true, and
read a growing one again.
A query.series.done or query.experimentseries.series reply with
stale: { reason } holds only what the experiment had published, because its
live data could not be reached right then. It may lack the newest points; show
it as stale, do not cache it, and read again later.
Streaming series
POST /api/v2/query/series takes exactly one envelope, never an array, and
answers application/x-ndjson: one reply envelope per line.
jq -n --arg id "$(uuid)" --arg exp "$EXP" '{
messageId: $id, event: "syvain.v2.query.series",
data: {experimentId: $exp, seriesName: "loss", filter: [{metadataKey: "split", metadataValue: "valid"}], xAxis: "step"}
}' | curl -sSN "$HOST/api/v2/query/series" \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' --data-binary @-{"messageId":"...","correlationId":"...","event":"syvain.v2.query.series.data","data":{"experimentId":"019e...","seriesName":"loss","metadata":{"split":"valid"},"data":{"step":[100],"timestampMs":[1789776000000],"value":[2.47]}}}
{"messageId":"...","correlationId":"...","event":"syvain.v2.query.series.done","data":{"batches":1,"points":1}}syvain.v2.query.series reads one series name. filter: [] reads every
metadata partition. A predicate with only metadataKey requires the key; with
metadataValue it requires that exact value. All predicates must match, at
most 32. xAxis is step or timestamp and sorts points ascending; there is
no slicing, descending order or aggregation.
syvain.v2.query.experimentseries reads many series of one experiment in one
stream: seriesNames lists up to 1,000 names, or is omitted for every series,
and filter and xAxis apply to all of them. Data records carry their
seriesName and records of different series can interleave; after a series'
last record comes syvain.v2.query.experimentseries.series with its batches
and points, and the stream ends with syvain.v2.query.experimentseries.done
with series, batches and points totals. A requested name without data
reports zero counts.
Parse the stream by these rules:
- Split on newlines across network chunks; a record can span chunks.
- Each
series.datarecord is one metadata partition with alignedstep,timestampMsandvaluearrays; the values at one index form one point.stepcan benullfor older data. - A long partition arrives as several consecutive records with the same
seriesNameandmetadata; concatenate them. - A complete stream ends with
done. No matching data still ends withdoneand zero counts. - A
syvain.v2.errorrecord ends a failed stream, possibly after partial data. End of input withoutdoneis incomplete, even with HTTP 200. - Count batches and points against
donebefore trusting the result, and never append a full retry to partial data without deduplicating.
Reads that fail in part
By default one failing experiment fails a whole query.experiments or
query.revisions event, and one failing series fails a whole
query.experimentseries stream, with a syvain.v2.error record. A client that
would rather have the rest sends onError: "report" in the event's data:
query.experiments.resultandquery.revisions.resultthen also carryfailedExperiments, a list of{experimentId, code, message, retryable}. Each requested ID is in exactly one of the records,missingExperimentIdsandfailedExperiments.- In
query.experimentseries, a series that fails ends with itssyvain.v2.query.experimentseries.seriesrecord carryingerror: {code, message, retryable}; the stream goes on with the other series and ends withdoneas usual. That record's counts, and the totals, include any data records the series streamed before it failed: check the counts, then discard that series' records.
A retryable failure may succeed when you ask again for just that ID or series. A
failure that is not about one ID or series, such as an unreadable list of an
experiment's series, still ends the read with syvain.v2.error. Without
onError, or with onError: "fail", the replies are as they always were.
Removed v1 API
Metrics v1 has been removed. Existing integrations must use /api/v2.
Organization credentials can send experiment.done, experiment.error, and
experiment.annotate, and list or revoke tokens with query.ingestionkeys and
ingestionkey.revoke. See the complete event reference.
Related: API events, organizations and keys, limits and fair use, the collector.