Skip to main content
Two surfaces are documented here:
  1. The local spool format: what the SDK writes to ./.cirron/. Public API, stable within a major SDK version, consumed by the Cirron ingestion worker and by any third-party tool.
  2. The platform wire schemas: what ends up in the Cirron database after ingestion. Useful when you’re writing queries, building a custom consumer, or exporting to your own storage.

Local spool format (v1)

Every spool file is valid RFC 8259 JSON. The SDK never emits the bare NaN, Infinity or -Infinity tokens that Python’s json module produces by default, so any conforming parser in any language can read a spool file. Non-finite floats are substituted instead; see marks[].value_nonfinite and snapshots[].stats.nonfinite below.

Directory layout

  • <created_ns>: wall-clock time the batch was sealed, nanoseconds since Unix epoch, zero-padded to 20 digits. Filenames sort lexicographically in chronological order; the flush thread uses this for oldest-first eviction when the spool cap is exceeded.
  • <batch_id>: 32-char lowercase hex (UUID4 without dashes).
  • Files are written via a .json.tmpos.replace() handoff, so a reader that opens a *.json file always sees a complete batch.
  • Readers MUST ignore *.json.tmp. A temp file is either a write currently in flight or, if its writer was hard-killed between the write and the rename (SIGKILL, OOM kill, node preemption, ENOSPC), an orphan holding a partial batch. Either way it is not a readable batch. Its bytes do count toward spool_max_bytes, and the SDK deletes any it finds older than one hour during a cap-enforcement pass. The age gate matters because every rank of a distributed run shares one spool directory, so a recent .json.tmp may be another rank’s in-flight write; an operator cleaning up by hand should apply the same rule.
  • The cap counts every byte the SDK put in the directory, sealed *.json and unsealed *.json.tmp alike. Orphaned temp files are swept before any batch is evicted, so a batch is never dropped to make room for garbage. If in-flight temp files alone meet or exceed the cap, the SDK logs a warning and evicts nothing, since dropping batches could not get the directory under the cap anyway.

Batch JSON

spans[]

cpu_ns, gpu_ns, and memory_peak_bytes default to null. gpu_ns is set by torch CUDA event pairs when profiling a CUDA forward/backward pass. cpu_ns and memory_peak_bytes are reserved and not populated today. mark_ids holds the IDs of every mark attached to this span.

marks[]

A mark attaches to the innermost open scope on the producing thread. When no scope is open, it attaches to the cirron.session root. Marks emitted before ci.profile() was called (or after shutdown()) use the legacy "root" sentinel instead of a real span ID. A diverged loss is legitimate data, and ci.mark("loss", float("nan")) records it. A float mark whose value is nan, inf or -inf is written as "value": null with a sibling "value_nonfinite" naming which one it was. value_type stays "float": the mark is still a float mark, and a reader that ignores the new field sees a correctly typed record with a missing value rather than a type contradiction. value_nonfinite is absent on every finite mark, so its presence is the only test a reader needs. Only value_type: "float" marks can carry it. Non-finite floats inside attrs, at any nesting depth, become the strings "nan" / "inf" / "-inf" instead. attrs is free-form and user-owned, so a companion field naming the substitution has nowhere to live without risking a collision with one of your own keys.

snapshots[]

mode values:
  • "stats": inline statistics only. blob_uri is null. Default.
  • "sampled": stats + a safetensors blob on random() < sample_rate epoch boundaries. Records that lose the roll stay mode="stats" with blob_uri=null.
  • "full": stats + blob every epoch. Debug-only; not recommended for 100M+ parameter models.
A diverged model produces nan / inf statistics, which is precisely the run you enabled snapshots to debug. Any of mean / std / min / max / norm that is non-finite is written as null, and a companion nonfinite object inside stats records which fields were affected and what they were:
nonfinite is absent when every statistic is finite. norm can be inf on its own: it is derived algebraically rather than by a second pass, so it can overflow on a large but entirely finite tensor while the other statistics stay meaningful. The histogram key is omitted entirely when the tensor’s extremes are non-finite. bins is a fixed-length array of numbers, so nulls inside it are not representable, and a histogram over a non-finite range carries no information anyway. When histogram is present it always has exactly 17 bins and 16 counts. Sampled and full write one safetensors file per (span, kind): ./.cirron/snapshots/<span_id>/weights.safetensors for weights and gradients.safetensors for gradients. Every record for that span shares the same blob_uri; tensor_name is used verbatim as the key inside the container, so consumers can call container[record["tensor_name"]] directly with no sanitization. Gradient records use tensor_name = "<param>.grad" (e.g. "layer1.0.conv1.weight.grad") and only appear when the gradient was non-None at capture time.

Canonical scope shape

Epoch spans are siblings under the session, never nested. When multiple framework hooks coexist (e.g. HF Trainer over a PyTorch DataLoader), only the highest-priority hook owns epoch and step (transformers > tensorflow > torch); others yield on those names so no semantic scope is duplicated. Operations executed before the training loop runs (warmup forwards, sanity checks, optimizer construction) have parent_id == session_id, not an epoch. No epoch exists yet; this is correct behavior, not a bug. For inference, the top-level scope per call is request instead of epoch.

Reading the spool

Forward compatibility

Readers must tolerate unknown top-level keys and unknown per-span / per-mark fields, so minor SDK bumps can add optional metadata. Removing or renaming existing fields, or changing their types, requires a schema_version bump and follows SemVer.

Wire format: POST /v1/traces

When the HTTP transport is active (external runs with an API key), the SDK batches spans / marks / snapshots into the same JSON shape documented above and posts it to POST /v1/traces on the Cirron platform API. The body wraps the batch like this:
Successful submissions return 202 Accepted with the batch ID. Idempotent by batch_id (24-hour dedupe window server-side), so retrying the same batch after a timeout is safe. Rate-limited responses return 429 with a Retry-After header the SDK respects via exponential backoff. For self-hosted installs, this is the full wire contract: a custom ingestion worker that accepts the above payload is sufficient to consume SDK traffic.

Platform wire schemas

After ingestion, traces land in these tables. Field names are camelCase (Prisma conventions); the SDK sends snake_case and the ingestion worker maps it.

TraceSpan

Indexes: (workspaceId, runId, startNs), (workspaceId, pipelineId, startNs), (workspaceId, deploymentId, startNs), (traceId, parentSpanId).

TraceMark

TraceSnapshot

Snapshot object-storage layout

Self-hosted deployments point at MinIO or on-prem S3-compatible storage using the same path scheme.