metsuke is a small agent you run beside cardano-node. It reads your node's Prometheus metrics endpoint over loopback and sends this server a signed submission. It never opens your node socket and never touches a key beyond the one signing key you point it at. It reads your node's journal only if you turn that on in step 5, and then only the trace lines your own configuration selects.
Steps 1 to 3 are decisions and on-chain work. Steps 4 to 9 are what you run on the machine your node is on.
One submission is a plain JSON header, then your scrapes zstd compressed, one
JSON object per line, with a detached Ed25519 signature over the whole byte
sequence. The header rides in a zstd skippable frame, so zstd -d
on a submission hands back the lines and nothing else. This is an example of the
whole thing: two scrapes, the first cut down to two metrics where a real line
carries every one your node exposes, the second a scrape that failed.
{
"agent_id": "relay-1",
"agent_version": "0.1.0",
"counter": 42,
"pool_id": "pool1ewf4s55a73efcvjx52sr8jucyx4ml5tdujygqpvsf27yza5rpqn",
"schema_version": 1,
"timestamp": "2026-05-28T20:26:40Z"
}
{"scraped_at":"2026-05-28T20:26:40Z","clock_offset_ms":-3,"failure":null,"metrics":[{"name":"cardano_node_metrics_blockNum_int","labels":{},"value":12318442,"declared_type":"gauge"},{"name":"cardano_node_metrics_tipBlock","labels":{"hash":"0e2b4b1a0e2b4b1a0e2b4b1a0e2b4b1a0e2b4b1a0e2b4b1a0e2b4b1a0e2b4b1a"},"value":1,"declared_type":"info"}],"metsuke":{"pool_id":"pool1ewf4s55a73efcvjx52sr8jucyx4ml5tdujygqpvsf27yza5rpqn","agent_id":"relay-1"}}
{"scraped_at":"2026-05-28T20:31:40Z","clock_offset_ms":null,"failure":{"reason":"unreachable","detail":"the endpoint did not answer: connection refused"},"metrics":[],"metsuke":{"pool_id":"pool1ewf4s55a73efcvjx52sr8jucyx4ml5tdujygqpvsf27yza5rpqn","agent_id":"relay-1"}}
One line per scrape, and its metrics are every metric your node
exposes on the endpoint you open in step 4 whose value JSON can hold. Those are
the same metric lines that command prints. Each entry carries the metric's
name, its labels and its value as your
node stated them, plus the declared_type its # TYPE
line gave where it had one. The agent reads nothing else on your machine. It
contributes two facts of its own, scraped_at, the time it scraped,
and clock_offset_ms, the offset its own NTP query measured.
A scrape that failed is itself a signal, so the submission is sent either way:
no
metrics, and failure naming what stopped it. Its
reason is one of unreachable, refused, too_large, unreadable, and its detail is the
message the agent had: the port, the status, or the size limit it was
configured with.
Every line names the pool and the machine that wrote it under
metsuke, as pool_id and agent_id, the
name you configure. One line read out of the archive on its own still says where
it came from. That is the only key metsuke claims on a line.
The header carries those same two, and four more: agent_version,
the build that scraped; counter, which submission of yours this is;
timestamp, when the submission was sealed; and
schema_version, which shape its lines are.
If you do step 5, trace lines travel as their own submissions: the same header
with schema_version 2, and then the lines you selected, one per
line, each the object your node wrote plus that same metsuke
key.
The signature travels beside the body in two headers:
x-metsuke-vkey and x-metsuke-signature. Anything between
your agent and this server has to pass both through unchanged, or the signature
will not verify. Your pool id is not among them. It is the hash of the key in
the first header, so this server derives it rather than taking your word for
it.
Your application to the rewards program carries an application code. Put the same code in your pool registration transaction's metadata, under label 674:
{"674": {"musashinet_incentives_application_code": "YOUR-CODE"}}
Only your cold key can sign a pool registration, so the two halves matching is what shows the application came from you. Until they do, this server refuses your submissions whatever key you sign them with.
Your pool's cold key signs submissions. That is the whole of it. A pool id is the hash of its cold verification key, so the key that signs is what says which pool a submission is for, and nothing else has to be looked up or believed.
The agent reads the key as a cardano-cli TextEnvelope file, the
pool.skey you already have. It refuses to start unless the key
hashes to the pool_id you configured, which is the same check this
server makes on every submission.
cardano-node exposes nothing to scrape until you add the backend. Add it to
your node configuration's TraceOptions, bound to loopback so it is
not reachable from anywhere else:
{
"TraceOptions": {
"": { "backends": ["Stdout MachineFormat", "PrometheusSimple 127.0.0.1 12798"] }
}
}
If your node configuration has no TraceOptions at all, paste that
as it stands. If it has one, merge into it rather than pasting over it. The
"" key is your node's root entry, so keep every other key it has
and add to its backends list.
Both backends have to end up in that list, and each replaces one of its own
kind rather than joining it. If your root already names a
PrometheusSimple or an Stdout backend, replace that one
instead of keeping both. If you would rather keep your own
PrometheusSimple, leave it and point step 7's
metrics_url at its port. Get this wrong and metrics still work,
step 5 looks applied, and not one trace line is ever collected.
Restart the node, then check it answers:
curl -s http://127.0.0.1:12798/metrics
The metrics endpoint is a periodic snapshot. It carries no per-event timestamps, so it cannot answer when an announcement arrived, when a block body and its closure were received, or when a quorum was reached. Those live in the node's trace stream, and the agent ships every field of the lines you select from it. It reads one field to decide, and it computes nothing from any of them.
Skip this step and the agent stays exactly as step 4 leaves it: metrics
only, and no read of your journal. To turn it on, the node has to emit those
traces in the first place. These are the namespaces it has to emit, again as
keys to merge into your TraceOptions:
{
"TraceOptions": {
"Consensus.LeiosKernel": { "severity": "Info", "maxFrequency": 0 },
"Consensus.LeiosPeer": { "severity": "Info", "maxFrequency": 0 },
"Forge.Loop.AdoptedBlock": { "severity": "Info", "maxFrequency": 0 },
"ChainDB.AddBlockEvent.AddedToCurrentChain": { "severity": "Info", "maxFrequency": 0 }
}
}
Check before you add them. Your configuration may already set some of these, and merging replaces a key's whole entry, so these take the place of whatever severity or rate limit you had on those namespaces. It may also set rate limits on namespaces this snippet does not name; pasting over the object rather than merging into it would drop those too.
Each namespace carries its own severity, so your node's root
threshold is left as you have it and nothing here depends on where you set it.
There is no "" key in this snippet, so it cannot disturb the root
entry step 4 touched. maxFrequency: 0 is not a typo, it means no
rate limit, and leaving it out silently caps the stream.
Restart the node. Its lines then go to the journal under its own unit, which
is what the agent's [log] section in step 7 points at. That read
costs the agent membership of the systemd-journal group, the one
privilege it holds beyond scraping loopback; if you would rather it did not
hold that, skip this step and leave [log] out.
The current agent is 0.1.0. On NixOS, add
github:input-output-hk/metsuke as a flake input and import its
nixosModules.metsuke, which writes the config and the unit for
you; the rest of this page is then a description of what that module does.
Anywhere else, take the static build for your architecture and put it where the unit expects it:
nix build github:input-output-hk/metsuke#metsuke-static-x86_64-linux sudo install -m 0755 result/bin/metsuke /usr/local/bin/metsuke
Substitute metsuke-static-aarch64-linux on ARM. There is no
install script and no self-update: updating is always something you do
deliberately.
Write this to /etc/metsuke/config.toml; its own comments say which values
you must set.
# metsuke agent configuration. Copy to /etc/metsuke/config.toml and set the # three required values; every commented line shows a default (pinned to the # code by a test in crates/metsuke/tests/config.rs). # Required: your pool id, bech32. pool_id = "pool1CHANGEME" # Required: the node's loopback PrometheusSimple endpoint. metrics_url = "http://127.0.0.1:12798/metrics" # Required: the metsuke-server submission endpoint. upload_url = "https://metsuke.example.org/v1/submit" # What to call this Agent on every line it ships, so a pool reporting from more # than one Agent can tell them apart. Defaults to this Agent's hostname, folded # to lowercase a-z0-9 in dash-separated runs; a value set here is folded the # same way. # agent_id = "edge-1" # Path to the signing key (cardano-cli TextEnvelope). It has to be the cold key # pool_id above hashes from, or the agent refuses to start. The --signing-key # flag overrides this: with systemd, leave this unset and pass the key as a # credential. See contrib/metsuke.service. # signing_key = "/etc/metsuke/pool.skey" # Cadences: scraping and uploading are independent. # scrape_interval_secs = 300 # upload_interval_secs = 3600 # SNTP clock-offset probe: servers tried in order, per-server timeout. # sntp_servers = ["time.cloudflare.com:123"] # sntp_timeout_secs = 5 # SQLite spool: scrapes wait here until the server acks them. Oldest bytes # beyond spool_max_bytes are dropped. spool_busy_timeout_secs is how long one # write waits for the other connection, which exists only with [log] below. # spool_path = "/var/lib/metsuke/spool.sqlite" # spool_max_bytes = 33554432 # spool_busy_timeout_secs = 5 # Scrape limits. # scrape_timeout_secs = 5 # scrape_max_body_bytes = 4194304 # Upload limits: request deadline, retry jitter bound after server/network # failures, and the clamp on the rejection backoff. upload_batch_max_bytes # counts the header frame and the payload before compression; the server's own # ceiling is [ingest].max_body_bytes on the compressed bytes # (contrib/server.example.toml), and nothing checks the two against each other. # Why the two count a line the same way: docs/adr/0010. # upload_timeout_secs = 60 # upload_jitter_max_secs = 300 # upload_backoff_max_secs = 86400 # upload_batch_max_bytes = 4194304 # zstd level for the upload body (0 = zstd's default). # compression_level = 0 # Trace-line collection, off unless this whole section is present. It reads the # node's journal, which the unit has to be allowed to do. The instructions # page says which two directives change, and the NixOS module makes both when # this section is set (docs/adr/0010). The node also has to be configured to # write these traces at all: that step is on the instructions page too. # # source picks the stream and is never guessed. With "journald", journal_unit # and journalctl_path have no default: name your node's unit, and give # journalctl's absolute path, because this unit's PATH is not one to resolve a # program on. # # With source = "pipe" the node runs as `cardano-node run | metsuke`: metsuke # reads the node's stdout, writes every line through to its own stdout # unchanged, and never makes the node wait. A line it has no room to queue is # dropped and counted, and pipe_queue_capacity is how many it may hold. It # reads no journal, so none of the journal keys may be set, and the NixOS module # does not render this source: the unit it writes has no node upstream of it. # # namespaces are prefixes and are the whole selection rule; namespace_roots is # this host's ceiling on them, and a namespaces entry outside it is refused at # startup. Both match on segment boundaries, so an entry names a namespace the # node emits or an ancestor of one, never a fragment of a name. Why severity is # not a second rule: docs/adr/0010. # # start_grace_secs is how long a just-started journalctl has to stay running # before the agent accepts it as following, so a unit that may not read the # journal fails the start rather than collecting nothing (docs/adr/0010). # [log] # source = "journald" # journal_unit = "cardano-node" # journalctl_path = "/usr/bin/journalctl" # namespace_roots = ["Consensus", "ChainDB", "Forge"] # namespaces = ["Consensus.LeiosKernel", "Consensus.LeiosPeer", "ChainDB.AddBlockEvent.AddedToCurrentChain", "Forge.Loop.AdoptedBlock"] # log_max_bytes = 268435456 # respawn_backoff_secs = 30 # start_grace_secs = 1
The upload URL is this server. Replace the example host with the host you are reading this page on. The metrics URL has to match the endpoint you opened in step 4, and has to be a loopback address. The agent refuses to scrape anything else.
This unit runs the agent with no privileges beyond reading its own config
and writing its spool; its own header says where to install it and how to hand
it the signing key. If you did step 5, two directives change: add
SupplementaryGroups=systemd-journal, and turn
ProcSubset=pid into ProcSubset=all. journalctl needs
both, and they are the whole difference; the NixOS module makes them for you
when [log] is set.
# Example hardened unit for a host that is not NixOS. Generated:
# edit nix/unit.nix, then `nix build .#metsuke-unit` and commit
# what it wrote here.
#
# Copy to /etc/systemd/system/metsuke.service, with the binary at
# /usr/local/bin/metsuke and the configuration at
# /etc/metsuke/config.toml.
#
# Optional, and what the NixOS module does: keep the signing key
# unreadable to the service user by loading it as a credential. Add
# LoadCredential=signing-key:/etc/metsuke/pool.skey
# append
# --signing-key ${CREDENTIALS_DIRECTORY}/signing-key
# to ExecStart, and leave signing_key out of config.toml.
[Unit]
Description=metsuke telemetry agent
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/metsuke --config /etc/metsuke/config.toml
Restart=always
RestartSec=30
AmbientCapabilities=
CapabilityBoundingSet=
DynamicUser=true
LockPersonality=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProcSubset=pid
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=invisible
ProtectSystem=strict
RestrictAddressFamilies=AF_INET
RestrictAddressFamilies=AF_INET6
RestrictAddressFamilies=AF_NETLINK
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
StateDirectory=metsuke
SupplementaryGroups=
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
SystemCallFilter=~@resources
UMask=0077
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable --now metsuke
The agent logs one line at startup naming the endpoint it scrapes and the pool it reports for, and one line per submission saying whether the server took it:
systemctl status metsuke journalctl -u metsuke -f
The first submission is sent as soon as the agent starts, so you do not have to wait out a cadence to find out that something is wrong. A refused submission logs the server's reason and the scrapes stay spooled. Nothing is lost while you fix it, and they upload once it is fixed.
This server was built against agent 0.1.0, and tells every agent that uploads to it which version that is. Yours logs a warning when it is older. To update, repeat step 6 and restart the service:
sudo systemctl restart metsuke
The spool is on disk, so queued scrapes survive the restart; nothing is scraped while the agent is down.