Structured logging with logfmt
Cyrus is converting its syslogging from prose to logfmt, so that logs can be parsed reliably instead of scraped with regular expressions.
This document is the rulebook for that conversion. It covers how to emit an
event, how to name it, and how to name and format its fields. The vocabulary
itself — the list of every key you're allowed to use — lives in
doc/logfmt-keys and is enforced by tools/lint-logfmt-keys.
Why a rulebook
The value of structured logging is entirely in its consistency. A log where
the same fact is called mboxid in one place, mbox.uniqueid in another and
uniqueid in a third is barely better than prose, because every consumer still
needs a table of special cases.
Cyrus has around 2700 logging call sites. Converting them without an agreed vocabulary would produce exactly that mess, so the vocabulary comes first and the lint makes it stick.
The shape of an event
A logfmt event is a single syslog line of key=value pairs:
event=login.good r.sessionid=fastmail-1723150181-3493991-1 r.clienthost=10.0.0.7 u.username=jbloggs login.mech=PLAIN login.tls=1
event always comes first, and every line has exactly one. It is the name of
the thing that happened, and it is what a log consumer keys off.
Values are escaped by lib/logfmt.c, which quotes anything containing a space
or other awkward byte. You never have to escape or quote a value yourself, and
you should never try — passing a pre-quoted string just gets it double-quoted.
The grammar the escaper implements is documented in a comment at the top of
logfmt_escape_bytestring() in lib/logfmt.c.
Emitting an event
There are three ways to log, in increasing order of ceremony. Pick the least ceremonious one that fits.
1. xsyslog_ev() — for a one-off event
Most call sites are like this: something went wrong in one particular place, and you want to say so with some context.
xsyslog_ev(LOG_ERR, "mailbox.append.failed",
lf_mailbox(mailbox),
lf_msgrecord(record),
lf_err("error", r));
This is the direct replacement for syslog() and xsyslog(), and it's what
the bulk of the conversion produces.
xsyslog_ev() adds some fields for you:
r.sessionidandr.tid, when the process has them;sys.error(fromerrno) andcaller.file/caller.line/caller.func, but only when the priority is notLOG_NOTICEorLOG_INFO.
That last rule exists so that routine operational events don't carry debugging
noise. It does mean that if you log at LOG_NOTICE you will not get
caller.*, which is occasionally surprising.
2. A typed event module — for an event logged from several places
When the same event is logged from more than one file, don't repeat the field
list at each site. Write a function that takes the event's data as arguments
and emits it, and put it next to its peers. imap/loginlog.c and
imap/auditlog.c are the existing examples:
loginlog_good(clienthost, userid, "PLAIN", /*tls*/ 1);
This is how we guarantee that login.good means the same thing and carries the
same fields whether it came from imapd, pop3d or httpd. It is also the
unit that log-parsing code is written against, so a shared event that isn't in
a module will drift.
Rule of thumb: two call sites is a coincidence, three is a module.
3. struct logfmt directly — for events built up conditionally
The typed modules use this internally. Reach for it when the field list depends on control flow in a way that a single call can't express.
struct logfmt lf = LOGFMT_INITIALIZER;
logfmt_init(&lf, "sync.mailbox.replicated");
logfmt_push_session(&lf);
logfmt_push_mailbox(&lf, mailbox);
if (renamed) logfmt_push(&lf, "old.mbox.name", oldname);
logfmt_emit(&lf, LOG_NOTICE);
Note that this form does not add r.sessionid, sys.error or caller.* for
you; call logfmt_push_session() and logfmt_push_caller() yourself if you
want them.
Naming events
event = segment *( "." segment )
segment = 1*( lowercase / DIGIT / "_" / "-" )
A hyphen is legal in an event name where the thing being named has one. Don't reach for it to join an object to its outcome, though -- that's what the dot is for. (Keys are stricter: no hyphens. See "Naming keys".)
Read the name as <subsystem>.<object>.<outcome>, from general to
specific, so that a prefix match selects a useful family:
Good |
Bad |
Why |
|---|---|---|
|
|
a name, not a sentence |
|
|
general to specific |
|
|
outcome is its own segment |
|
|
lowercase only |
Rules:
Never put data in the event name.
event=jmap.email.getwithjmap.accountid=..., neverevent=jmap.email.get.u123. The name must have low cardinality or it can't be counted or grouped.Don't put the severity in the name.
mailbox.append.failedlogged atLOG_ERRis right;mailbox.append.erroris redundant.Name the outcome, not the function.
mailbox.append.failedbeatsappend_setup_mbox_failed. Function names are refactoring debris and they tell a log consumer nothing about what actually broke. The function name is already incaller.func.An event name is an interface. Once something outside the tree parses it, renaming it is a breaking change; see "Changing an existing event" below.
Naming keys
Keys are dotted too, and the first segment says what the value is about, not
which subsystem happened to log it. A mailbox is mbox.name whether IMAP,
JMAP or the replication code logged it.
The registered namespaces:
Prefix |
For |
|---|---|
|
the request or connection: |
|
the user: |
|
a mailbox: |
|
a message: |
|
the operating system: |
|
the C source location that logged it |
|
authentication |
|
outbound delivery |
|
those subsystems |
|
those protocols |
|
why the operation failed (bare, no prefix — it's universal) |
Two structural prefixes stack on top of the above:
old.— the value before a change:old.mbox.name,old.msg.sysflags. Always paired with the unprefixed key carrying the new value.out.— a value on the way out, where the event also mentions one coming in:msg.idandout.msg.idin Sieve redirect logging.
Rules:
Every key must be registered in
doc/logfmt-keysbefore use.tools/lint-logfmt-keysfails the build otherwise. Adding a key is meant to be easy — a one-line patch — but deliberate.Same fact, same key, everywhere. If you're about to invent a key for something that already has one, use the existing one even if you'd have spelled it differently.
Don't abbreviate inconsistently. It's
mbox, nevermailboxormb.A key is an interface, exactly like an event name.
Values
Use the typed lf_* macros rather than formatting values yourself; they keep
the representation of a given type consistent across the whole log.
Macro |
Emits |
|---|---|
|
a byte string, escaped |
|
a UTF-8 string, escaped per codepoint |
|
integers |
|
integers |
|
hex, no |
|
a double, |
|
|
|
a |
|
a double as seconds, 3 decimal places |
|
a Cyrus error code as its |
|
a |
|
|
|
|
|
anything else, |
And these log a whole struct, contributing several fields at once:
Macro |
Emits |
Declared in |
|---|---|---|
|
|
|
|
|
|
|
as |
|
|
a mailbox name, under the key you give |
|
|
an internal mailbox name, in the admin namespace |
|
|
each element as |
|
|
whatever |
|
Prefer these to spelling the fields out. They're the reason the same
mailbox is described the same way in every event, and adding a field to one
of them improves every call site at once. To log a struct that doesn't have
one yet, write the push function next to the struct and give it an lf_ macro
there. It doesn't matter whether the struct is defined in imap/ or lib/ or
somewhere else, put the logging helper with the struct.
Conventions these encode, so you don't have to decide each time:
Booleans are
1and0, nottrue/falseand notyes/no. This matches what shipped in 3.13 forlogin.tlsand friends.Times are epoch seconds, matching the existing
send.scheduledandsend.time. Log the machine-readable form; let the reader localise it.Durations are seconds, as a decimal, so that
0.004and12.500are directly comparable. Never log milliseconds under a key that doesn't say so.Text that came from a user is
lf_utf8, notlf_s: mailbox names, subjects, display names. Protocol tokens, hostnames, GUIDs and error strings arelf_s.
Absent values
lf_s(k, NULL) logs k=~null~, which says "we looked and there was nothing".
lf_s_opt(k, NULL) omits the key entirely, which says "not applicable here".
Both are legitimate; they mean different things. Prefer lf_s_opt for fields
that only apply to some variants of an event, and plain lf_s where a missing
value is itself interesting.
Lists
A list of values is logged as one indexed key per element, not as a joined string:
sched.addresses.0=cassandane@example.com sched.addresses.1=cass@example.net
lf_strarray() does this for you. A reader gets the elements without having
to know which separator we picked, and without us having to promise that no
element ever contains that separator.
Register the base key — sched.addresses — not the indexed forms. The lint
only ever sees the base key, because that's what the source says.
An empty list logs k="" and a NULL one k=~null~, so those two cases still
say something. A non-empty list logs only the indexed keys, never the bare
one.
Things not to log
Passwords and credentials. The one exception in the tree is
login.passwordfor a rejected anonymous login, where the "password" is an email address by convention. Don't add more.Message bodies, or anything unbounded. Log a GUID and a size.
Values you formatted into a sentence.
lf_s("error", "failed to open mailbox foo")throws away the structure we're here to create. It'slf_mailbox(...)pluslf_err("error", r).
Choosing a severity
The severity is not decoration: Cassandane fails any test whose log contains a
logfmt event at err or worse (see _check_syslog in
cassandane/Cassandane/Instance.pm). Logging routine, expected conditions at
LOG_ERR will turn tests red.
Priority |
Means |
|---|---|
|
the server malfunctioned, or lost data, or a bug was hit |
|
something is wrong but the server coped |
|
a routine operational event worth keeping: logins, deliveries |
|
detail a busy operator might want |
|
detail only a developer wants |
A client sending a malformed request is not LOG_ERR — the server worked
correctly by rejecting it.
Adding to the vocabulary
Check
doc/logfmt-keysfor an existing key that means what you mean.If there isn't one, add it there, in the same patch as the code that uses it, with a description that says what the value is — not what your one call site uses it for.
Run
tools/lint-logfmt-keys(or just build; it runs as part ofmake check).
Changing an existing event
Event names and keys are consumed by log-parsing code outside this repository. Treat them as you would any other interface:
Adding a new key to an existing event is safe.
Renaming or removing a key, renaming an event, or changing a value's format or units is a breaking change. It needs a
changes/next/entry saying exactly what changed, so that operators find it in the release notes.
Converting an existing call site
The mechanical part is turning the prose into a name and the interpolations into fields:
/* before */
syslog(LOG_ERR, "IOERROR: failed to append to %s uid %u: %s",
mailbox_name(mailbox), record->uid, error_message(r));
/* after */
xsyslog_ev(LOG_ERR, "mailbox.append.failed",
lf_mailbox(mailbox),
lf_msgrecord(record),
lf_err("error", r));
The judgement part is everything else:
Does this event already exist somewhere else under another name? Reuse it.
Is the message actually two different events sharing a code path? Split it.
Is anything in the message text a value? It becomes a field, not part of the name.
Is there context available that the prose didn't bother with — a mailbox, a uid, a userid? Add it. The old message was constrained by what fit in a readable sentence; the new one isn't.
Note the IOERROR: prefix disappearing. That prefix existed so operators
could grep for trouble; event= plus the syslog severity now does that job
properly.
Where the code lives
File |
Contains |
|---|---|
|
escaping, and the |
|
|
|
|
|
the |
|
the |
|
the registered key vocabulary |
|
the lint that enforces it |
|
tests for escaping and the push functions |
|
tests for |
Data-type-specific push functions live next to the struct they log, not in
logfmt.c, since that module can't see them. Find them with:
$ git grep 'void logfmt_push_'