Metadata-Version: 2.4
Name: metrana
Version: 0.7.4
Summary: Inephany client library to use Metrana.
Author-email: Recurvia <info@recurvia.ai>
License: Apache 2.0
Project-URL: Homepage, https://recurvia.ai
Keywords: metrana,mlops,rlops,ml,metrics
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy<3.0.0,>=1.24.0
Requires-Dist: loguru<0.8.0,>=0.7.0
Requires-Dist: metrana-logging-engine<1.0.0,>=0.2.4
Requires-Dist: urllib3<3.0,>=1.26
Provides-Extra: rendering
Requires-Dist: av<18.0.0,>=12.0.0; extra == "rendering"
Provides-Extra: s3
Requires-Dist: boto3<2.0,>=1.34; extra == "s3"
Provides-Extra: dev
Requires-Dist: pytest<10.0.0,>=7.0.0; extra == "dev"
Requires-Dist: pytest-mock<4.0.0,>=3.10.0; extra == "dev"
Requires-Dist: bump-my-version==1.4.1; extra == "dev"
Requires-Dist: black==26.5.1; extra == "dev"
Requires-Dist: isort==8.0.1; extra == "dev"
Requires-Dist: flake8==7.3.0; extra == "dev"
Requires-Dist: pre-commit==4.6.0; extra == "dev"
Requires-Dist: mypy==2.1.0; extra == "dev"
Requires-Dist: typeguard==4.5.2; extra == "dev"
Dynamic: license-file

# Metrana Client Library

