Metrics
All pages
Docs ยท HTTP APIMarkdown

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
endpointcredentialaccepts
POST /api/v2/eventsorganization API key ak_...folder events, experiment.open, experiment.tokenRefresh, reads
POST /api/v2/experiments/{experimentId}/eventsexperiment token ik_... from experiment.openlifecycle, metric, annotate, tokenRevoke
POST /api/v2/query/seriesorganization API keyone query.series or query.experimentseries
GET /api/v2/auth/statusorganization API keynothing; returns the key's organization
POST /api/v2/feedbacknone{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 }
}
  • messageId is a nonempty string of at most 200 characters; use a UUID.
  • correlationId is optional in a request. In every reply it equals the request's messageId, and the reply has a new messageId of its own.
  • event names are case-sensitive and carry the syvain.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 messageId must be unique; a duplicate rejects the whole request with HTTP 400 and code duplicate_message_id before any event runs. tokenRevoke must be the last event.
  • Every experimentId in an experiment batch must equal the one in the URL; a mismatch is a 409 event error.
  • syvain.v2.query.experiments and syvain.v2.query.revisions, which take up to 500 ids, and syvain.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 messageId within the experiment. Retry a metric with the same messageId and the same payload, and it is stored once.
  • Every other write has side effects when repeated. A repeated experiment.open replaces description and meta and issues another token; a repeated annotate adds a second annotation; a repeated done moves doneAtMs; folder.create and tokenRefresh also change state.
  • Retry connection failures, HTTP 429 and 5xx, and event errors with retryable: true, with backoff. Wait at least retryAfterMs or Retry-After when 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 metricRecorded replies; 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:

fieldrule
timestampMsinteger Unix milliseconds, 0 to 2^53 - 1
stepinteger, 0 to 2^53 - 2
metricNameprintable ASCII, 1 to 256 bytes
metricValuefinite number
metaflat 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.

eventdatareply
syvain.v2.folders.read{}every folder as a flat list of folderId, name and parentFolderId
syvain.v2.experiments.listoptional folderId, slug, slugPrefix, experimentIds, cursorone page of up to 1000 experiments in slug order and nextCursor; see below
syvain.v2.foldertree.read{}; optional includeIndex, folderIdthe whole tree: root folders, each with child folders and experiments, and root experiments; see below
syvain.v2.query.experimentexperimentId or experimentSlugthe record: status, description, meta, error, timestamps
syvain.v2.query.catalogexperimentId or experimentSlugevery series name with the values of each metadata key
syvain.v2.query.annotationsexperimentId or experimentSlugannotations, newest first; optional inclusive from/to timestamps and limit from 1 to 100,000
syvain.v2.query.experimentsexperimentIds, up to 500records, missingExperimentIds, and with onError: "report" also failedExperiments
syvain.v2.query.revisionsexperimentIds, up to 500experiment and series revision counters, for cache checks, with status and lifecycle times
syvain.v2.query.catalogsexperimentIds, up to 100catalogs, 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.data record is one metadata partition with aligned step, timestampMs and value arrays; the values at one index form one point. step can be null for older data.
  • A long partition arrives as several consecutive records with the same seriesName and metadata; concatenate them.
  • A complete stream ends with done. No matching data still ends with done and zero counts.
  • A syvain.v2.error record ends a failed stream, possibly after partial data. End of input without done is incomplete, even with HTTP 200.
  • Count batches and points against done before 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.result and query.revisions.result then also carry failedExperiments, a list of {experimentId, code, message, retryable}. Each requested ID is in exactly one of the records, missingExperimentIds and failedExperiments.
  • In query.experimentseries, a series that fails ends with its syvain.v2.query.experimentseries.series record carrying error: {code, message, retryable}; the stream goes on with the other series and ends with done as 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.