PPX-based library for compile-time validated message templates with structured logging.
Obj usage. The library has known problems like the fact that it cannot distinguish between bools and integers of the value 0 or 1 because of how they are represented by the runtime. More complex types like Maps and Sets also end up with very verbose and unclear output formats.
- Compile-time template validation with automatic variable capture from scope
- Type-safe logging with hard compile errors for undefined variables
- Dual output: formatted strings and structured JSON (CLEF format)
- PPX-driven code generation with zero runtime template parsing overhead
- Operators for structure preservation (
@) and stringification ($) - Printf-compatible format specifiers (
:05d,.2f, etc.) and alignment - Six log levels with comparison operators
- Multiple sinks: Console, File (with rolling), JSON, Composite, Null
- Context tracking with ambient properties and correlation IDs
- Level-based and property-based filtering
- Lwt and Eio async logging support
opam install message-templates message-templates-ppxFor Lwt (monadic concurrency):
opam install message-templates-lwtFor Eio (effects-based concurrency):
opam install message-templates-eio(depends
(ocaml (>= 5.4.0))
message-templates
message-templates-ppx
;; Optional: choose one or both
(message-templates-lwt (>= 0.1.0))
(message-templates-eio (>= 0.1.0))
yojson
ptime
unix)Add the PPX to your dune file:
(executable
(name myapp)
(libraries message-templates yojson unix)
(preprocess (pps message-templates-ppx)))let () =
let username = "alice" in
let ip_address = "192.168.1.1" in
(* Template with automatic variable capture *)
let msg, json = [%template "User {username} logged in from {ip_address}"] in
Printf.printf "%s\n" msg;
(* Output: User alice logged in from 192.168.1.1 *)
Yojson.Safe.to_string json |> print_endline;
(* Output: {"@t":"2026-01-31T23:54:42-00:00","@mt":"User {username} logged in from {ip_address}",
"@m":"User alice logged in from 192.168.1.1","username":"alice","ip_address":"192.168.1.1"} *)Configure the global logger:
open Message_templates
let () =
(* Setup logger at application startup *)
let logger =
Configuration.create ()
|> Configuration.minimum_level Level.Information
|> Configuration.write_to_console ~colors:true ()
|> Configuration.write_to_file ~rolling:File_sink.Daily "app.log"
|> Configuration.create_logger
in
Log.set_logger loggerLog messages with variables:
let process_user user_id =
Log.information "Processing user {user_id}" [("user_id", `Int user_id)];
try
(* ... work ... *)
Log.debug "User {user_id} processed successfully" [("user_id", `Int user_id)]
with exn ->
Log.error "Failed to process user {user_id}" [("user_id", `Int user_id)]open Message_templates
open Message_templates_lwt
open Lwt.Syntax
let main () =
(* Setup async logger *)
let logger =
Configuration.create ()
|> Configuration.minimum_level Level.Information
|> Configuration.write_to_console ~colors:true ()
|> Lwt_configuration.create_logger
in
(* All log methods return unit Lwt.t *)
let* () = Lwt_logger.information logger "Server starting on port {port}" [("port", `Int 8080)] in
(* Clean up *)
Lwt_logger.close logger
let () = Lwt_main.run (main ())open Message_templates
open Message_templates_eio
let run ~stdout ~fs =
Eio.Switch.run @@ fun sw ->
(* Setup Eio logger - requires switch for fiber management *)
let logger =
Configuration.create ()
|> Configuration.minimum_level Level.Information
|> Configuration.write_to_console ~colors:true ()
|> Eio_configuration.create_logger ~sw
in
(* Synchronous logging - waits for completion *)
Eio_logger.information logger "Server starting" [];
(* Fire-and-forget logging - runs in background fiber *)
Eio_logger.information_async logger "Background task started" [];
(* Your Eio code here *)
()
let () = Eio_main.run @@ fun env -> run ~stdout:env#stdout ~fs:env#fsUse PPX extensions for even cleaner syntax:
let user = "alice" in
let action = "login" in
(* All six log levels supported *)
[%log.verbose "Detailed trace: user={user}, action={action}"];
[%log.debug "Debug info: user={user}"];
[%log.information "User {user} performed {action}"];
[%log.warning "Warning for user {user}"];
[%log.error "Error for user {user}"];
[%log.fatal "Fatal error for user {user}"];Track request context across function calls:
let handle_request request_id user_id =
Log_context.with_property "RequestId" (`String request_id) (fun () ->
Log_context.with_property "UserId" (`Int user_id) (fun () ->
Log.information "Request started" [];
(* All logs within this scope include RequestId and UserId *)
validate_request ();
process_data ();
Log.information "Request completed" []
)
)For distributed tracing, use correlation IDs:
(* Generate and use a correlation ID automatically *)
Log_context.with_correlation_id_auto (fun () ->
Log.information "Processing request" [];
(* All logs include correlation ID *)
call_external_service ();
Log.information "Request completed" []
);
(* Or use a specific correlation ID *)
Log_context.with_correlation_id "req-abc-123" (fun () ->
(* Logs include @i field with correlation ID *)
process_request ()
)let logger =
Configuration.create ()
|> Configuration.debug (* Set minimum level *)
(* Console with colors *)
|> Configuration.write_to_console
~colors:true
~stderr_threshold:Level.Warning
()
(* File with daily rolling *)
|> Configuration.write_to_file
~rolling:File_sink.Daily
~output_template:"{timestamp} [{level}] {message}"
"logs/app.log"
(* Static properties *)
|> Configuration.enrich_with_property "AppVersion" (`String "1.0.0")
|> Configuration.enrich_with_property "Environment" (`String "Production")
(* Filters *)
|> Configuration.filter_by_min_level Level.Information
|> Configuration.create_logger{var}- Default: Standard variable substitution{@var}- Structure: Preserve as JSON structure{$var}- Stringify: Convert value to string representation
Format specifiers work like Printf formats:
let count = 42 in
let score = 98.5 in
let active = true in
let msg, _ = [%template "Count: {count:05d}, Score: {score:.1f}, Active: {active:B}"] in
(* Output: Count: 00042, Score: 98.5, Active: true *)Common format specifiers:
{var:d}- Integer (decimal){var:05d}- Integer with zero-padding{var:f}- Float{var:.2f}- Float with 2 decimal places{var:B}- Boolean{var:s}- String (default)
Control field width and alignment:
let name = "Alice" in
let status = "active" in
[%template "|{name,10}|{status,-10}|"]
(* Output: | Alice|active | *)Use doubled braces for literal braces:
let msg, _ = [%template "Use {{braces}} for literals"] in
(* Output: Use {braces} for literals *)The PPX rewriter operates at compile time:
- Parse template string into parts using Angstrom
- Validate variable existence against lexical scope
- Generate OCaml code for string and JSON output
- Zero runtime parsing overhead
Synchronous:
Application
|
v
Level Check
|
v
Template Expansion (PPX)
|
v
Context Enrichment
|
v
Filtering
|
v
Sinks
Log events follow the CLEF format:
{
"@t": "2026-01-31T23:54:42-00:00",
"@mt": "User {username} logged in from {ip_address}",
"@m": "User alice logged in from 192.168.1.1",
"@l": "Information",
"username": "alice",
"ip_address": "192.168.1.1"
}@t: RFC3339 timestamp@mt: Message template@m: Rendered message@l: Log level@i: Correlation ID (optional)- Additional fields: Template variables and context properties
Benchmark results (1 million iterations):
PPX Simple Template: 0.090s (11.2M ops/sec)
Printf Simple: 0.058s (17.1M ops/sec)
String Concat: 0.034s (29.8M ops/sec)
PPX with Formats: 0.431s (2.32M ops/sec)
Printf with Formats: 0.387s (2.58M ops/sec)
PPX JSON Output: 0.334s (2.99M ops/sec)
PPX-generated code has minimal overhead compared to hand-written Printf.
Run the test suite:
dune runtestThis runs tests across all packages:
- Core library: Level, Sink, Logger, Configuration, Global log, PPX, Parser, Circuit breaker, Metrics tests
- Lwt package: Lwt logger and sink tests
- Eio package: Eio logger and sink tests
See the examples/ directory:
basic.ml- Simple template usagelogging_basic.ml- Basic logging setup and usagelogging_advanced.ml- Multiple sinks, rolling files, enrichmentlogging_ppx.ml- PPX extension usagelogging_clef_ppx.ml- PPX with pure JSON CLEF outputlogging_clef_json.ml- Structured JSON logging
Run examples:
# Core examples
dune exec examples/basic.exe
dune exec examples/logging_basic.exe
dune exec examples/logging_advanced.exe
dune exec examples/logging_ppx.exe
dune exec examples/logging_clef_ppx.exe
dune exec examples/logging_clef_json.exeLevel- Log levels with comparison operatorsLog_event- Log event typeTemplate_parser- Template parsingTypes- Core types
Console_sink- Console output with colorsFile_sink- File output with rollingJson_sink- CLEF/JSON outputComposite_sink- Multi-sink routingNull_sink- Discard events
Logger- Logger interfaceFilter- Event filtersConfiguration- Configuration builderLog- Global loggerLog_context- Ambient context
Circuit_breaker- Error recoveryMetrics- Per-sink performance trackingTimestamp_cache- Timestamp cachingShutdown- Graceful shutdown
Lwt_logger,Lwt_configuration,Lwt_file_sink,Lwt_console_sink
Eio_logger,Eio_configuration,Eio_file_sink,Eio_console_sink
Implements the Message Templates specification:
- Named property holes:
{name} - Positional property holes:
{0},{1} - Escaped braces:
{{and}} - Operators:
@for structure,$for stringification - Format specifiers:
:formatsyntax - Alignment specifiers:
,widthsyntax - CLEF output with
@t,@mt,@m,@lfields
MIT
This implementation follows the Message Templates specification from https://messagetemplates.org/ and is inspired by Serilog's design patterns.