Observability reference¶
WaWarden reports everything on standard output as JSON lines: log events, notification events, audit lines and, optionally, metrics in CloudWatch's embedded metric format (EMF). This page describes those lines, the webhook that also receives notification events, the Prometheus endpoint, and the audit chain that records every client change and request. Terms such as chain head, shipped head and key id are explained in the glossary.
Contents¶
Logging¶
The service writes JSON lines to standard output, one object per line. Every line has these keys:
| Key | Content |
|---|---|
time |
RFC 3339 timestamp in UTC with fractional seconds |
level |
DEBUG, INFO, WARN or ERROR |
msg |
A human-readable sentence; it may change between releases |
event |
A stable, machine-readable event name; match on this, not on msg |
The audit lines are the one exception: they carry the event
audit and their own fixed keys, with no time, level or msg.
WAWARDEN_LOG_LEVEL sets the lowest level written, with exceptions that ignore
the setting. The startup_refused line is written before the configuration is
accepted. The dev_build, fake_engine, listener_not_loopback and
backup_disabled warnings are written at every level, error included, because
they are the only signal that a development binary is running, that it runs the
fake engine, that a listener is reachable beyond loopback, or that no backup
is taken. So are the notification events, which an operator must see,
the notify_failed and notify_dropped warnings, and the unsafe_debug and unsafe_debug_ended
notices of a debug window. EMF
lines have none of the four keys.
| Event | Level | Other keys | Written when |
|---|---|---|---|
startup_refused |
ERROR |
reason, error |
The configuration or the master key is refused; exit 2. |
keys_loaded |
INFO |
key_id, retired |
The master key is loaded and the log pseudonyms are keyed with it; key_id is the 8-digit key id, never a key, and retired the number of retired keys loaded. |
db_lock_wait |
WARN |
database (archive or session), within |
Another process holds the lock of the archive or the device store; serve retries until within (5m0s) has passed. Logged once per database and start. |
archive_opened |
INFO |
schema_version, profile, ofd_locking, recent_starts |
The archive is open and locked. ofd_locking is true when open-file-description locks are in use, and recent_starts counts the starts of the last ten minutes, this one included, at most 64. |
session_opened |
INFO |
paired |
The device store is open, locked and up to date; paired is true when it holds a linked device. |
fake_engine |
WARN, at every log level |
wrong_account |
A development build runs the fake engine in place of WhatsApp and opens no device store; wrong_account is true when the fake pairs a number that is not the owner's. |
startup_failed |
ERROR |
error |
The archive or the device store cannot be opened or its lock was not acquired in time, the device store cannot be brought up to date, or a listener cannot be opened, for example because its address is in use; exit 1. |
starting |
INFO |
version, revision, modified, dev |
The listeners are open. |
dev_build |
WARN, at every log level |
The binary was built with the dev tag. |
|
listening |
INFO |
listener, address |
Once per open listener. |
listener_not_loopback |
WARN, at every log level |
listener, address |
Once per client or admin listener bound to a non-loopback address. |
ready |
INFO |
The listeners are serving. | |
listener_failed |
ERROR |
error |
A listener stopped serving on its own; shutdown follows. |
shutdown_started |
INFO |
Shutdown begins. | |
stopped |
INFO, or ERROR with error |
Shutdown ended. | |
panic |
ERROR |
name, panic_type, stack |
A panic was recovered in a handler or a goroutine; name is as in wawarden_panics_total. |
http_server_error |
WARN |
listener |
Go's HTTP server reported an error of its own, such as a failed accept; msg holds the server's text. |
db_deadline |
ERROR |
database, operation, timeout_ms, goroutines |
A read of the archive ran beyond 2 seconds, a write beyond 10 or a rewrite of its full-text index beyond 5 minutes. It is written when the deadline passes, while the call still runs, and the call is interrupted and fails, unless it is waiting on the filesystem or committing, or the deadline passed just as a statement started; see Message archive. operation names the call in the code, and goroutines holds the goroutine profile of the process (function names and source positions, cut at 32 KiB). |
db_lost |
ERROR |
database |
The connection that held the lock of the archive or the device store is gone, and the service refuses to open another; /healthz answers 503 from then on. |
log_dropped |
WARN |
reason: xml or too_long |
Replaces a line that carried XML (xml) or was longer than 65,536 bytes (too_long); see Pseudonyms and dropped lines. It is written in place of a line that passed the log level, whatever that line's level was. |
unpaired |
WARN, at every log level |
A notification event. | |
engine_state |
INFO |
state, reason |
The engine's state changed; reason is empty unless the state is disconnected. |
disconnected |
WARN, at every log level |
reason |
A notification event. |
version_updated |
INFO |
version |
The engine took a newer WhatsApp Web version, such as 2.3000.1027000000. |
version_refresh_failed |
WARN |
attempt |
A version fetch failed or returned no usable version. |
connect_failed |
WARN |
attempt, error_type |
A connection attempt failed; the engine retries. error_type is the Go type of the error, never its text. |
pairing_started |
INFO |
A pairing attempt passed the refusals above. The code is never logged. | |
pair_failed |
WARN |
error_type |
Connecting or requesting the pairing code failed. |
pair_rejected |
WARN, at every log level |
stage |
A notification event. |
logout_failed |
WARN, at every log level |
attempt, error_type |
A notification event. |
ingest_failed |
WARN |
queue: inbox or history, attempt when an attempt failed, error_type |
Reading, applying or quarantining an inbox row or a history blob failed. |
quarantine |
WARN, at every log level |
queue, attempts |
A notification event. |
rekey_conflict |
WARN, at every log level |
conflict |
A notification event. |
ingest_paused |
WARN, at every log level |
free_bytes, floor_bytes |
A notification event. |
backup_done |
INFO, at every log level |
bytes, archive_bytes, session_bytes, duration_ms |
A notification event. |
backup_failed |
WARN, at every log level |
reason |
A notification event. |
backup_pruned |
INFO, at every log level |
removed, kept |
A notification event. |
backup_prune_failed |
WARN, at every log level |
reason |
A notification event. |
backup_skipped |
INFO |
reason: unchanged |
Neither database was written since this process's last backup, so the nightly backup of this slot is skipped. Logged once per slot. |
audit_pruned |
INFO |
through, rows, anchor_head |
The nightly audit retention deleted rows audit rows; the audit anchor now names row through, and anchor_head is the first 16 bytes of that row's HMAC, as its line's chain_head was. Not logged when it deleted none. |
audit_prune_refused |
WARN |
reason: chain_mismatch |
The nightly audit retention found that the audit anchor, or a row it was about to remove, does not verify under the master key and the retired keys, so it moved and removed nothing more and tries again at the next slot; an audit_pruned line the same night reports what earlier batches removed; run audit verify. Logged once per slot. |
history_blobs_pruned |
INFO |
rows |
The nightly prune deleted rows history blob rows processed more than 30 days before. Not logged when it deleted none. |
ingest_resumed |
INFO |
free_bytes, floor_bytes |
The free space reached 1.25 times the floor again. |
free_space_unknown |
WARN |
error_type |
The free space of the data directory could not be read. |
index_rewrite_pending |
WARN |
operation |
A change committed, but the full-text index rewrite it requires failed; it stays due and runs at the next start. |
history_ack_failed |
WARN |
error_type |
Sending the history receipt failed; the blob is processed anyway. |
history_file_kept |
WARN |
error_type |
A history file could not be deleted, or the directory could not be flushed after a deletion. The engine deletes a file whose blob is no longer waiting at its next start and once an hour while it runs. |
expiry_sweep_failed |
WARN |
error_type |
Removing the text of expired messages failed; the sweep runs again a minute later. |
whatsmeow_log |
the line's own: ERROR, WARN, INFO, or DEBUG in a debug window |
module, detail |
A log line of the protocol library; see Protocol library logs. |
unsafe_debug |
WARN, at every log level |
minutes, until |
WAWARDEN_UNSAFE_DEBUG opened a debug window at the start. |
unsafe_rate_caps |
WARN, at every log level |
read_per_minute, search_per_minute |
WAWARDEN_UNSAFE_RATE_CAPS=1 lifted the upper caps of the read budgets. Logged once per start. |
unsafe_send_caps |
WARN, at every log level |
send_global_per_hour, send_per_client_per_minute |
WAWARDEN_UNSAFE_SEND_CAPS=1 lifted the upper caps of the send limits. Logged once per start. |
unsafe_debug_ended |
WARN, at every log level |
The debug window has passed, and the protocol library's debug output is discarded again. Logged at the first debug line after the window. | |
audit_key_changed |
INFO or WARN, at every log level |
previous_key_id, key_id, previous_key |
A notification event. |
audit_key_unread |
WARN |
error_type |
At start, the key id of the newest audit row could not be read, so audit_key_changed was not checked. |
admin_mutation |
INFO, at every log level |
action, outcome |
A notification event. |
admin_auth_failure |
WARN, at every log level |
count |
A notification event. |
send_outcome_unknown |
WARN, at every log level |
reason |
A notification event. |
send_unrecorded |
WARN, at every log level |
A notification event. | |
notify_failed |
WARN, at every log level |
attempts, reason, status |
The webhook did not take an event, which is dropped. |
notify_dropped |
WARN, at every log level |
count |
At shutdown, the webhook dropped count events it had not delivered, and serve exits 1. |
emf_failed |
WARN |
error_type |
Writing the EMF lines failed. |
backup_disabled |
WARN, at every log level |
WAWARDEN_BACKUP_AGE_RECIPIENT is not set, or the fake engine runs and opens no device store, so no backup is ever taken. Logged once per start. |
|
backup_check_failed |
WARN |
error_type |
A step of the backup scheduler failed: reading or recording whether a backup is due, the prune of old backups (also reported as backup_prune_failed), the audit retention or the prune of history blob rows; the scheduler runs again 30 seconds later. |
audit |
none, written at every log level | ts, client, action, chat_hmac, ok, reason, key_id, chain_head |
An audit row was committed; the line has no time, level or msg. |
What is never logged: requests (there is no access log), request bodies, header
values, tokens, failed authentications other than the count in
admin_auth_failure, the admin token's hash, the webhook's URL and secret, the values of
refused variables (a refusal names the variable only), panic values (only their
Go type and the stack), the master key and the keys derived from it (only the key
id), pairing codes and pairing QR codes, message text and push names (the engine logs states, reasons,
counts and the Go types of errors, never their text; the protocol library's lines are
treated as described below), WhatsApp identifiers in their user@server form when no ASCII letter or
digit follows the server name (they become
pseudonyms) and lines carrying XML in one of
the recognised shapes. The listen addresses are the only configuration values that
are logged: the listening and listener_not_loopback events carry the bound
address, and the error texts of startup_failed and listener_failed come
from the operating system and can contain a listen address.
Protocol library logs¶
The protocol library logs through an adapter into the same writer as every other
line, as whatsmeow_log events: module names the protocol library's component, such as
whatsmeow/Client/Socket, and detail holds its formatted text after four
changes. First every client or admin token in it is redacted as the writer
does (see Pseudonyms and dropped lines), so
that masking its digits cannot leave the rest of its secret unrecognised. Then
every run of six or more digits, with . and : between them, that is
not directly followed by @ becomes [number], so that a phone number or a
user-and-device address outside the user@server form does not pass; a run
followed by @ is an identifier, which the writer turns into a pseudonym. Then
text beyond 1 KiB is cut at the last white space before that limit and ends in
[truncated], so that an error quoting a server's response stays short and no
identifier is cut in half; a text without white space in its first 1 KiB becomes
[truncated]. Finally the writer pseudonymises identifiers and drops lines
carrying XML, such as the protocol library's dumps of protocol stanzas, as for any other
line. Its error, warning and information lines follow WAWARDEN_LOG_LEVEL,
during a WAWARDEN_UNSAFE_DEBUG window too.
Its debug output is discarded at every log level unless WAWARDEN_UNSAFE_DEBUG
opens a window, in minutes, at the start. During the window the protocol library's debug
lines are written at level DEBUG, whatever WAWARDEN_LOG_LEVEL is set to; the
start logs unsafe_debug with the window's length and end, and the first debug
line after the window logs unsafe_debug_ended instead and closes it for good.
The protocol library's debug lines name contacts and groups, carry push names and dump
every stanza it sends and receives: the writer drops the stanza dumps and
pseudonymises identifiers, but a push name or other text it does not recognise
reaches the log. Open a window only to diagnose a fault, keep it short, and treat
the log of that period as holding chat data. The protocol library's second logging
channel, a logger attached to a context, stays off: nothing in WaWarden attaches
one, and an architecture test refuses the calls that would.
The signal library that the protocol library uses for its encryption logs
through the same adapter, as whatsmeow_log events with module libsignal
and its source file and line at the start of detail. Its error and warning
lines follow WAWARDEN_LOG_LEVEL; its debug and information lines are
discarded at every log level, during a WAWARDEN_UNSAFE_DEBUG window too,
because they print key material. An error line can quote up to 64 bytes of a
malformed message as it arrived: the quoted bytes stay inside detail, and the
changes described above apply to them like any other text.
Pseudonyms and dropped lines¶
Every line serve writes to standard output passes through one writer, which
changes it in the four ways below before it is written. The writer writes a line
only when its newline arrives, so a line written in several pieces is checked as
a whole, and a last line without a newline is never written. The loggers that
feed the writer also write values fail-closed, as Values are written fail-closed describes.
Tokens are redacted¶
Every part of a line with the exact shape of a client
token, ww_, the 8-character client id, _, 43 characters of A to Z, a to
z, 0 to 9, _ and -, _ and 8 lower-case hexadecimal digits, becomes
ww_<client id>_[redacted], and every part with the shape of an admin token,
wwadm_ and the same 43 characters, _ and 8 hexadecimal digits, becomes
wwadm_[redacted]. This runs before identifiers become pseudonyms, wherever
the token stands in the line, also glued to other text, and the
protocol library's lines get it before their numbers
are masked. A token cut short, or otherwise not of that exact shape, is written
as it is: nothing guesses at partial tokens.
Identifiers become pseudonyms¶
Every part of a line shaped like a WhatsApp
identifier is replaced by jid: and 8 lower-case hexadecimal digits. The shape is
a run of letters, digits, ., _, :, + and -, then @, then one of the
servers s.whatsapp.net, c.us, lid, g.us, broadcast, newsletter,
hosted, hosted.lid, bot, msgr or interop in any letter case, followed by
a character that is not an ASCII letter or digit, or by the end of the line. The
identifier is the longest ending of the run that starts with a digit and holds
only digits, ., _, : and -, or the whole run when it has no such ending;
what precedes it is kept, so sender:15550100001@s.whatsapp.net becomes
sender:jid: and 8 digits. A JSON escape sequence such as \n just before an
identifier is kept as well, so the line stays valid JSON. The 8 digits are the first 4 bytes of an
HMAC-SHA256, under the log-redact key derived from the master key,
of the user cut at its first . or : (which drops an agent and a device
number) and lower-cased, then @, then the server lower-cased with c.us written
as s.whatsapp.net. So 15550100001@s.whatsapp.net,
15550100001:4@s.whatsapp.net, 15550100001.0:4@s.whatsapp.net and
15550100001@c.us all become the same pseudonym, and they keep it as long as the
master key is the same.
- Before the master key is loaded, the writer writes
jid:unkeyedinstead; only astartup_refusedline can be written that early. - Text that only looks like an address on one of these server names, such as
someone@lid.example, is replaced too. Go module paths such asexample.com/module@v1.2.3are not. - Identifiers written another way are not recognised: a phone number on its own,
a user and device number without
@and a server, or an identifier whose server name is followed directly by an ASCII letter or digit, such as15550100001@g.us2. - Pseudonyms are 32 bits long, so two identifiers can share one; among some 65,000 identifiers a shared pseudonym becomes likely.
Lines carrying XML are dropped¶
A line carries XML when it contains:
- a closing tag (
</x>); - a self-closing tag without attributes (
<x/>); - the start of a tag with an attribute:
<, a name, white space (also written as the JSON escape\n,\ror\t), a name,=and a quote (<x a="), whatever follows it, so a quote inside the value or a missing end of the tag does not matter; - a tag directly followed by another tag (
<x><y), with nothing in between; <!--,<![CDATA[or<?xml.
White space is allowed before the > of a closing tag, before /> and around
=. Such a line is replaced by one fixed line, also when its quotes are written as
\" and its tags' < and > as the JSON escapes
\u003c and \u003e:
{"time":"2026-10-04T08:09:24.446116295Z","level":"WARN","msg":"log line dropped","event":"log_dropped","reason":"xml"}
Text such as <nil>, [<nil> <nil>], <autogenerated> or a < b does not count
as XML, and neither does a tag without attributes followed by text and nothing
else from the list, such as <message> without attributes.
Overlong lines are dropped¶
A line longer than 65,536 bytes, not counting its
newline, is replaced by the same fixed line with "reason":"too_long", whether it
was written at once or in pieces.
Values are written fail-closed¶
In the events above every value is a
string, a number, a boolean or a time. Should any other value reach a log line, a
byte slice of any type, or a pointer to one, is written as its length
([32 bytes]), even when it has a String or Error method, as a raw JSON value
does; an error or a value with a
String method as its text, which is then pseudonymised like the rest of the
line; and a protocol message or a URL, whatever its methods, and every other
value as its Go type in brackets, such as [seal.Chat] or [*waE2E.Message],
never as its content.
Notifications¶
The service reports what an operator must act on as notification events. Each
event is one JSON line on standard output, written through the same writer as
every other line and at every log level, with the event key and a fixed set of
other keys; when WAWARDEN_NOTIFY_URL is set, the same event is also posted to
the webhook.
| Event | Level | Other keys | Reported when |
|---|---|---|---|
unpaired |
WARN |
The engine starts without a paired device, so it makes no connection until pairing is requested, also when the restart budget holds the start, or a paired device is lost: WhatsApp logged it out, or a rejected device was logged out. | |
disconnected |
WARN |
reason |
The engine entered disconnected for any reason but shutdown; reason is one of the disconnect reasons. |
pair_rejected |
WARN |
stage: before_save, after_pairing |
Pairing linked or tried to link an account other than the owner's; see Pairing. |
logout_failed |
WARN |
attempt, error_type |
Logging out a rejected device failed; the engine tries again. error_type is the Go type of the error. |
quarantine |
WARN |
queue: inbox, history; attempts |
An inbox row or a history blob was set aside after three failed attempts. |
rekey_conflict |
WARN |
conflict: mapping_contradicts, both_chats_have_messages, message_collision, scoped_chat |
A LID mapping was refused; the event that carried it is still applied. Reported once per refused mapping, as described under Ingest. |
ingest_paused |
WARN |
free_bytes, floor_bytes |
The data directory fell below its free-space floor. |
admin_mutation |
INFO |
action: pair, reconnect, backfill, client_create, client_revoke; outcome |
A POST admin route was called with the admin token; outcome is ok or the error code it answered, such as already_paired, name_taken or invalid_body. The event never names the client: the audit chain records its id. |
backup_done |
INFO |
bytes, archive_bytes, session_bytes, duration_ms |
A backup was written: bytes is the size of the encrypted file, the others the sizes of the two database copies and the time it took. |
backup_failed |
WARN |
reason |
A backup failed and what it had written was removed; reason as in the table under Backups. It is reported on every failure, retries included. |
backup_pruned |
INFO |
removed, kept |
After a successful backup, backups beyond WAWARDEN_BACKUP_KEEP were deleted: removed files, kept remain. Not reported when nothing was deleted. |
backup_prune_failed |
WARN |
reason: directory, unexpected_entry, remove, sync |
Deleting old backups failed or was refused; see Backup retention. |
audit_key_changed |
INFO when previous_key is retired, WARN otherwise |
previous_key_id, key_id, previous_key: retired, missing |
At start, the newest audit row carries the key id previous_key_id, not the master key's key_id, as after keys rotate or a replaced master key. previous_key is retired when that key is in keys/retired/, and missing when it is not, so audit verify reports the rows written under it as unknown_key unless it is given a copy. Reported at each start until a row is written under the master key. |
send_outcome_unknown |
WARN |
reason: ack_timeout, lease_expired, restart |
A send may have reached WhatsApp without an acknowledgement: the engine call ended without one (ack_timeout, which covers a timeout, a drop after the message was written and any error the engine cannot place), a request found its key still reserved past the 60-second lease (lease_expired), or the start found a send left running by the previous process (restart, one event per send). The send is stored as failed and never resent. |
send_unrecorded |
WARN |
The outcome of a send could not be stored after three retries, or arrived after its lease had been settled, or the key of a send that never left could not be freed after three retries: the key answers send_outcome_unknown once its lease has ended, although the message may have been delivered. |
|
admin_auth_failure |
WARN |
count |
Authentication failed on the admin listener. The first failure is reported at once with count 1; further failures within the next minute are held back, and reported together once the minute has passed, within ten seconds, or at shutdown. count is the number of failures since the previous admin_auth_failure. |
No event carries an identifier, a name, message text, a token, a key or a
pairing code. Every value is a number or a fixed code: a value that is not a
lower-case code of at most 64 characters (or, for error_type, a Go type name,
and for previous_key_id and key_id, 8 lower-case hexadecimal digits) is
written as invalid. A key id is the 4-byte key-id value derived from a
master key, already logged by keys_loaded; it is not a key. For example:
{"time":"2026-10-05T08:12:40.002931604Z","level":"WARN","msg":"the engine is disconnected from WhatsApp","event":"disconnected","reason":"replaced"}
{"time":"2026-10-05T08:15:02.518840121Z","level":"INFO","msg":"an admin route that changes the service's state was called","event":"admin_mutation","action":"reconnect","outcome":"ok"}
Webhook¶
With WAWARDEN_NOTIFY_URL and WAWARDEN_NOTIFY_SECRET_FILE set, every
notification event is also sent as POST to that URL, with this body, the
line's event and other keys plus an id and the time:
{"id":"evt_5f0c2a9de1b34c7a8e6f1d2c3b4a5968","event":"disconnected","time":"2026-10-05T08:12:40.002931604Z","reason":"replaced"}
and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
wawarden |
WaWarden-Event-Id |
The event's id, evt_ and 32 hexadecimal digits, the same on every attempt. |
WaWarden-Timestamp |
The time of this attempt, in Unix seconds. |
WaWarden-Signature |
v1= and the lower-case hexadecimal HMAC-SHA256, keyed with the secret, of the event id, ., the timestamp, . and the body's bytes. Each attempt is signed again. |
The secret is the content of WAWARDEN_NOTIFY_SECRET_FILE without surrounding
white space. It is read once, at start, and never logged.
Verifying a delivery¶
A receiver should, before it acts on a request:
- read the body as raw bytes, before parsing it;
- refuse the request when
WaWarden-Timestampis more than 5 minutes away from its own clock; - compute
v1=and the hexadecimal HMAC-SHA256 of<WaWarden-Event-Id>.<WaWarden-Timestamp>.<body>with the shared secret, and compare it, in constant time, with each comma-separated value ofWaWarden-Signature, accepting the request when one matches; - answer
2xx, and ignore an event id it has already handled: a retry, or a delivery whose answer was lost, carries the same id.
For example, in Python:
import hashlib, hmac, time
def verified(secret: bytes, headers, body: bytes) -> bool:
event_id, timestamp = headers["WaWarden-Event-Id"], headers["WaWarden-Timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
signed = f"{event_id}.{timestamp}.".encode() + body
expected = "v1=" + hmac.new(secret, signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v.strip()) for v in headers["WaWarden-Signature"].split(","))
Webhook delivery¶
Events wait in a queue of 64 for one sender, so that a slow or failing receiver never holds up the engine, the admin routes or shutdown; an event that finds the queue full is dropped. Each attempt:
- connects directly, ignoring the proxy variables, with TLS 1.2 or later, and
follows no redirect: a
3xxanswer is a failure; - has 5 seconds to connect and for the TLS handshake, 10 seconds for the answer's headers and 15 seconds in all; at most 4096 bytes of the answer are read, and none of it is logged;
- succeeds on any
2xx. A network error,408,425,429or a5xxis tried again, up to 5 attempts in all, after a delay that starts at about a second and doubles, randomised to between half and all of it, or after the answer'sRetry-Afterin seconds, at most a minute. Any other answer is final.
Before it connects, the sender checks the address it resolved. It always
refuses link-local addresses (169.254.0.0/16, fe80::/10, which hold the
cloud metadata and container credential endpoints), the metadata addresses
fd00:ec2::254, 100.100.100.200 and 192.0.0.192, multicast, broadcast,
reserved and unspecified addresses, every IPv6 address with a zone (such as
fe80::1%eth0), and the local-use NAT64 prefix 64:ff9b:1::/48, whose
embedded IPv4 address depends on the network; it refuses loopback, private and shared
(100.64.0.0/10) addresses unless WAWARDEN_NOTIFY_ALLOW_PRIVATE is 1. An
address in the well-known NAT64 prefix 64:ff9b::/96 is checked as the IPv4
address it embeds, so 64:ff9b::a9fe:a9fe counts as 169.254.169.254. A
refused destination is not tried again. The check runs on every connection,
after name resolution, so a name that resolves to a refused address is refused
too.
An event that was not delivered is counted in wawarden_notify_dropped_total,
with reason queue_full, failed (after its last attempt, or a final answer
or refused destination, logged as notify_failed with attempts, reason:
destination_refused, http_status or network_error, and status, 0
without an answer) or shutdown. At shutdown the sender delivers what is
queued within the grace period, then gives up on the rest: it
cancels the attempt in progress, logs notify_dropped with the number of
events dropped at shutdown, and serve exits 1.
Metrics¶
Metrics are served on the admin listener, at GET /metrics, with the admin
token. The format is the Prometheus text exposition format 0.0.4
(Content-Type: text/plain; version=0.0.4; charset=utf-8), sorted by metric name.
With WAWARDEN_METRICS_EMF=1 the main ones are also written on standard output
in EMF, which needs no token.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
wawarden_auth_failures_total |
counter | Failed authentications on the client listener, including those answered 429. |
|
wawarden_admin_auth_failures_total |
counter | Failed authentications on the admin listener, including those answered 429. |
|
wawarden_policy_denials_total |
counter | Client requests that the client's grant did not allow. Always 0 in this build: the client API resolves a chat outside the scope and a missing chat by the same query, so it cannot count one apart from the other; a send to a chat the client reads but may not write to is counted in wawarden_sends_rejected_total as scope. |
|
wawarden_sends_rejected_total |
counter | reason |
Sends refused: scope (a chat the client reads but may not write to), first_contact, budget_minute, budget_hour, budget_global, pacer (the pacer, or another send that was running). Every reason is present from the start, at 0. Other refusals, such as a malformed text or key, are not counted. |
wawarden_panics_total |
counter | name |
Panics recovered, by handler or goroutine name. Absent until the first panic. |
wawarden_notify_dropped_total |
counter | reason |
Notification events the webhook did not deliver: queue_full, failed, shutdown. |
wawarden_build_info |
gauge | version, revision, dev |
Always 1; the labels describe the running binary. |
wawarden_backup_last_success_timestamp_seconds |
gauge | Unix time at which the newest successful backup began, read from the archive at start; 0 until a backup succeeds. It is not written to the EMF lines. |
The WhatsApp engine adds these:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
wawarden_paired |
gauge | 1 while a device is paired, 0 otherwise. |
|
wawarden_connected |
gauge | 1 while the engine is connected, 0 otherwise. |
|
wawarden_messages_ingested_total |
counter | Messages, reactions and poll votes stored in the archive, live and from history. | |
wawarden_ingest_dropped_total |
counter | reason |
Events dropped by an ingest rule or a history-sync check: chat_rejected, sender_rejected, invalid, unknown_kind, no_target, foreign_reference, target_unknown, target_kind, stale_edit, not_original_sender, not_admin, admin_unknown, owner_unknown, not_paired, too_large, history_not_primary. |
wawarden_ingest_refused_total |
counter | reason |
Events the engine refused to write and left unacknowledged, which loses them (see Delivery): backlog_full, paused, store_error. |
wawarden_ingest_quarantined_total |
counter | queue |
Inbox rows (inbox) and history blobs (history) quarantined. |
wawarden_rekey_conflicts_total |
counter | conflict |
LID mappings refused, by conflict as in rekey_conflict. |
Labelled counters appear once they count their first event.
Anything that scrapes /metrics holds the full admin token. That token can
also start pairing, ask for a reconnect, read the status, list the chats with
their identifiers and names, create and revoke clients, and request backfills.
Treat a scrape configuration as holding the admin credential, and prefer the
EMF lines, which need no token, for alerting.
Panic names¶
Panic names: api.client, api.admin and api.health for the handlers,
api.mcp for an MCP tool call;
listeners.client, listeners.admin, listeners.health, listeners.shutdown
and signals for goroutines. The engine adds engine.supervisor,
engine.ingest and engine.logout; the first two also count a panic of one
version fetch, connection attempt, or pairing's dial or code request
(engine.supervisor) or of one inbox row's or history blob's attempt
(engine.ingest), which the engine treats as a failed attempt. A panic while a
reconnect closes the connection being dialled also counts as
engine.supervisor, and the reconnect goes ahead. Its adapter adds wa.connect, which ends a connection attempt whose
caller gave up, wa.keepalive, which closes a connection whose keepalives
failed or that WhatsApp asked to log in again, and wa.event for a panic while
it translates one of the protocol library's events or the engine handles it,
which refuses the event. Other code on goroutines that the protocol library
starts itself is not covered: a panic there ends the process. The notifier adds
notify.flush and notify.webhook, the EMF writer
metrics.emf, the backup scheduler backup.schedule, and each
database call the watcher db.deadline, which logs a call that runs past its
deadline. In development builds, the fake engine adds
fake.connection.
Embedded metric format¶
With WAWARDEN_METRICS_EMF=1, the service writes metrics in CloudWatch's
embedded metric format (EMF)
on standard output, through the same writer as every other line: every 60
seconds after the start, and once more at shutdown, after the engine has
stopped. Each time it writes one line in namespace WaWarden without
dimensions ("Dimensions":[[]]) that carries all eight metrics, zero when
nothing changed:
| Metric | Unit | Value | Source |
|---|---|---|---|
Paired |
None |
The current 0 or 1 |
wawarden_paired |
Connected |
None |
The current 0 or 1; the line at shutdown reports 0 |
wawarden_connected |
MessagesIngested |
Count |
Since the previous line | wawarden_messages_ingested_total |
PolicyDenials |
Count |
Since the previous line | wawarden_policy_denials_total |
Panics |
Count |
Since the previous line, all names together | wawarden_panics_total |
SendsRejected |
Count |
Since the previous line | wawarden_sends_rejected_total |
AuthFailures |
Count |
Since the previous line | wawarden_auth_failures_total |
AdminAuthFailures |
Count |
Since the previous line | wawarden_admin_auth_failures_total |
{"AdminAuthFailures":0,"AuthFailures":0,"Connected":1,"MessagesIngested":42,"Paired":1,"Panics":1,"PolicyDenials":0,"SendsRejected":0,"_aws":{"Timestamp":1791201600000,"CloudWatchMetrics":[{"Namespace":"WaWarden","Dimensions":[[]],"Metrics":[{"Name":"Paired","Unit":"None"},{"Name":"Connected","Unit":"None"},{"Name":"MessagesIngested","Unit":"Count"},{"Name":"PolicyDenials","Unit":"Count"},{"Name":"Panics","Unit":"Count"},{"Name":"SendsRejected","Unit":"Count"},{"Name":"AuthFailures","Unit":"Count"},{"Name":"AdminAuthFailures","Unit":"Count"}]}]}}
A metric that also has a label, in this build Panics by name and
SendsRejected by reason, is additionally written, for each label value whose count grew, in a line of its
own with that label as its only dimension. The dimensionless line holds the
total, and no line carries the metric twice, so an alarm without dimensions
matches the total:
{"Panics":1,"_aws":{"Timestamp":1791201600000,"CloudWatchMetrics":[{"Namespace":"WaWarden","Dimensions":[["name"]],"Metrics":[{"Name":"Panics","Unit":"Count"}]}]},"name":"engine.ingest"}
These lines have no time, level, msg or event key. The first line counts
from the start of the process; a restart starts the counts again.
Audit chain¶
The archive's audit table records:
- every change of a client: creating one (
client_create) and revoking one (client_revoke, once; repeating it records nothing); - every request a client makes to the client API or the MCP endpoint that its read budget admits.
Failed authentications are not recorded. A row holds:
- an id one more than the previous row's, and the time in milliseconds;
- the client id, the action, the chat (none for client changes) and its
chat-hmacvalue; - whether the action was allowed, and a reason code (
okfor client changes); - the peer address when one is known;
- the key id: the 4-byte value, written as 8 hexadecimal digits, that names the master key the row was written under (see Master key);
- the row's HMAC, HMAC-SHA256 under the
audit-chainkey derived from the master key, over the row and the previous row's HMAC. The exact layout is under Audit row HMAC.
A client change's row is appended inside the same write transaction as the
change, so a change whose row cannot be written does not happen. A client
request's row is appended in a write transaction of its own before the answer
is sent, waiting at most 2 seconds for the connection. A request whose row
cannot be written is answered 503 (busy) when that wait ran out and 500
(internal_error) otherwise. Triggers refuse to update a row, to delete one
that the audit anchor does not cover or that is the newest row, or to insert
one with the id of an existing row, which an INSERT OR REPLACE or a
REPLACE would otherwise overwrite.
After the transaction commits, the service writes one line on standard output, through the same scrubbing writer as every log line:
{"event":"audit","ts":"2026-10-06T12:00:00.000Z","client":"aaaqeaye","action":"client_create","chat_hmac":null,"ok":true,"reason":"ok","key_id":"6a53922c","chain_head":"3fa8c2d14be0917a5c6e2b8f0d4a7c19"}
chat_hmacis the first 16 bytes, in hexadecimal, of HMAC-SHA256 under thechat-hmackey over the chat's canonical identifier, ornull.key_idis the id of the key the row was written under.chain_headis the row's chain head: the first 16 bytes of its HMAC. A chain head that reaches a log store off the host is a shipped head.
The line names no chat, person or message, and the peer address stays in the archive.
What verification proves¶
audit verify recomputes every row's HMAC from a copy
of the archive, the master key and the retired keys, each row under the key
its key id names. It reports:
- a row whose content, HMAC or id sequence was changed;
- a row removed from the middle, as an
id_gapon the next row; - a row written under a key it was not given;
- a row written under a retired key after one written under the master key;
- an audit anchor that was changed, forged without a key, or removed with the
rows after it, as an
id_gapon the first retained row.
The triggers do not stop someone who holds the database file. Whoever holds
keys/master can recompute the whole chain after changing it, or remove rows
from its end without leaving a trace in the table. The chain heads on standard
output are the defence against both: ship the service's standard output to a
store the host cannot rewrite, and give a capture of it to
audit verify --log, which reports every shipped head the copy lacks. Rows
removed from the end of the chain are detectable only this way.
Whoever holds a retired key can write rows under it in place of every row under the master key, once all of those are older than the retention's cutoff. Such rows are detectable this way only in a backup taken before the removal that reached them, which signs them (Audit anchoring in the threat model's residual risks). The master key and the retired keys are not part of a backup: keep a copy of them apart from the backups to verify them.
Audit retention¶
Each night the service removes the audit rows written more than
WAWARDEN_AUDIT_RETENTION_DAYS days before (90 by default, at least 7). When
it runs:
- once the backup of the night's slot is taken, and never while that backup is still owed. A slot skipped because nothing changed counts as taken;
- at once at the slot when no backup recipient is configured;
- also for the first time ten minutes after the backup scheduler's first check after a start.
With a backup recipient, nothing is removed before the first backup succeeds: not while no device is paired, while the history sync settles, or while the first backup fails and is retried.
How it removes rows:
- It works from the oldest row, in transactions of at most 10,000 rows. Each transaction first moves the audit anchor to the last row it removes, then deletes the rows up to it, never the newest row.
- It stops when no older row is left or after 15 minutes, and goes on the next night.
- Rows are taken in id order, so a row whose time is a few seconds out of order goes with its neighbours.
The anchor keeps:
- the last removed row's id, key id and HMAC;
- the anchor's time: the latest time of the rows removed so far. An out-of-order row can make it later than the last row's, but never more than 10 seconds after the removal's cutoff. That is the archive's write timeout, and so the longest a row's time can precede its commit;
- the time of the removal and the id of the current master key;
- an HMAC-SHA256 under that key's
audit-chainkey, laid out as under Audit anchor HMAC.
Before it moves the anchor, each transaction checks what it signs with the
master key and the retired keys, by the rules of
audit verify:
- the MAC of the anchor already there, with the key it names;
- that the first row behind an anchor made under a retired key was not written under the master key;
- the HMAC of every row it is about to remove, from the anchor's HMAC (or from the chain's start), under the key the row names, with consecutive ids and no row under a retired key once the anchor or an earlier row names the master key.
When a check fails, the transaction moves and removes nothing. The service
logs audit_prune_refused with reason chain_mismatch, and the retention
stops until the next night, when it checks again. Rows under a key that is
neither the master key nor in keys/retired/ are never removed. The check
reads each row once more: about 40 ms for 10,000 rows. Removing 1,000,000
rows took 23 seconds instead of 19, measured on an SSD.
Triggers refuse to move the anchor back or to remove it. One audit_pruned
line reports each night's removal. The verification starts from the anchor. A
shipped head whose time is not after the anchor's counts as pruned, not
missing, unless the anchor was made under a retired key and the head's line
names the master key or no key. Those rows can be checked only in a backup
taken before they were removed, which is why the retention waits for the
night's backup.
Audit key changes¶
After keys rotate or a replaced master key, rows keep
the key id they were written under. Each start with the new key emits
audit_key_changed (see Notifications) until a row is
written under it. audit verify needs the retired keys to verify the older
rows and an anchor made under an older key. So does the retention, which
removes no row it cannot verify; the next removal makes the anchor again
under the current key. While the anchor is under an older key, the head of a
removed row on a line without a key_id counts as missing.
Audit growth¶
A row takes about 150 bytes of the archive, measured over ten million rows. Client changes add a handful of rows; a client that reads continuously at the default read budget of 600 requests a minute adds 864,000 rows, about 131 MB, a day, and each further client as much again, so the default retention of 90 days holds up to about 12 GB per such client; at 6 requests a minute it is about 118 MB. A request refused by the read budget adds no row. The archive file does not shrink when rows are removed: later rows reuse the freed pages.