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 and at https://metrics.syvain.com; both are the same service. The published clients default to https://metrics.syvain.com.

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

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 $SYVAIN_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, 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 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. 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=$SYVAIN_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.foldertree.read {} the whole tree: root folders, each with child folders and experiments, and root experiments
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, at most 100,000
syvain.v2.query.experiments experimentIds, up to 500 records, and missingExperimentIds
syvain.v2.query.revisions experimentIds, up to 500 experiment and series revision counters, for cache checks

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.

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.

v1

The older REST API under /api/v1 remains available for existing integrations. New code uses v2.

Related: API events, organizations and keys, limits and fair use, the collector.