Metrana is a metrics tracking client for ML/RL training runs. It provides a simple API to log
metrics from training loops to the Metrana ingestion service. A Rust-backed engine batches points,
streams them over gRPC, retries transient failures, and can spool to disk — all on a background
thread, so logging stays cheap on the hot path while **favouring delivery**: by default Metrana keeps
trying to deliver your data and fails *loudly* if it truly can't, rather than dropping it silently
(see [Guarantees and retries](#guarantees-and-retries)). The on-disk spool — off by default, but
**strongly recommended for any real run** — is what lets a run *survive* a sustained backend outage;
without it, a long outage stalls and then fails your run (loudly, not silently).

## Installation

```bash
pip install metrana
```

Requires Python 3.10+.

**Supported platforms.** `metrana` depends on the native `metrana-logging-engine`, which ships
prebuilt wheels (there is no source distribution) for: Linux x86-64 and aarch64 (glibc `manylinux_2_28`
and musl `musllinux_1_2`), macOS x86-64 and Apple Silicon, and Windows x86-64. On any other platform
(e.g. Windows on ARM, or an older-than-`manylinux_2_28` Linux) `pip install` will fail to find a
compatible wheel — open an issue if you need a target added.

To log RL environment video with `metrana.log_rendering()`, install the optional `rendering`
extra (pulls in PyAV for client-side H.264 encoding):

```bash
pip install 'metrana[rendering]'
```

## Quick Start

### ML training run

```python
import metrana

metrana.init(
    api_key="your-api-key",
    workspace_name="my-workspace",
    project_name="my-project",
    run_name="run-001",
)

for step in range(num_steps):
    ...
    metrana.log("loss", loss)                      # one metric
    metrana.log({"accuracy": acc, "lr": lr})       # several at once

metrana.close()   # flush and shut down — always call this
```

### RL training run

```python
import metrana

metrana.init(api_key="...", workspace_name="ws", project_name="proj", run_name="rl-001")

for rl_step in range(num_updates):
    ...
    # one metric per episode
    metrana.log_rl_episode("episode_return", ep_return, rl_step=rl_step, episode=episode)

    # per-environment-step metrics for a batch of envs at once
    metrana.log_rl_environment_step(
        "reward", rewards, rl_step=rl_step, env_id=env_ids, episode=episodes
    )

metrana.close()
```

## Logging standard metrics

`metrana.log(metric_name, value, *, step=None, timestamp=None, scale=None, labels=None, evaluation=False, dtype=None)`
logs to the default ML-step scale.

**Values** may be a scalar or an array — a Python list or any NumPy / PyTorch / JAX / TensorFlow
tensor (any float dtype, on any device); arrays are converted to contiguous NumPy once before
crossing into the engine:

```python
metrana.log("loss", 0.5)                       # one point
metrana.log("loss", [0.51, 0.49, 0.48])        # three points (bulk)
metrana.log("grad_norm", grad_norm_tensor)     # torch/np/jax/tf tensor
```

**Storage precision** is `float32` by default for float values: metric signals don't carry more, and
it halves the wire and storage footprint. Values arriving wider are rounded (IEEE
round-to-nearest-even); `"float16"` rounds to half precision (travelling as float32). Integer-typed
values — token counts, cumulative sample counts — are exempt and ship as `float64` (exact to 2**53;
float32 would silently round counters past ~16.7M, 2**24). Override per call with
`dtype="float64"` / `"float32"` / `"float16"` (numpy spellings work too), or run-wide with
`metrana.init(..., default_dtype=...)`; an explicit dtype also overrides the integer exemption. `dtype="int64"`
/ `"int32"` declare a counter whose values arrive as floats (a division, a mean, a float metrics dict):
values truncate toward zero and ship as exact-to-2**53 float64 (NaN/inf are rejected — accepted as usual under float dtypes).

**Multiple metrics at once** — pass a mapping (values may themselves be scalars or arrays):

```python
metrana.log({"loss": loss, "accuracy": acc})
```

### Steps

A series is identified by `(metric_name, scale, labels)`, and each series has its own step axis. The
`step` argument controls it:

| `step` value      | meaning                                                            |
|-------------------|-------------------------------------------------------------------|
| `None` (default)  | auto-increment from the series' last step                          |
| a single `int`    | the step of the first point; further points in the call continue from it |
| a sequence/array  | an explicit step per point (length must match `value`)            |

```python
metrana.log("loss", 0.5)                        # auto: next step
metrana.log("loss", [a, b, c], step=100)        # steps 100, 101, 102
metrana.log("loss", [a, b, c], step=[10, 20, 30])  # explicit steps
```

Timestamps work the same way via `timestamp` (Unix **milliseconds**): `None` lets the server stamp
on arrival; a single int applies to every point; a sequence gives one per point.

### Scale, labels, and evaluation

These three arguments shape the series identity:

- **`scale`** — the step scale (a `StandardMetricScale` value: `"ML_STEP"`, `"EPISODE"`,
  `"ENVIRONMENT_STEP"`). `None` defaults to `ML_STEP`. Only `log` / `log_distributed` take it; the RL
  helpers fix their own scale.
- **`labels`** — a `dict[str, str]` that, together with the name and scale, identifies the series.
  Two points with the same name but different labels go to different series.
- **`evaluation`** — a shorthand that adds the label `{"evaluation": "true"}` (unless you already set
  the `evaluation` key in `labels`), so evaluation points form a series distinct from otherwise
  identically-identified training points.

```python
metrana.log("reward", train_reward)                       # training series
metrana.log("reward", eval_reward, evaluation=True)        # distinct eval series
metrana.log("reward", r, labels={"policy": "greedy"})      # distinct labelled series
```

### Retrieving the last step

`metrana.get_last_step(metric_name, scale=None, labels=None)` returns the last step logged for a
series, or `None` (pass the same `scale` / `labels` you logged it with). This is
seeded from the server at `init()`, so after a restart or resume you can continue from where the run
left off — useful when you want explicit steps but need to know the current position:

```python
last = metrana.get_last_step("loss")
next_step = 0 if last is None else last + 1
metrana.log("loss", loss, step=next_step)
```

### Closing

`metrana.close()` flushes queued points, marks the run closed, and shuts the background engine down,
returning a `CloseInfo` (see [Guarantees and retries](#guarantees-and-retries)). **Always call it** —
the engine runs on a daemon thread, so if the interpreter exits without `close()`, queued-but-unsent
points are lost (a warning is emitted at exit). Use it directly or rely on `try/finally`.

`close()` flushes for up to `close_timeout` seconds (default 180). To abandon a run immediately,
dropping anything not yet sent, pass `metrana.close(close_timeout=0)` — pair it with
`init(skip_drain_render_on_close=True)` if you also want queued rendering frames dropped rather than
encoded. To stop **without** closing the run so it can be resumed later use `metrana.shutdown_logger()`,
and to force a durability checkpoint mid-run use `metrana.flush()` — both are covered under
[Guarantees and retries](#guarantees-and-retries).

## Logging RL metrics

RL metrics use a two-level step: a major `rl_step` (the training/update step, which must not
decrease) plus a minor step. Two helpers cover the common scales; the scale is implied by the
function, so you never pass it explicitly. Both helpers also accept `labels` and `evaluation`, which
behave exactly as for [standard metrics](#scale-labels-and-evaluation), and `dtype`, which selects the
storage precision exactly as for `metrana.log` (float32 by default; see
[Logging standard metrics](#logging-standard-metrics)).

### Per-episode metrics

`metrana.log_rl_episode(metric_name, value, rl_step, episode=None, env_id=None, labels=None, evaluation=False, dtype=None)`
— the `episode` is the minor step. Omit it to auto-increment from the last logged episode:

```python
metrana.log_rl_episode("episode_return", ep_return, rl_step=rl_step, episode=episode)
```

### Per-environment-step metrics

`metrana.log_rl_environment_step(metric_name, value, rl_step, env_id=None, episode=None, env_step=None, auto_start_from_one=False, labels=None, evaluation=False, dtype=None)`
logs env-step-scale points. The env-step axis counts across `rl_step`s, bounded by episode: each
episode's steps keep increasing until the episode ends (without episodes, a single ever-increasing
axis per environment).

By default (`env_step=None`) env steps are numbered by the server, starting at 0 — pass
`auto_start_from_one=True` to number episodic steps from 1 instead. Pass `env_step` to number the
points yourself; explicit steps must increase within their episode, or they are deduplicated
server-side even though the call succeeds. Auto and explicit env steps must not be mixed on one
(environment, metric) — the first call pins the choice.

Re-collecting an episode from its start **restarts** it (its earlier env steps are discarded):
explicit steps signal a restart by dropping back to 0/1; auto steps by replaying the episode's
first `rl_step` batch, which is exactly the shape a from-scratch process restart produces.

It is vectorized over environments. Pass a list of env ids and a matching value block:

- single env: `env_id="env0"`, `value` a scalar or 1D `[T]` array;
- many envs: `env_id=["env0", "env1", ...]` (length `M`), `value` a 1D `[M]` (one point each) or 2D
  `[M, T]` array. `episode` / `env_step` (and `timestamp`) broadcast: a scalar, 1D, or 2D matching
  the value. Environments usually share `rl_step`s but not episode counters, so per-env (`[M]`) or
  per-point (`[M, T]`) episodes are the typical forms; a scalar episode fits lockstep setups only.

```python
# 8 envs, one reward each at this rl_step
metrana.log_rl_environment_step("reward", rewards_8, rl_step=rl_step,
                                env_id=env_ids, episode=episodes)

# 8 envs x 128 timesteps in one call
metrana.log_rl_environment_step("reward", rewards_8x128, rl_step=rl_step,
                                env_id=env_ids, episode=episodes_8x128)

# explicit env steps (your own numbering) for episode 7
metrana.log_rl_environment_step("reward", [0.1, 0.3], rl_step=rl_step,
                                episode=7, env_step=[12, 13])
```

`metrana.get_env_last_rl_step_and_episode(env_id)` returns `(last_rl_step, last_episode)` for an
environment (either may be `None`) — handy for computing explicit steps after a resume.

## Run configuration and attributes

```python
metrana.log_config({"optimizer": {"name": "adam", "lr": 3e-4}, "batch_size": 256})
metrana.set_tags(["baseline", "v2"])      # replace the tag set
metrana.add_tags(["ablation"])            # add without removing
metrana.remove_tags(["baseline"])         # remove
metrana.set_description("LR sweep, seed 0")
```

`config` passed to `init()` is logged the same way (nested dicts/lists flatten under `config/`).
These run-level attributes — along with the git commit SHA and any `tags`/`description` given to
`init()` — are applied **only by the process that creates the run**, so distributed siblings that
resume it never clobber them.

For arbitrary run attributes use `metrana.log_attributes(prefix_path, value)`. For
**per-environment** RL attributes (a distinct, env-scoped kind) use
`metrana.log_env_attributes(env_id, value, episode=None)`.

## Environment renderings

`metrana.log_rendering(frame, rl_step, episode, env_id=None)` appends a frame to a per-`(env_id,
episode)` H.264 `.mp4`, encoded on a dedicated background thread (never blocks the training loop).

- `frame`: a `uint8` NumPy array, `(H, W, 3)` RGB or `(H, W)` / `(H, W, 1)` grayscale. Width and
  height must be even (libx264 `yuv420p`).
- When the `(env_id, episode)` pair changes, the open encoder for that env is closed and a new one
  opened for the next episode.

Configure via `init()`: `rendering_output_dir`, `rendering_fps`, `rendering_max_concurrent_encoders`,
`rendering_queue_max_size`, `skip_drain_render_on_close`, `rendering_close_timeout`. Requires the
`rendering` extra.

## Naming rules

- **Metric names** identify a series together with the scale and labels. Use `/`-delimited prefixes
  to group related series (e.g. `train/loss`, `eval/loss` are distinct series); labels and the
  `evaluation` shorthand are an alternative way to split a name into distinct series.
- **Environment ids** appear in URLs, so they must be URL-safe segments.
- **Config / attribute paths** are `/`-delimited; keys must be non-empty and contain only
  `[a-zA-Z0-9._-:/]`.

## Distributed logging

When several processes (e.g. distributed-training ranks) log into one run, two pieces matter:

**1. They must agree on the run.** Every process that should share a run needs the same
`orchestration_id`. If you don't pass one, it is resolved automatically from
`METRANA_ORCHESTRATION_ID`, then the framework job ids `TORCHELASTIC_RUN_ID` / `SLURM_JOB_ID` /
`RAY_JOB_ID`, then a random token (which only descendants that inherit the environment will match).
The resolved value is published back to `METRANA_ORCHESTRATION_ID` so forked/spawned children
inherit it. With `resume_strategy="never"` (the default), the first process creates the run and the
rest resume it by matching this identifier; a genuinely different job hitting the same run name errors
instead of corrupting it.

```python
# torchrun / Slurm / Ray: nothing to do — the framework job id is picked up automatically.
metrana.init(api_key="...", workspace_name="ws", project_name="proj", run_name="run")

# Custom launcher: pass a token shared by all workers of the job.
metrana.init(..., orchestration_id="job-2025-06-23-abc")
```

**2. Choose the right log function for shared series.** Use `metrana.log_distributed(...)` (instead
of `metrana.log(...)`) when **multiple processes write to the same series** — for example all ranks
logging a global `loss`. It uses unordered, merge semantics so concurrent writers don't conflict.
Provide an explicit `step` (the global training step) so points from different ranks align on the
same axis:

```python
metrana.log_distributed("loss", loss, step=global_step)
```

Use plain `metrana.log(...)` for series owned by a single writer (it is ordered and can
auto-increment). Pin `logger_id` (e.g. one per rank) if you want the backend to distinguish a
restarted writer from a genuinely new concurrent one.

**3. RL metrics need an exclusive owner per (environment, episode).** The RL functions
(`metrana.log_rl_episode(...)` and `metrana.log_rl_environment_step(...)`) are **ordered**, and the unit
of ownership is the **(environment, episode) pair**: each episode of each environment must be logged by
exactly one process. Sharding whole environments across ranks (e.g. a vectorized env split over workers)
satisfies this automatically. Sharding **episodes** of one environment across parallel rollout workers —
each worker running its own episodes of `cartpole` — works too; each point just has to carry an explicit
`episode`. In `log_rl_episode` an explicit episode switches the series to unordered (merge) semantics:
you own the episode numbering, and parallel workers' points merge cleanly instead of contending for one
auto-increment cursor. In `log_rl_environment_step` nothing special is needed — log normally with
`episode=...` set; env steps are numbered within each episode, so writers that own different episodes
never contend.

**Episode restarts are the main risk with parallel loggers.** An episode that logs its first env-step
again is treated as an **episode restart**: the server discards the episode's earlier points and starts
it over (this is how a crashed-and-restarted worker recovers). Two processes writing the same episode of
one environment therefore don't just interleave — the second writer looks like a restart and silently
wipes what the first one logged. Keep episode ownership disjoint.

Plain `metrana.log(...)` / `metrana.log_distributed(...)` float series have no per-env restriction
(`log_distributed` is explicitly built for many writers on one series).

## Guarantees and retries

Metrana favours **delivering your data** over shaving microseconds off the hot path — while never
blocking it *forever*. The defaults are chosen so a healthy run pays almost nothing and an unhealthy
one fails **loudly** instead of silently dropping metrics:

- **Backpressure** (`backpressure_strategy`, default `"block"`): when the in-process queue is full an
  enqueue waits for room rather than dropping. This is not an unbounded stall — the queue only stays
  full while delivery is stuck, which is itself bounded by the retry policy below (and, with a disk
  spool, drained quickly to disk). Alternatives: `"drop_new"` (drop the new points — protects the
  loop, can lose data under sustained pressure) and `"raise"` (raise `MetranaEventQueueFullError`).
- **Retries** (`max_send_retries`, default `15`): a failed gRPC send is retried with exponential
  backoff (`send_retry_initial_backoff_secs` → `send_retry_max_backoff_secs`, ≈1.7 min total at the
  defaults). When the budget is spent the logger **fails over to the disk spool** if one is
  configured (below); otherwise the delivery policy applies. `max_send_retries=None` retries gRPC
  forever.
- **Delivery policy** (`delivery_policy`, default `"all_or_crash"`): what happens once *every*
  channel has given up. `"all_or_crash"` stops the logger so the next `log` / `flush` / `close`
  raises `MetranaSenderError` — for an experiment tracker a loud failure beats a silent hole.
  `"tolerate_loss"` drops the data and keeps the process running, for callers who would rather lose
  metrics than the job.
- **Rendering errors** (`rendering_error_strategy`, default `"warn"`): governs *only* the Python
  rendering pipeline's frame-encoding errors (`"silent"`, `"warn"`, `"raise_on_log"`,
  `"raise_on_close"`). Engine delivery is governed by `delivery_policy`, not this.

With the defaults (`block` + `all_or_crash`) and **no** disk spool, data survives *transient* outages
(they're retried), and the only *silent*-loss path is a hard process kill (`kill -9`, or exiting
without `close()`). But a **sustained** outage — longer than the ~2-min retry budget — still loses the
buffered window, *loudly*: the loop blocks, then `all_or_crash` stops the logger and the next call
raises. So without a spool, "favouring delivery" means *fail loud*, not *survive*. A **disk spool** is
what closes that gap, turning a sustained outage into a transparent failover. (Or opt into
`tolerate_loss` / `drop_new` to drop-and-continue instead of block-then-crash.)

### Lifecycle: flush, close, shutdown

All three accept a timeout (`timeout_secs` for `flush`, `close_timeout` for `close` /
`shutdown_logger`) bounding how long they wait; pass `None` to wait without bound. The defaults are
finite so an unreachable backend can never hang interpreter shutdown forever.

- **`metrana.flush(timeout_secs=180)`** — a durability barrier: it returns only once everything logged
  *before* it is durable (acknowledged by the backend, or — with a disk spool — fsync'd to it). It
  rides out a transient outage (a reconnect does not fail it) and raises only if that data is
  actually dropped (`MetranaSenderError`) or the timeout elapses (`MetranaTimeoutError` — **not** loss;
  the data is still in flight or on the spool). The logger stays usable afterwards. **Call it at checkpoints**: a `flush()` next to
  your checkpoint write makes the metrics leading up to the checkpoint durable together, so a resumed
  run lines up with its metrics.
- **`metrana.close(close_timeout=180)`** — closes the **run**: flush, mark it closed, stop the engine.
  Returns a `CloseInfo`. Always call it (see [Closing](#closing)).
- **`metrana.shutdown_logger(close_timeout=180)`** — like `close`, but **leaves the run open** so a
  later process can resume it. Use it for a graceful, resumable stop — e.g. on a **spot-instance**
  preemption signal, call it to flush in-flight data and stop cleanly instead of being killed
  mid-send, then `init()` the same run when the job restarts. Returns a `CloseInfo`.

`close()` and `shutdown_logger()` (and `replay_spool`, below) return a **`CloseInfo`**:

```python
info = metrana.close()
print(info.metrics)                  # final self-metrics snapshot (see "Tuning and observability")
if info.has_undelivered_spool:       # data still on the disk spool, not yet seen by the backend
    print(f"{info.leftover_segments} spool segments ({info.leftover_bytes} bytes) left to replay")
```

`has_undelivered_spool` is `True` when the disk spool still holds data the recovery channel could not
deliver within the close window — a future resume or `replay_spool` will deliver it. It is always
`False` when no disk spool is configured.

**Sizing timeouts for strong guarantees.** During a backend outage data is not spooled instantly — it
first exhausts the gRPC retry budget (**up to ~2 minutes** at the defaults), *then* fails over to the
disk spool. So a `flush` / `close` / `shutdown_logger` timeout shorter than that window can elapse *before* the data
ever reaches disk. The two surface differently: `flush` raises `MetranaTimeoutError` (benign — the data
is still in flight; just re-flush or close), while `close` / `shutdown_logger` raise `MetranaClosingError`
**only** if their deadline elapses with data still not durable — neither acknowledged by the backend nor
written to the disk spool — which means likely loss. If the data reached the spool before the deadline they
do **not** raise: they return a `CloseInfo` and log that the data is safe on disk, awaiting delivery. For strong guarantees, configure a **disk spool** *and* give these
calls a timeout at least as long as the failover window (≥ ~2–3 minutes): an outage at close-time then
lands the data on disk — recoverable on the next resume / via `replay_spool` — instead of cutting the
failover short.

### Disk spool (strongly recommended)

The disk spool is an **extra, encouraged delivery layer** that turns "deliver or fail" into "deliver,
eventually, through almost anything". Point `init()` at a directory to enable it:

```python
metrana.init(..., disk="/var/spool/metrana")          # sensible defaults
# or tune it:
metrana.init(..., disk=metrana.DiskConfig("/var/spool/metrana", max_size_bytes=16 << 30))
```

Each run gets its **own sub-directory** under that path — the spool actually lives at
`<dir>/<workspace>/<project>/<run_name>` — so many runs can safely share one base `disk=` directory
without ever mixing segments.

**Multiple processes logging to one run?** A single logging process needs no thought — run it however
you like. For multiple processes writing the *same* run, put each in its own container *before* enabling
`disk`: a per-pod volume (the ReadWriteOnce default) gives each its own spool filesystem, so they can't
collide, and that isolation travels with your deployment config instead of a flag someone can forget.
The only way to collide is to physically share one spool directory between concurrent writers (e.g.
several ranks pointed at one network filesystem); if you must, give each a distinct base `disk=` path.
`fork()` is handled for you: a forked child that logs gets a fresh logger with **no spool** (it logs
straight to gRPC), so it can never collide with the parent's spool — but it therefore won't spool either,
so for a worker that needs durable spooling start a **fresh process** (its own `init()`), not a fork. In
practice a forked DataLoader/`multiprocessing` worker shouldn't be logging metrics at all — the main
process is the logger.

**How it works.** While gRPC is healthy, data goes straight over the wire and the spool is unused.
When delivery gives up (the gRPC retry budget is spent), the logger **fails over to the spool** —
every subsequent point is fsync'd to disk instead of dropped — and a background **recovery channel**
keeps trying to re-establish gRPC. Once the wire returns it drains the spool to the backend, in
order, switches delivery back to gRPC, and reclaims the disk as it goes.

Because the spool sits *behind* the delivery decision, it protects against **every** backend-side
failure, not just network blips — including ones retries alone can never fix: an expired token, an
**accidentally revoked authorization**, an *unauthenticated* rejection, a backend refusing requests,
a regional outage. The data waits on disk and the recovery channel retries reconnecting
**indefinitely**, so the moment the problem is fixed (token rotated, permissions restored) the
backlog ships automatically. Nothing is dropped while there is room on disk (bounded by
`max_size_bytes`).

If the outage outlasts the run — you `close()` (or the process ends) while data is still stranded —
Metrana **warns**, and the `CloseInfo` reports `has_undelivered_spool`. The data is safe on disk;
deliver it later by **replaying the spool**:

```python
import metrana

# Re-deliver a leftover spool to its run. No prior init() needed; the identity args must
# match the run that produced the spool, and the run must already exist on the backend.
info = metrana.replay_spool(
    "/var/spool/metrana",
    workspace_name="my-team",
    project_name="my-project",
    run_name="run-2026-06-28",
    api_key="...",            # or via METRANA_API_KEY
)
print(info.metrics.float_points_sent, "points re-delivered")
```

The spool is **durable across process restarts**: if a process dies while data is spooled and the
next process starts with the same `disk=` directory, it resumes from the leftover spool and keeps
delivering — no manual replay needed for the crash-and-restart case.

**Offline startup.** A *restarted* process can keep going without the backend — but **only when the
previous process left data on the spool** (i.e. it was already failing over to disk when it stopped).
It then resumes straight from that leftover spool, skipping the backend handshake. Otherwise `init()`
must reach the backend at startup and **fails if it is unreachable** — this covers a brand-**new** run,
and also resuming a run that stopped while the backend was reachable (so the spool was empty/drained,
leaving nothing on disk to resume from). In short: the spool covers losing the backend *during* a run,
including across a crash-and-restart, but not creating or resuming a run from a cold start with no
prior spooled data. (Initialising a fresh run fully offline is a candidate for a future release.)

## Logging at scale

When you log a lot — many series, high step rates, many environments — a few habits keep the overhead
negligible:

- **Log in bulk, not point-by-point.** Every `log` call crosses from Python into the engine; one call
  with an array amortises that far better than a loop of scalar calls. Pass a 1-D array of values
  (with a matching `step` range or array) instead of calling `log` once per step, and for RL prefer
  the **vectorized** `log_rl_environment_step(..., env_id=[...], value=grid)` over a per-env loop — it
  takes a `[num_envs, T]` block in a single call (see [Logging RL metrics](#logging-rl-metrics)).
  Contiguous arrays already in the target dtype (`float32` by default) are handed to the engine
  without a copy, so wide batches are cheap.
- **Don't flush on the hot path.** `flush()` is a synchronous barrier — it waits for delivery /
  durability, so calling it every step serialises your loop against the network. Flush at
  **checkpoints** (or every few minutes), not every step; let the background engine batch and stream
  everything in between.
- **Give the engine room.** Under sustained high rates raise `queue_capacity` (more buffer before
  backpressure) and `max_pending_requests` (more in-flight streams); `batch_max_age_secs` controls how
  long points coalesce before a send. Watch `get_metrics()` (*added* vs *sent*, and `*_dropped`) to
  confirm you are keeping up — see [Tuning and observability](#tuning-and-observability).

## Tuning and observability

- `max_pending_requests` (default `30`): in-flight streaming requests — raise it to push more
  throughput when the backend is the bottleneck.
- `queue_capacity` (default `10_000`): in-process point buffer depth.
- `batch_max_age_secs` (default `1.0`): how long points wait to coalesce into a batch before sending.
- `max_msg_size`: max serialized request size in bytes.

`metrana.get_metrics()` returns a point-in-time snapshot of the engine's self-metrics. For each data
kind (`float_points`, `rl_float_points`, `attribute_updates`, `env_attribute_updates`) it reports how
much was *added* (attempted), *enqueued*, *sent* (server-acked over gRPC), and *dropped* (shed under
backpressure or after a channel gave up). With a disk spool configured it also reports
*disk_persisted* (made durable on the spool, not yet seen by the backend) and the recovery counters
`recovered_spool_segments` / `recovered_spool_messages` (drained from the spool back to the backend),
alongside transport/health counters (`connection_attempts`, `requests_sent`, `send_errors`).

Comparing *added* vs *sent* tells you whether anything was lost:

```python
m = metrana.get_metrics()
print(m.float_points_added, m.float_points_sent, m.float_points_dropped)
```

The counters are monotonic, so diff two snapshots over a window to get rates (e.g. for a periodic
health log):

```python
import time

prev = metrana.get_metrics()
time.sleep(10)
now = metrana.get_metrics()
sent_per_sec = (now.float_points_sent - prev.float_points_sent) / 10
backlog = now.float_points_added - now.float_points_sent      # added but not yet acked
if now.float_points_dropped > prev.float_points_dropped:
    print("data loss in the last window — enable a disk spool, or raise queue_capacity / retries")
```

## Environment Variables

| Variable                          | Equivalent `init()` argument        |
|-----------------------------------|-------------------------------------|
| `METRANA_API_KEY`                 | `api_key`                           |
| `METRANA_ORCHESTRATION_ID`        | `orchestration_id`                  |
| `METRANA_BACKPRESSURE_STRATEGY`   | `backpressure_strategy`             |
| `METRANA_DELIVERY_POLICY`         | `delivery_policy`                   |
| `METRANA_RENDERING_ERROR_STRATEGY`| `rendering_error_strategy`          |
| `METRANA_RESUME_STRATEGY`         | `resume_strategy`                   |
| `METRANA_LOG_LEVEL`               | `log_level`                         |
| `METRANA_EVENT_QUEUE_MAX_SIZE`    | `queue_capacity`                    |
| `METRANA_SKIP_DRAIN_RENDER_ON_CLOSE` | `skip_drain_render_on_close`     |
| `METRANA_RENDERING_CLOSE_TIMEOUT` | `rendering_close_timeout`           |
