Message templates
The grammar: holes, operators, formats, alignment, escaping, and the event-type hash.
A message template is a string with named holes. The call supplies one value per hole, in order:
log.info('User {UserId} bought {Count} items for {Total:0.00}', 42, 3, 19.5);The event keeps three things: the template text, the captured values under their names (UserId: 42, Count: 3, Total: 19.5), and nothing rendered. Rendering happens in each sink, if that sink wants text at all. This is the messagetemplates.org grammar that Serilog uses, so templates written for one are templates for the other.
Why templates#
- Querying. Every event written with
'User {UserId} bought {Count} items'is the same kind of event, whatever the values. Seq, Datadog and friends can group, count and alert on the template — or on its hash, see below — where a rendered string needs a regex. - Values keep their names and types.
Countis the number 3, not the text "3" lost in a sentence. - No wasted work. When the level is off, nothing is parsed or rendered. When it is on, the parse is cached per template text.
Grammar#
{ [@|$] Name [,Alignment] [:Format] }| Part | Meaning | Example |
|---|---|---|
Name | [0-9A-Za-z_]+. All-digit names are positional. | {UserId}, {0} |
@ | Destructure: capture the value's structure, class instances included | {@Order} |
$ | Stringify: capture String(value) whatever it is | {$Error} |
,Alignment | Pad the rendered text: positive right-aligns, negative left-aligns | {Elapsed,8}, {Name,-12} |
:Format | How to render the value as text (below). Does not change what is captured | {Total:0.00} |
{{ }} | A literal brace | 'Got {{json}}' |
A { that does not form a valid hole is literal text, so a template with a stray brace never throws.
Operators#
Without an operator the capture rules decide: scalars stay scalars, plain objects and arrays are captured structurally, class instances are rendered with their own toString() / toJSON() or class name.
@ destructures anything — a class instance becomes its own enumerable properties with a $type:
log.info('Created {@User}', new User(7, 'Ada'));
// … "User":{"$type":"User","id":7,"name":"Ada"}$ stringifies anything — an array becomes "a,b", an object its toString().
Positional holes#
If every hole is a number, arguments bind by index and a hole may repeat:
log.info('{0} → {1} ({0} again)', 'a', 'b');Mixing named and numeric holes binds left to right by position, like named templates.
Format specifiers#
Formats affect text rendering only — {Message} in an output template, the pretty console, CLEF's @r renderings. The captured value is unchanged.
| Specifier | Applies to | Renders |
|---|---|---|
l | strings | Literal — no quotes. Without it a string renders as "text" |
j | structures, arrays | JSON instead of { Key: value } |
0.00, 0.#, 000 | numbers | Fixed decimals / optional decimals / zero padding — {Elapsed:0.0} → 83.2 |
F2, N0, P1, D3, X | numbers | Fixed, grouped (1,234.50), percent (12.3 %), zero-padded integer, hex |
yyyy-MM-dd HH:mm:ss.fff zzz and friends | Dates | The .NET date patterns — see Formatting |
Specifiers combine: {Message:lj} is the usual output-template form — literal strings, JSON structures.
Rendering rules#
- Strings are quoted (
"Ada") unless:l. - Structures render as
Type { Key: value, … }; the$typebecomes the leading name. Arrays as[1, 2]. - Dates render as ISO 8601.
- A hole with no value renders as written —
{Missing}— so a mismatch is visible, as in Serilog. - An
Errorcaptured as a property renders asName: message; the stack goes to the{Exception}token, not into the message.
Extra arguments and missing ones#
Fewer arguments than holes: the unfilled holes render as written. More arguments than holes: the extras are dropped. Neither throws.
The event-type hash#
Every event carries eventId: eight hex digits computed from the template text with the same function Serilog uses (Jenkins one-at-a-time over the UTF-16 code units). The CLEF formatter writes it as @i, which is what Seq shows and filters by; the JSON formatter can include it with eventId: true.
import { templateHash } from '@unhingged/logit';
templateHash('User {UserId} signed in'); // "c3a1…" — stable across processes, languages and yearsLogging without a template#
log.error(err) with no template uses the error's message as the template, with its braces escaped so it renders verbatim. log.info(someObject) treats the object as one-off properties and logs an empty message. Both are conveniences for the migration period; a template is always the better event.
Parsing yourself#
import { parseTemplate } from '@unhingged/logit';
const t = parseTemplate('User {UserId} bought {@Order}');
t.holes.map((h) => h.name); // ['UserId', 'Order']
t.holes[1].operator; // '@'