Configuration¶
For: whoever runs the process. 12
keys. One of them changes how the engine runs, and it is there to correct a number the runtime can read wrong, not to tune anything.
Precedence is flag > file > default. Everything works with no config file at
all; the file exists for what a flag cannot express, chiefly interpolation. The
flag spellings are in the CLI reference, which is
mira --help verbatim.
Every key¶
A closed set. Anything else stops the process at boot, naming the outermost key
it does not recognise and listing every key it knows — an empty section counts,
so { "cluster": {} } is unknown key "cluster".
| Key | Flag | Type | Default | What it sets |
|---|---|---|---|---|
node |
--node |
string | mira |
This replica's name. Hashed into the block directory name so that replicas sharing a volume cannot collide (see mira_core::block). |
listen.grpc |
--grpc |
host:port | 0.0.0.0:4317 |
Where OTLP/gRPC listens. |
listen.http |
--http |
host:port | 0.0.0.0:4318 |
Where OTLP/HTTP, the query API, the MCP endpoint and the web UI listen — one port, because they are one surface over one set of blocks. |
storage.dir |
--data-dir |
path | ./mira-data |
The block directory. It is the whole manifest: no catalogue, no index file, nothing outside it to keep in sync. |
storage.retention |
--retention |
duration | 7d |
How long a block is kept. Retention is a delete of whole blocks, so the oldest data disappears in block-sized steps rather than row by row. |
ingest.max_request_bytes |
--max-request-bytes |
size | 16MiB |
The largest export either listener will decode. See receiver::Receivers::max_request_bytes for why it is one number. |
ingest.queue |
--queue |
count | 128 |
How many exports may be queued for one signal's flusher before the next one has to wait for a slot — and is shed with a 503 only if none frees up within pipeline::ADMIT_WAIT. |
ingest.shards |
--shards |
count | 0 |
How many flushers a signal runs, or 0 for "one per two cores". |
ingest.wal |
--wal |
boolean | true |
Acknowledge an export once it is a frame in the write-ahead log, rather than once the block holding it has been published. |
telemetry.self |
--self-telemetry |
boolean | false |
Store this node's own telemetry in this node, as ordinary metrics. |
telemetry.interval |
--telemetry-interval |
duration | 15s |
How often Config::self_telemetry samples this node's counters. |
alerts.rules |
--alerts |
path | unset |
A KYAML file of alerting rules (crate::alert), or none. |
Durations are 500ms, 30s, 5m, 2h, 7d; a bare number is seconds.
Sizes are b, k/kb/kib, m/mb/mib, g/gb/gib,
case-insensitive; a bare number is bytes. Units are binary throughout, so MB
means MiB — every other size in this system is binary and a MB that meant
10^6 beside a page size that meant 2^20 would be a trap.
--wal and --self-telemetry are the flags that take no value: a file has to
be able to say false to undo what an inherited config turned on, and a flag is
only ever typed to turn something on.
There is no block size, flush interval, buffer depth, cache size or compaction
threshold, and there will not be. That is the self-driving half of principle 2
in Architecture section 1:
the two numbers an operator would most want to tune are constants in the binary,
with no path from this file to either. ingest.shards is not the exception it
looks like: the engine sizes it from the core count on its own, and the key
exists to tell it something available_parallelism cannot see rather than to
search for a better value.
The file¶
KYAML: a strict subset of YAML 1.2 with explicit {} and [], every string
double-quoted, and indentation that carries no meaning. Any YAML parser reads
it; no YAML parser can guess about it.
{
"node": "${env:HOSTNAME,mira-0}",
"listen": {
"grpc": "0.0.0.0:4317",
"http": "0.0.0.0:4318",
},
"storage": {
"dir": "/var/lib/mira/${node}",
"retention": "7d",
},
"ingest": {
"max_request_bytes": "16MiB",
"queue": "128",
"shards": "0",
"wal": "true",
},
"telemetry": {
"self": "false",
"interval": "15s",
},
"alerts": {
"rules": "/etc/mira/alerts.kyaml",
},
}
Trailing commas are allowed. Every value Mira reads is a string, and anything that arrived as another type is a startup error:
| written | what happens |
|---|---|
node: 0x1f |
refused — YAML resolved it to 31, and coercing back would be a different name |
node: False |
refused — coerced back it would be "false", a different case |
"storage": { "dir": {} } |
storage.dir: expected a string, found a map — quoting does not fix a shape |
"listen": "0.0.0.0:4317" |
listen: expected a map of settings, found a value |
node: null |
treated as absent; the default is used. So is "listen": null |
null meaning "absent, use the default" at every level is deliberate: it is how
a templating layer writes "not set".
Interpolation¶
| syntax | meaning |
|---|---|
${env:NAME} |
environment variable; missing is a startup error, not an empty string |
${env:NAME,default} |
everything after the first comma is the default — untrimmed, later commas included, and itself expanded |
${dotted.path} |
another key in this file, resolved recursively |
$${ |
a literal ${ |
Defaults nest: ${env:A,${env:B,fallback}} finds the matching }, not the
first one. An unbalanced ${ is a startup error quoting the string it could not
terminate, and a reference cycle is a startup error naming the cycle
(reference cycle: node -> a -> node). Resolution is lazy, so an unused key
with a broken reference does not stop the process booting.
There is no second MIRA_* override mechanism. ${env:...} covers every case
in one visible file.
ingest.wal¶
"true" or "false", and nothing else — no yes, no on, no 1. It chooses
when an export is acknowledged, which is the same thing as choosing what an
acknowledgement means. Read-your-writes holds either way: a query reads the open
block as well as the published ones.
On (the default). Acknowledged means logged: the export has been framed and
write(2)n to wal/, so it is in the page cache and the block that will hold
it is a background reorganisation of data already recoverable. p50 7 µs, p99
39 µs for a 4 KiB body. It survives the process dying, panic = "abort", SIGKILL and the OOM
killer. It does not survive power loss or a kernel panic for up to
WAL_SYNC_PERIOD (250 ms), which is how often the background flusher fsyncs.
Off. Acknowledged means published: the export is in a sealed, fsynced,
renamed block directory. Nothing acknowledged is lost by anything short of
losing the disk. The cost is latency — an export waits for its block to fill or
age out, so p50 is 657 ms and p99 is 2.6 s. On Apple targets a durable
acknowledgement cannot beat 4.2 ms in any case, because sync_all is
F_FULLFSYNC.
At boot the log is replayed into the flushers before either port is served, so
frames the last process logged but never sealed are re-ingested in order. Each
block records the log position it covers in its directory name — the fifth
field — so replay skips what is already stored and the log is truncated behind
the lowest such position across the three signals. There is still no manifest,
and recovery is still three readdirs.
alerts.rules¶
A path to a file of alert rules, or nothing. Nothing is the default, so
/api/v1/alerts answering an empty list means alerting is off here, not
everything is healthy; both UIs say so in those words.
That default is also the coordination mechanism. Nothing elects an evaluator, so in a fleet exactly one replica is given the key and it is the one that pages. Three replicas pointed at the same rules file send three copies of every alert.
A rules file that does not parse stops the process at boot, before anything is created, bound or mapped.
The schema¶
e2e/alerts.kyaml is the worked example — loaded by
make demo and parsed by a unit test, so it cannot drift. In outline:
{
every: 15s, # evaluation interval; default 15s
link_base: "https://mira.example.com", # where an alert's link points
notify: [
{ name: oncall, url: "http://...", format: slack }, # slack | discord
{ name: pager, url: "http://...", format: pagerduty, # | pagerduty | json
key: "${env:PD_ROUTING_KEY}" },
],
rules: [
{
name: shop-error-rate, # unique; the dedup key in every payload
query: { signal: traces, where: [ { field: status_code, eq: 2 } ] },
of: { signal: traces }, # the denominator; omit for a count rule
over: 1m, # the window each evaluation counts over
when: "ratio > 2%", # count | ratio, then > >= < <=
for: 30s, # how long it must hold before firing
severity: critical, # free text; PagerDuty maps four of them
notify: [ oncall ], # default: every target
},
],
}
query and of are /api/v1/query documents, verbatim. A rule may not set
from, to, limit or after inside one — over is the window and the
evaluator sets the rest, so those are refused rather than silently overwritten.
Two metrics and no more. count is how many records matched; ratio is query
over of. Between them they express every threshold this engine answers
exactly, percentiles included: p95(duration) > 250ms is exactly
|{d > 250ms}| / |d| > 5%, the same inequality written two ways. A ratio over
an empty denominator reads as 0, so zero traffic is not a 100% error rate.
Webhooks¶
One JSON POST per target when a rule starts firing and one when it stops, with a
10-second timeout and no retry — the next evaluation is along in every seconds
anyway. Slack and Discord get their own body shapes, PagerDuty gets Events v2
with dedup_key set to the rule name so a resolve closes the incident it
opened, and json gets Mira's own document.
An https:// target needs a build with --features webhook-tls. The default
build refuses one when the rules file is loaded, not at the first page; point
it at a local egress proxy and the default build is enough. The published
binaries and image are the default build, so the crate count and binary size in
the README describe what you downloaded.
ingest.max_request_bytes¶
The largest export Mira will decode, on either port: the axum body limit on
4318, tonic's max_decoding_message_size on 4317, and the ceiling on what a
gzip body may inflate to — one number, so a batch cannot succeed on one
transport and fail on the other.
The size of an export is a property of the sender, which the engine cannot
know. The default is eight times axum's and four times tonic's, and comfortably
above what a stock collector produces at its own default of 8192 records. Set it
too low and you lose data rather than throughput: 4318 answers 413, which OTLP
classes as permanent, so the exporter drops the batch instead of retrying. Both
error messages name this key.
ingest.queue¶
How many exports may be waiting for one signal's flushers, across all of them —
see ingest.shards below for how the depth is divided up. Full does not mean
refused: the next export waits up to five seconds (pipeline::ADMIT_WAIT) for a
slot, and only gets a 503 if none frees up. OTLP classes that as retryable, so
a collector backs off and sends it again — correct backpressure, and not a loss.
That wait is why the default of 128 is small and stays small. An earlier revision shed the moment the queue was full, and a wide collector fleet then spent its time being told to retry: 93% of exports refused at 96 connections, and a third of the two-connection rate, because every shed export is decoded, refused, retried and decoded again. Parking instead took the same row to nothing shed at double the throughput, and it has refused nothing in twenty-one consecutive runs of the sweep since — see End-to-end testing section 3 for the numbers and the box they were measured on.
So this knob buys queueing, not throughput. The flushers drain at the rate they
drain, and a queue deep enough to hide a permanently overloaded node has only
moved the shed into a latency tail. Size it to absorb a burst, not to avoid a
503: each slot can hold a decoded export, so the worst case is
ingest.queue * ingest.max_request_bytes * 3 resident — 6 GiB at the defaults,
and eight times that at 1024. Raising it spends memory an operator has and the
engine cannot know whether they have it, which is the whole reason it is a key
rather than a constant.
ingest.shards¶
How many flusher tasks each signal runs. 0, the default, means one per two
cores as the process sees them, capped at 16 (pipeline::MAX_SHARDS) — six per
signal on the 12-core box the numbers here were taken on, sixteen on anything
from 32 cores up.
Per two cores, because a flusher is a consumer and the protobuf decode in front of it is the producer: a single flusher per signal absorbed 1.35M records/s in the A/B in End-to-end testing section 3, and the anchors below burn 1.75 cores to produce that — so a flusher per core would leave most of them waiting on the decode. Architecture section 4 is the rest of it, including why a shard may be keyed on the core count and on nothing about the data.
So this is not a throughput knob: the engine already reads the number it wants,
and this key is here for the case where what it reads is a lie. A cgroup CPU
quota is invisible to available_parallelism, so a 96-core host running Mira at
2 CPUs would otherwise start the full sixteen flushers per signal and publish
sixteen files per seal window. Set it to the quota; 1 is the pre-shard
behaviour, exactly.
Shards split ingest.queue rather than multiplying it — each gets
queue / shards slots — so the worst-case resident bytes in the section above
are the same whatever this is. What setting it too high costs is blocks and
cores instead: a shard that takes work carries its own open block and publishes
its own file per seal window, and flushers past the core count compete with the
decode that feeds them. Setting it to 1 on a wide machine puts one flusher
back on the critical path of every export for that signal.
telemetry.self¶
Mira storing Mira's own telemetry, in Mira. Off by default; --self-telemetry
or "telemetry": { "self": "true" } turns it on, and
telemetry.interval (15s) is how often it samples.
There is no exporter, no scrape endpoint and no second port. A timer builds an
OTLP ExportMetricsServiceRequest and hands it to the same submit an incoming
export goes through, so the metrics land in ordinary metric blocks and every
existing surface reads them: /api/v1/metrics/names lists them, the TUI charts
them, an alert rule can fire on them, and an agent can ask about them through
MCP. It is the fastest way to see the engine work — mira --self-telemetry and
the metrics tab has content within one interval, with no collector at all.
What it emits, all prefixed mira.: uptime, process.memory.peak,
query.count, query.duration.max, query.duration.mean, storage.free,
storage.blocks, and per-signal ingest.rows, .bytes, .blocks, .shed,
.failed, .refused and .open_block.age. The signal is an attribute rather
than three metric names, so signal is a group-by and not a naming convention.
A measurement that could not be taken is left out rather than reported as zero.
The cost is honest and small: it is ingest, so it competes with real ingest for
the same flusher, and its rows are stored on the disk being measured. At the
default interval that is a few dozen points a minute. It counts itself — the
ingest.rows it reports include the rows it just wrote.
Sizing¶
Three axes driven by three different things: CPU by the record rate, memory by the number of concurrent exporters, and disk by the record rate again — through whichever of two costs applies. Every row is anchored to a measured point in End-to-end testing section 3, the median of three full passes on a 12-core M3 Pro; between the anchors it is linear interpolation and nothing more.
| Workload | Ingest | Exporters | CPU | Memory | Disk/day |
|---|---|---|---|---|---|
| Laptop, CI, one service | ≤ 10k records/s | 1 | 250m |
256Mi |
16 GiB |
| A team's services | 100k records/s | 1–2 | 500m |
512Mi |
1.3 TiB |
| A cluster | 600k records/s | 1–2 | 1 |
512Mi |
7.7 TiB |
| A busy cluster | 1.5M records/s | 4 | 2 |
1024Mi |
19 TiB |
| Past the plateau | 1.5M records/s | 8+ | 2500m |
2048Mi |
19 TiB |
Multiply the last column by storage.retention — 7 days by default — for the
volume. records/s is logs plus spans plus data points, which is what the
harness counts and what /api/v1/stats reports.
CPU is the record rate over one number. The engine's own rate is 886k records/s per core at one exporter and falls to 515k at 96, so budget 500k records/s per core and you are covered anywhere on that curve; under four exporters you will get closer to 850k. The measured anchors are 0.71 cores at 629k records/s, 1.75 at 1.35M and 2.23 at 1.54M. There is deliberately no CPU limit in the chart, for the reason its comment gives: throttling the ingest path does not shed load, it queues it.
Memory is the exporter count, not the rate. Peak RSS goes 232 MiB at one
exporter, 314 at two, 689 at four, 1,243 at eight — and then flattens: 1,575 MiB
at 16, 1,495 at 32 and 1,648 at 96, while the throughput over that same range
rises 2% and then falls by a quarter. What the extra connections buy is
in-flight decodes, not work, which is why the memory column tracks the middle
column and not the one beside it. Sizing from RSS is
conservative on purpose: Mira reads blocks through mmap, mapped pages are
clean, and a cgroup reclaims them under pressure instead of OOM-killing — which
is why a memory limit is safe here when it usually is not.
Disk has two costs, and the cutover is a throughput rather than an age. A
hot block costs 164 bytes on disk per 137-byte wire record; compaction rewrites
it ZSTD at 8.38x an hour later, taking the same record to about 20 bytes. But
the sweep compacts at most 8 blocks per signal per minute
(block::MAX_COMPACT_PER_SWEEP), and a block is sealed at a 32 MiB target,
which is ~205k rows (End-to-end testing section
3) — so one signal can be compacted at
~1.6M rows/min, about 27k records/s, which for the usual half-logs
half-spans stream is about 55k records/s in total. Below that, size the volume
at 20 B/record; above it compaction is permanently behind and the cost stays at
164. The first row above is the compacted number, the last four the hot one,
and that step is the whole reason the column jumps 80x between rows that
differ 10x in rate.
A full volume takes the replica out of the Service via /readyz rather than
losing data, so the failure mode of under-sizing is a stopped intake and not a
corrupt store. storage.retention is the knob that prevents it.
Several replicas¶
node is hashed into every block directory name
({min_ts}-{max_ts}-{node}-{seq}-{wal_hi}), which is what lets several active
replicas write to one volume without coordinating. The resolved name and its
hash are logged at startup:
Two replicas sharing a volume with the same node_id write duplicate data,
which OTLP's at-least-once contract already permits — the staging path carries
the block's timestamp range too, so they cannot mix each other's tables. Give
each replica a distinct --node anyway; that log line is where you check it.
On Kubernetes, one volume per replica. A StatefulSet gives each pod a stable
name and rebinds it to the same PVC across reschedules, so HOSTNAME names both
the replica and the disk its blocks are on:
{
"node": "${env:HOSTNAME}",
"storage": {
"dir": "/data", # where the PVC is mounted
"retention": "${env:MIRA_RETENTION,7d}",
},
}
volumeClaimTemplates: # StatefulSet.spec
- metadata: { name: data }
spec:
accessModes: ["ReadWriteOnce"]
resources: { requests: { storage: 100Gi } }
Ingest goes through one Service — any replica accepts any export. A query does
not: there is no fan-out, so a replica answers only from its own blocks.
Address a specific pod (a headless Service gives you mira-0.mira), or run one
replica.
Several pods sharing one /data is the topology the node_id exists for, and
the only one where any replica answers for all of them — but it is not
deployable on Kubernetes today. Every backend an RWX PVC is in practice — NFS
(EFS, Filestore, most CSI drivers), CephFS, Azure Files, Lustre, 9P — is on the
list block::fs_type refuses, because mmap over a network filesystem raises
SIGBUS with no recovery path. volumeMode: Block is not a way round it: a
Block PVC is a raw device under volumeDevices, unformatted and with no mount
path, so it can never be "dir": "/data" — and the cluster filesystems you
would format one with are on the same refuse list.