Logging¶
Spindle logs every request for you, and every line your code logs during a request carries that request’s id, so one search finds everything it did.
Logging from your code¶
Logging is logs. Make a source per part of your app, and log with fields of your own:
(* A source per part of the application, so its level can be set alone:
APP_LOG=warn,shop.billing=debug. *)
module Log = (val Logs.src_log (Logs.Src.create "shop.billing") : Logs.LOG)
let pence = Spindle.Path.int "pence"
let charge pence =
Log.info (fun m ->
m "charged" ~tags:(Spindle.Log.tags [ ("shop.pence", `Int pence) ]));
Ok "Charged."
Set the levels and the format from wherever you keep your settings – here two environment variables. Spindle reads no environment variable itself:
(* The framework reads no environment variable: the application hands it
two strings, and either left unset is the default. *)
let () =
match
Spindle.Log.configure ~levels:(Sys.getenv_opt "APP_LOG")
~format:(Sys.getenv_opt "APP_LOG_FORMAT")
with
| Error why ->
prerr_endline why;
exit 1
| Ok () -> Eio_main.run @@ fun env -> Spindle.serve env routes
10:24:06.913 info shop.billing [trpw9jmqsz6a] charged shop.pence=500
10:24:06.913 info spindle.http [trpw9jmqsz6a] GET /charge/500 200 http.request.method=GET url.path=/charge/500 http.response.status_code=200 duration=259958 http.response.body.size=8 http.route=/charge/{pence}
Your line carries the request’s id, [trpw9jmqsz6a], without being handed
it. The second line is the access line Spindle writes for every request:
method, path, status, duration in nanoseconds, body size and route. When
the answer is a refusal, the line also has spindle.refusal.code (say,
invalid): search for that field to find every refused request.
Lines go to stderr. The format is pretty on a terminal and JSON otherwise. The JSON keys are the ones Datadog and an OpenTelemetry Collector read without a mapping:
{"timestamp":"2026-10-08T09:24:07.936Z","level":"info","logger.name":"spindle.http","message":"GET /charge/500 200","request_id":"yxh81c3jxdrr","trace_id":"d94e1ed026bb56ed4360738efdab369b","span_id":"aa5e83d9ed21a352","http.request.method":"GET","url.path":"/charge/500","http.response.status_code":200,"duration":199000,"http.response.body.size":8,"http.route":"/charge/{pence}"}
Log.configure returns an Error naming what it could not read, and this
program prints it and exits:
Spindle.Log.setup is the same without strings. A level is a
Logs.level option, where None turns a source off:
It also takes ?format, and ?out, a function that receives each line in
place of stderr: a test’s way to keep them.
The whole app
(* Logging: the levels and the format read from where the application keeps
its settings, and a line of the application's own, which carries the id
of the request that caused it.
APP_LOG=debug dune exec examples/logging.exe
curl localhost:8080/charge/500 *)
open Spindle.Syntax
(* A source per part of the application, so its level can be set alone:
APP_LOG=warn,shop.billing=debug. *)
module Log = (val Logs.src_log (Logs.Src.create "shop.billing") : Logs.LOG)
let pence = Spindle.Path.int "pence"
let charge pence =
Log.info (fun m ->
m "charged" ~tags:(Spindle.Log.tags [ ("shop.pence", `Int pence) ]));
Ok "Charged."
let routes =
[
Spindle.get
Spindle.Path.(s "charge" / pence)
Spindle.Returns.text
(let+ pence = Spindle.param pence in
charge pence);
]
(* The framework reads no environment variable: the application hands it
two strings, and either left unset is the default. *)
let () =
match
Spindle.Log.configure ~levels:(Sys.getenv_opt "APP_LOG")
~format:(Sys.getenv_opt "APP_LOG_FORMAT")
with
| Error why ->
prerr_endline why;
exit 1
| Ok () -> Eio_main.run @@ fun env -> Spindle.serve env routes
What each line contains¶
- The request id is on every line the request causes – yours, the
database’s, a call to another server’s – and on fibers it forks.
Spindle.blockingtakes it onto its systhread for you. For a domain or a thread you start yourself, wrap the function inSpindle.Log.carry. - The trace. In JSON, every line logged during a request also has
trace_idandspan_id; the pretty format shows only the request id. A request joins the caller’s W3Ctraceparent, or starts a trace, andSpindle_clientpasses it on to the servers it calls. - Field names: put your own under a prefix (
shop.pence), since keys likestatus,hostandservicemean something to a collector. - Failures: Spindle logs what a handler raises with
error.kindanderror.stack, never its message, which is whatever the raiser wrote – a card number, a token. Your own catch-all does the same with~tags:(Spindle.Log.tags (Spindle.Log.raised exn bt)), takingbt = Printexc.get_raw_backtrace ()first thing. - Levels:
erroris a bug,warnhandled but degraded,infowhat happened (the access log),debughow. A route polled every few seconds can log its access line atDebug, as the health checks do:Spindle.get ~meta:Spindle.Meta.(empty |> add access Logs.Debug) …. - No secrets. Spindle never logs a header value, a body, a query string or a statement’s parameters, at any level.
Tracing¶
To send traces to an OpenTelemetry Collector, give Spindle.serve an
exporter:
Spindle_client.run env @@ fun client ->
Spindle_client.Otlp.run ~clock:(Eio.Stdenv.clock env)
~endpoint:"http://collector:4318" ~service:"shop" client
@@ fun trace -> Spindle.serve env ~trace routes
Then every request is a span. A request’s span is named by its method and
route (GET /users/{id}), a Spindle_client call’s by its method (GET),
and a Postgres statement’s postgresql. None is ever named by a path, so a
collector keeps one series per route, not one per URL. Add spans of your
own with:
- The exporter posts OTLP JSON to
/v1/tracesevery five seconds, or as soon as 512 spans wait.?ratio(default1.0) is the share of new traces kept; a trace joined from a caller is kept when the caller kept it. - At most 2048 spans wait; past that, and when the collector fails, spans
are dropped and counted in a
warnline. - Spans carry no secrets either: never a query string, header or statement parameter.
- To send spans elsewhere, build your own with
Spindle.Trace.exporter.
Next: static files.