From c26cf3a771ae5979624bfe515147175de5456229 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 05:47:36 +0000 Subject: [PATCH 01/13] Initialize structured logging library Co-authored-by: GPT-5 Codex --- README.md | 56 +++++++++++++++++- dune-project | 26 ++++++++ lib/dune | 3 + lib/scribe.ml | 141 ++++++++++++++++++++++++++++++++++++++++++++ lib/scribe.mli | 49 +++++++++++++++ scribe.opam | 33 +++++++++++ test/dune | 3 + test/test_scribe.ml | 81 +++++++++++++++++++++++++ 8 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 dune-project create mode 100644 lib/dune create mode 100644 lib/scribe.ml create mode 100644 lib/scribe.mli create mode 100644 scribe.opam create mode 100644 test/dune create mode 100644 test/test_scribe.ml diff --git a/README.md b/README.md index 748e4aa..7005ef4 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# scribe \ No newline at end of file +# scribe + +`scribe` is a small structured logging library for OCaml. A logger is an ordinary value that carries its level, sink, and context fields, so application code can compose loggers and library code can accept `?logger` without touching process-wide logging state. + +## Quick Start + +```ocaml +let logger = + Scribe.create + ~level:Scribe.Level.Warning + ~sink:(Scribe.Sink.stderr_json ()) + |> Scribe.with_field (Scribe.Field.string "component" "mir.parser") + +let () = + Scribe.warn logger "metadata parse failed" + [ Scribe.Field.string "reason" "malformed directive" + ; Scribe.Field.string "file" path + ] +``` + +The JSON sink writes one object per line: + +```json +{"level":"warning","message":"metadata parse failed","fields":{"component":"mir.parser","reason":"malformed directive","file":"example.md"}} +``` + +## Logger Values + +`Scribe.create` returns a self-contained logger value. `Scribe.with_field` and `Scribe.with_fields` add reusable context to that value, and call-site fields can override earlier context fields with the same key. + +```ocaml +let logger = + Scribe.create ~level:Scribe.Level.Info ~sink:(Scribe.Sink.stderr_json ()) + |> Scribe.with_field (Scribe.Field.string "component" "worker") + +let job_logger = + logger + |> Scribe.with_field (Scribe.Field.string "job_id" job_id) +``` + +## Library-Friendly Use + +Libraries should accept an optional logger and default to `Scribe.noop`. + +```ocaml +let parse ?(logger = Scribe.noop) path = + Scribe.debug logger "parse started" + [ Scribe.Field.string "file" path ]; + (* parsing work *) + () +``` + +## MVP Scope + +The first version focuses on value-based structured logging, JSON lines, stderr output, and test capture sinks. Adapters for other logging systems are intentionally left out of the MVP. diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..8c9e8f0 --- /dev/null +++ b/dune-project @@ -0,0 +1,26 @@ +(lang dune 3.23) + +(name scribe) + +(generate_opam_files true) + +(source + (github sambyeol/scribe)) + +(authors "Seokhyun Lee ") + +(maintainers "Seokhyun Lee ") + +(license MIT) + +(documentation https://github.com/sambyeol/scribe) + +(package + (name scribe) + (synopsis "Value-based structured logging for OCaml") + (description "Scribe is a small structured logging library whose logger values carry their level, sink, and context.") + (depends + (ocaml (>= 4.14)) + (dune (>= 3.23))) + (tags + ("logging" "structured-logging" "json" "library"))) diff --git a/lib/dune b/lib/dune new file mode 100644 index 0000000..a19cc9f --- /dev/null +++ b/lib/dune @@ -0,0 +1,3 @@ +(library + (name scribe) + (public_name scribe)) diff --git a/lib/scribe.ml b/lib/scribe.ml new file mode 100644 index 0000000..a360665 --- /dev/null +++ b/lib/scribe.ml @@ -0,0 +1,141 @@ +module Level = struct + type t = App | Error | Warning | Info | Debug + + let rank = function + | App -> 0 + | Error -> 1 + | Warning -> 2 + | Info -> 3 + | Debug -> 4 + + let enabled ~configured level = rank level <= rank configured + + let to_string = function + | App -> "app" + | Error -> "error" + | Warning -> "warning" + | Info -> "info" + | Debug -> "debug" +end + +module Field = struct + type value = String of string | Int of int | Bool of bool + type t = { key : string; value : value } + + let make key value = { key; value } + let string key value = make key (String value) + let int key value = make key (Int value) + let bool key value = make key (Bool value) + let key field = field.key + let value field = field.value +end + +module Event = struct + type t = + { level : Level.t + ; message : string + ; fields : Field.t list + } + + let make ~level ~message ~fields = { level; message; fields } + let level event = event.level + let message event = event.message + let fields event = event.fields +end + +module Sink = struct + type t = Event.t -> unit + + let make emit = emit + let noop _event = () + let emit sink event = sink event + + let add_json_string buffer value = + Buffer.add_char buffer '"'; + String.iter + (fun char -> + match char with + | '"' -> Buffer.add_string buffer "\\\"" + | '\\' -> Buffer.add_string buffer "\\\\" + | '\b' -> Buffer.add_string buffer "\\b" + | '\012' -> Buffer.add_string buffer "\\f" + | '\n' -> Buffer.add_string buffer "\\n" + | '\r' -> Buffer.add_string buffer "\\r" + | '\t' -> Buffer.add_string buffer "\\t" + | char when Char.code char < 0x20 -> Buffer.add_string buffer (Printf.sprintf "\\u%04x" (Char.code char)) + | char -> Buffer.add_char buffer char) + value; + Buffer.add_char buffer '"' + + let add_json_value buffer = function + | Field.String value -> add_json_string buffer value + | Field.Int value -> Buffer.add_string buffer (string_of_int value) + | Field.Bool value -> Buffer.add_string buffer (string_of_bool value) + + let add_fields buffer fields = + Buffer.add_char buffer '{'; + List.iteri + (fun index field -> + if index > 0 then Buffer.add_char buffer ','; + add_json_string buffer (Field.key field); + Buffer.add_char buffer ':'; + add_json_value buffer (Field.value field)) + fields; + Buffer.add_char buffer '}' + + let event_to_json event = + let buffer = Buffer.create 128 in + Buffer.add_string buffer "{\"level\":"; + add_json_string buffer (Level.to_string (Event.level event)); + Buffer.add_string buffer ",\"message\":"; + add_json_string buffer (Event.message event); + Buffer.add_string buffer ",\"fields\":"; + add_fields buffer (Event.fields event); + Buffer.add_char buffer '}'; + Buffer.contents buffer + + let channel_json channel event = + output_string channel (event_to_json event); + output_char channel '\n'; + flush channel + + let stderr_json () = channel_json stderr + + let test_capture () = + let events = ref [] in + let sink event = events := event :: !events in + (sink, fun () -> List.rev !events) +end + +type t = + { level : Level.t + ; sink : Sink.t + ; fields : Field.t list + } + +let noop = { level = Level.Debug; sink = Sink.noop; fields = [] } +let create ~level ~sink = { level; sink; fields = [] } +let with_field field logger = { logger with fields = logger.fields @ [ field ] } +let with_fields fields logger = { logger with fields = logger.fields @ fields } + +let merge_fields logger_fields call_fields = + let seen = Hashtbl.create 16 in + let merge acc field = + let key = Field.key field in + if Hashtbl.mem seen key then acc + else ( + Hashtbl.add seen key (); + field :: acc) + in + List.fold_left merge [] (List.rev_append call_fields (List.rev logger_fields)) + +let log logger level message fields = + if Level.enabled ~configured:logger.level level then + let fields = merge_fields logger.fields fields in + Sink.emit logger.sink (Event.make ~level ~message ~fields) + +let app logger message fields = log logger Level.App message fields +let error logger message fields = log logger Level.Error message fields +let warn logger message fields = log logger Level.Warning message fields +let info logger message fields = log logger Level.Info message fields +let debug logger message fields = log logger Level.Debug message fields diff --git a/lib/scribe.mli b/lib/scribe.mli new file mode 100644 index 0000000..1322f9c --- /dev/null +++ b/lib/scribe.mli @@ -0,0 +1,49 @@ +(** Value-based structured logging. *) + +module Level : sig + type t = App | Error | Warning | Info | Debug + + val to_string : t -> string +end + +module Field : sig + type value = String of string | Int of int | Bool of bool + type t + + val string : string -> string -> t + val int : string -> int -> t + val bool : string -> bool -> t + val key : t -> string + val value : t -> value +end + +module Event : sig + type t + + val level : t -> Level.t + val message : t -> string + val fields : t -> Field.t list +end + +module Sink : sig + type t + + val make : (Event.t -> unit) -> t + val noop : t + val channel_json : out_channel -> t + val stderr_json : unit -> t + val test_capture : unit -> t * (unit -> Event.t list) +end + +type t + +val noop : t +val create : level:Level.t -> sink:Sink.t -> t +val with_field : Field.t -> t -> t +val with_fields : Field.t list -> t -> t +val log : t -> Level.t -> string -> Field.t list -> unit +val app : t -> string -> Field.t list -> unit +val error : t -> string -> Field.t list -> unit +val warn : t -> string -> Field.t list -> unit +val info : t -> string -> Field.t list -> unit +val debug : t -> string -> Field.t list -> unit diff --git a/scribe.opam b/scribe.opam new file mode 100644 index 0000000..962e98f --- /dev/null +++ b/scribe.opam @@ -0,0 +1,33 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "Value-based structured logging for OCaml" +description: + "Scribe is a small structured logging library whose logger values carry their level, sink, and context." +maintainer: ["Seokhyun Lee "] +authors: ["Seokhyun Lee "] +license: "MIT" +tags: ["logging" "structured-logging" "json" "library"] +homepage: "https://github.com/sambyeol/scribe" +doc: "https://github.com/sambyeol/scribe" +bug-reports: "https://github.com/sambyeol/scribe/issues" +depends: [ + "ocaml" {>= "4.14"} + "dune" {>= "3.23"} + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://github.com/sambyeol/scribe.git" +x-maintenance-intent: ["(latest)"] diff --git a/test/dune b/test/dune new file mode 100644 index 0000000..5940e2c --- /dev/null +++ b/test/dune @@ -0,0 +1,3 @@ +(test + (name test_scribe) + (libraries scribe)) diff --git a/test/test_scribe.ml b/test/test_scribe.ml new file mode 100644 index 0000000..63d1f03 --- /dev/null +++ b/test/test_scribe.ml @@ -0,0 +1,81 @@ +let assert_equal expected actual = + if expected <> actual then + failwith (Printf.sprintf "expected %s, got %s" expected actual) + +let assert_int_equal expected actual = + if expected <> actual then + failwith (Printf.sprintf "expected %d, got %d" expected actual) + +let read_file path = + let channel = open_in_bin path in + Fun.protect + ~finally:(fun () -> close_in channel) + (fun () -> + let length = in_channel_length channel in + really_input_string channel length) + +let require_one events = + match events with + | [ event ] -> event + | events -> failwith (Printf.sprintf "expected one event, got %d" (List.length events)) + +let field_pair field = (Scribe.Field.key field, Scribe.Field.value field) + +let test_level_filtering () = + let sink, events = Scribe.Sink.test_capture () in + let logger = Scribe.create ~level:Scribe.Level.Warning ~sink in + Scribe.app logger "app" []; + Scribe.error logger "error" []; + Scribe.warn logger "warn" []; + Scribe.info logger "info" []; + Scribe.debug logger "debug" []; + let levels = List.map Scribe.Event.level (events ()) in + assert (levels = [ Scribe.Level.App; Scribe.Level.Error; Scribe.Level.Warning ]) + +let test_context_and_override () = + let sink, events = Scribe.Sink.test_capture () in + let logger = + Scribe.create ~level:Scribe.Level.Debug ~sink + |> Scribe.with_field (Scribe.Field.string "component" "parser") + |> Scribe.with_field (Scribe.Field.string "request_id" "context") + in + Scribe.warn logger "metadata parse failed" + [ Scribe.Field.string "request_id" "call" + ; Scribe.Field.int "line" 42 + ]; + let event = require_one (events ()) in + let fields = List.map field_pair (Scribe.Event.fields event) in + assert ( + fields + = [ ("component", Scribe.Field.String "parser") + ; ("request_id", Scribe.Field.String "call") + ; ("line", Scribe.Field.Int 42) + ]) + +let test_json_sink () = + let path = Filename.temp_file "scribe-json-" ".log" in + let channel = open_out_bin path in + let sink = Scribe.Sink.channel_json channel in + let logger = Scribe.create ~level:Scribe.Level.Warning ~sink in + Scribe.warn logger "metadata\nparse failed" + [ Scribe.Field.string "reason" "malformed \"directive\"" + ; Scribe.Field.int "line" 42 + ; Scribe.Field.bool "ok" false + ]; + close_out channel; + let output = read_file path in + Sys.remove path; + assert_equal + "{\"level\":\"warning\",\"message\":\"metadata\\nparse failed\",\"fields\":{\"reason\":\"malformed \\\"directive\\\"\",\"line\":42,\"ok\":false}}\n" + output + +let test_noop () = + Scribe.warn Scribe.noop "ignored" [ Scribe.Field.string "component" "test" ]; + Scribe.info Scribe.noop "ignored" [] + +let () = + test_level_filtering (); + test_context_and_override (); + test_json_sink (); + test_noop (); + assert_int_equal 1 1 From 6cf20141364138a1e6d35778b6e45c2cddc53da4 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:02:25 +0000 Subject: [PATCH 02/13] Use Alcotest and document public API Co-authored-by: GPT-5 Codex --- dune-project | 3 ++- lib/scribe.mli | 64 ++++++++++++++++++++++++++++++++++++++++++++- scribe.opam | 1 + test/dune | 2 +- test/test_scribe.ml | 55 +++++++++++++++++++++++--------------- 5 files changed, 101 insertions(+), 24 deletions(-) diff --git a/dune-project b/dune-project index 8c9e8f0..8b145a9 100644 --- a/dune-project +++ b/dune-project @@ -21,6 +21,7 @@ (description "Scribe is a small structured logging library whose logger values carry their level, sink, and context.") (depends (ocaml (>= 4.14)) - (dune (>= 3.23))) + (dune (>= 3.23)) + (alcotest :with-test)) (tags ("logging" "structured-logging" "json" "library"))) diff --git a/lib/scribe.mli b/lib/scribe.mli index 1322f9c..df11be8 100644 --- a/lib/scribe.mli +++ b/lib/scribe.mli @@ -1,49 +1,111 @@ -(** Value-based structured logging. *) +(** Value-based structured logging. + + A [t] logger is an ordinary value that carries its own level, sink, and context fields. Library code can accept an optional logger and default to [noop] without touching process-wide logging state. *) module Level : sig + (** Log verbosity. + + Levels are ordered from least verbose to most verbose: [App], [Error], [Warning], [Info], then [Debug]. A logger configured at [Warning] emits [App], [Error], and [Warning] events, and filters [Info] and [Debug]. *) + type t = App | Error | Warning | Info | Debug + (** The level of a log event. *) val to_string : t -> string + (** [to_string level] returns the stable lowercase representation used by JSON sinks. *) end module Field : sig + (** Structured fields attached to log events. *) + type value = String of string | Int of int | Bool of bool + (** Supported field values. *) + type t + (** A key-value field. *) val string : string -> string -> t + (** [string key value] creates a string field. *) + val int : string -> int -> t + (** [int key value] creates an integer field. *) + val bool : string -> bool -> t + (** [bool key value] creates a boolean field. *) + val key : t -> string + (** [key field] returns the field key. *) + val value : t -> value + (** [value field] returns the field value. *) end module Event : sig + (** Events delivered to sinks. *) + type t + (** A fully materialized log event. *) val level : t -> Level.t + (** [level event] returns the event level. *) + val message : t -> string + (** [message event] returns the event message. *) + val fields : t -> Field.t list + (** [fields event] returns merged context and call-site fields. *) end module Sink : sig + (** Event destinations. *) + type t + (** A sink consumes emitted events. *) val make : (Event.t -> unit) -> t + (** [make emit] creates a sink from an event callback. *) + val noop : t + (** A sink that discards every event. *) + val channel_json : out_channel -> t + (** [channel_json channel] writes each event as one JSON object followed by a newline, then flushes [channel]. *) + val stderr_json : unit -> t + (** [stderr_json ()] writes JSON lines to [stderr]. *) + val test_capture : unit -> t * (unit -> Event.t list) + (** [test_capture ()] returns a sink and a snapshot function for assertions. Events are returned in emission order. *) end type t +(** A logger value. *) val noop : t +(** A logger that discards every event. *) + val create : level:Level.t -> sink:Sink.t -> t +(** [create ~level ~sink] creates a logger with no context fields. *) + val with_field : Field.t -> t -> t +(** [with_field field logger] returns [logger] with [field] appended to its context. *) + val with_fields : Field.t list -> t -> t +(** [with_fields fields logger] returns [logger] with [fields] appended to its context in order. *) + val log : t -> Level.t -> string -> Field.t list -> unit +(** [log logger level message fields] emits an event when [level] is enabled for [logger]. Logger context fields are merged before call-site [fields], and later fields override earlier fields with the same key. *) + val app : t -> string -> Field.t list -> unit +(** Emit an application-level event. *) + val error : t -> string -> Field.t list -> unit +(** Emit an error event. *) + val warn : t -> string -> Field.t list -> unit +(** Emit a warning event. *) + val info : t -> string -> Field.t list -> unit +(** Emit an informational event. *) + val debug : t -> string -> Field.t list -> unit +(** Emit a debug event. *) diff --git a/scribe.opam b/scribe.opam index 962e98f..088803a 100644 --- a/scribe.opam +++ b/scribe.opam @@ -13,6 +13,7 @@ bug-reports: "https://github.com/sambyeol/scribe/issues" depends: [ "ocaml" {>= "4.14"} "dune" {>= "3.23"} + "alcotest" {with-test} "odoc" {with-doc} ] build: [ diff --git a/test/dune b/test/dune index 5940e2c..7b374b7 100644 --- a/test/dune +++ b/test/dune @@ -1,3 +1,3 @@ (test (name test_scribe) - (libraries scribe)) + (libraries scribe alcotest)) diff --git a/test/test_scribe.ml b/test/test_scribe.ml index 63d1f03..b5b60a2 100644 --- a/test/test_scribe.ml +++ b/test/test_scribe.ml @@ -1,10 +1,15 @@ -let assert_equal expected actual = - if expected <> actual then - failwith (Printf.sprintf "expected %s, got %s" expected actual) +let pp_level formatter level = + Format.pp_print_string formatter (Scribe.Level.to_string level) -let assert_int_equal expected actual = - if expected <> actual then - failwith (Printf.sprintf "expected %d, got %d" expected actual) +let level = Alcotest.testable pp_level ( = ) + +let pp_field_value formatter = function + | Scribe.Field.String value -> Format.fprintf formatter "String %S" value + | Scribe.Field.Int value -> Format.fprintf formatter "Int %d" value + | Scribe.Field.Bool value -> Format.fprintf formatter "Bool %b" value + +let field_value = Alcotest.testable pp_field_value ( = ) +let field_pair = Alcotest.pair Alcotest.string field_value let read_file path = let channel = open_in_bin path in @@ -17,9 +22,9 @@ let read_file path = let require_one events = match events with | [ event ] -> event - | events -> failwith (Printf.sprintf "expected one event, got %d" (List.length events)) + | events -> Alcotest.failf "expected one event, got %d" (List.length events) -let field_pair field = (Scribe.Field.key field, Scribe.Field.value field) +let event_field_pair field = (Scribe.Field.key field, Scribe.Field.value field) let test_level_filtering () = let sink, events = Scribe.Sink.test_capture () in @@ -30,7 +35,10 @@ let test_level_filtering () = Scribe.info logger "info" []; Scribe.debug logger "debug" []; let levels = List.map Scribe.Event.level (events ()) in - assert (levels = [ Scribe.Level.App; Scribe.Level.Error; Scribe.Level.Warning ]) + Alcotest.(check (list level)) + "emitted levels" + [ Scribe.Level.App; Scribe.Level.Error; Scribe.Level.Warning ] + levels let test_context_and_override () = let sink, events = Scribe.Sink.test_capture () in @@ -44,13 +52,14 @@ let test_context_and_override () = ; Scribe.Field.int "line" 42 ]; let event = require_one (events ()) in - let fields = List.map field_pair (Scribe.Event.fields event) in - assert ( + let fields = List.map event_field_pair (Scribe.Event.fields event) in + Alcotest.(check (list field_pair)) + "merged fields" + [ ("component", Scribe.Field.String "parser") + ; ("request_id", Scribe.Field.String "call") + ; ("line", Scribe.Field.Int 42) + ] fields - = [ ("component", Scribe.Field.String "parser") - ; ("request_id", Scribe.Field.String "call") - ; ("line", Scribe.Field.Int 42) - ]) let test_json_sink () = let path = Filename.temp_file "scribe-json-" ".log" in @@ -65,7 +74,8 @@ let test_json_sink () = close_out channel; let output = read_file path in Sys.remove path; - assert_equal + Alcotest.(check string) + "json line" "{\"level\":\"warning\",\"message\":\"metadata\\nparse failed\",\"fields\":{\"reason\":\"malformed \\\"directive\\\"\",\"line\":42,\"ok\":false}}\n" output @@ -74,8 +84,11 @@ let test_noop () = Scribe.info Scribe.noop "ignored" [] let () = - test_level_filtering (); - test_context_and_override (); - test_json_sink (); - test_noop (); - assert_int_equal 1 1 + Alcotest.run "scribe" + [ ( "logger" + , [ Alcotest.test_case "level filtering" `Quick test_level_filtering + ; Alcotest.test_case "context fields and override" `Quick test_context_and_override + ; Alcotest.test_case "noop logger" `Quick test_noop + ] ) + ; ("sink", [ Alcotest.test_case "json sink" `Quick test_json_sink ]) + ] From 9efccfd62b6bbc73b5f4d1c9c5d94bb599dc556d Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:27:00 +0000 Subject: [PATCH 03/13] Separate JSON sink adapter Co-authored-by: GPT-5 Codex --- lib/json/dune | 4 +++ lib/json/scribe_json.ml | 51 ++++++++++++++++++++++++++++++++++++ lib/json/scribe_json.mli | 10 +++++++ lib/scribe.ml | 56 ---------------------------------------- lib/scribe.mli | 9 ------- test/dune | 2 +- test/test_scribe.ml | 11 +++++--- 7 files changed, 74 insertions(+), 69 deletions(-) create mode 100644 lib/json/dune create mode 100644 lib/json/scribe_json.ml create mode 100644 lib/json/scribe_json.mli diff --git a/lib/json/dune b/lib/json/dune new file mode 100644 index 0000000..8864285 --- /dev/null +++ b/lib/json/dune @@ -0,0 +1,4 @@ +(library + (name scribe_json) + (public_name scribe.json) + (libraries scribe)) diff --git a/lib/json/scribe_json.ml b/lib/json/scribe_json.ml new file mode 100644 index 0000000..f724821 --- /dev/null +++ b/lib/json/scribe_json.ml @@ -0,0 +1,51 @@ +let add_json_string buffer value = + Buffer.add_char buffer '"'; + String.iter + (fun char -> + match char with + | '"' -> Buffer.add_string buffer "\\\"" + | '\\' -> Buffer.add_string buffer "\\\\" + | '\b' -> Buffer.add_string buffer "\\b" + | '\012' -> Buffer.add_string buffer "\\f" + | '\n' -> Buffer.add_string buffer "\\n" + | '\r' -> Buffer.add_string buffer "\\r" + | '\t' -> Buffer.add_string buffer "\\t" + | char when Char.code char < 0x20 -> Buffer.add_string buffer (Printf.sprintf "\\u%04x" (Char.code char)) + | char -> Buffer.add_char buffer char) + value; + Buffer.add_char buffer '"' + +let add_value buffer = function + | Scribe.Field.String value -> add_json_string buffer value + | Scribe.Field.Int value -> Buffer.add_string buffer (string_of_int value) + | Scribe.Field.Bool value -> Buffer.add_string buffer (string_of_bool value) + +let add_fields buffer fields = + Buffer.add_char buffer '{'; + List.iteri + (fun index field -> + if index > 0 then Buffer.add_char buffer ','; + add_json_string buffer (Scribe.Field.key field); + Buffer.add_char buffer ':'; + add_value buffer (Scribe.Field.value field)) + fields; + Buffer.add_char buffer '}' + +let string_of_event event = + let buffer = Buffer.create 128 in + Buffer.add_string buffer "{\"level\":"; + add_json_string buffer (Scribe.Level.to_string (Scribe.Event.level event)); + Buffer.add_string buffer ",\"message\":"; + add_json_string buffer (Scribe.Event.message event); + Buffer.add_string buffer ",\"fields\":"; + add_fields buffer (Scribe.Event.fields event); + Buffer.add_char buffer '}'; + Buffer.contents buffer + +let channel output = + Scribe.Sink.make (fun event -> + output_string output (string_of_event event); + output_char output '\n'; + flush output) + +let stderr () = channel stderr diff --git a/lib/json/scribe_json.mli b/lib/json/scribe_json.mli new file mode 100644 index 0000000..665e5e0 --- /dev/null +++ b/lib/json/scribe_json.mli @@ -0,0 +1,10 @@ +(** JSON line sinks for Scribe events. *) + +val string_of_event : Scribe.Event.t -> string +(** [string_of_event event] renders [event] as a compact JSON object. *) + +val channel : out_channel -> Scribe.Sink.t +(** [channel output] writes each event as one JSON object followed by a newline, then flushes [output]. *) + +val stderr : unit -> Scribe.Sink.t +(** [stderr ()] writes JSON lines to standard error. *) diff --git a/lib/scribe.ml b/lib/scribe.ml index a360665..9841c5d 100644 --- a/lib/scribe.ml +++ b/lib/scribe.ml @@ -49,62 +49,6 @@ module Sink = struct let make emit = emit let noop _event = () let emit sink event = sink event - - let add_json_string buffer value = - Buffer.add_char buffer '"'; - String.iter - (fun char -> - match char with - | '"' -> Buffer.add_string buffer "\\\"" - | '\\' -> Buffer.add_string buffer "\\\\" - | '\b' -> Buffer.add_string buffer "\\b" - | '\012' -> Buffer.add_string buffer "\\f" - | '\n' -> Buffer.add_string buffer "\\n" - | '\r' -> Buffer.add_string buffer "\\r" - | '\t' -> Buffer.add_string buffer "\\t" - | char when Char.code char < 0x20 -> Buffer.add_string buffer (Printf.sprintf "\\u%04x" (Char.code char)) - | char -> Buffer.add_char buffer char) - value; - Buffer.add_char buffer '"' - - let add_json_value buffer = function - | Field.String value -> add_json_string buffer value - | Field.Int value -> Buffer.add_string buffer (string_of_int value) - | Field.Bool value -> Buffer.add_string buffer (string_of_bool value) - - let add_fields buffer fields = - Buffer.add_char buffer '{'; - List.iteri - (fun index field -> - if index > 0 then Buffer.add_char buffer ','; - add_json_string buffer (Field.key field); - Buffer.add_char buffer ':'; - add_json_value buffer (Field.value field)) - fields; - Buffer.add_char buffer '}' - - let event_to_json event = - let buffer = Buffer.create 128 in - Buffer.add_string buffer "{\"level\":"; - add_json_string buffer (Level.to_string (Event.level event)); - Buffer.add_string buffer ",\"message\":"; - add_json_string buffer (Event.message event); - Buffer.add_string buffer ",\"fields\":"; - add_fields buffer (Event.fields event); - Buffer.add_char buffer '}'; - Buffer.contents buffer - - let channel_json channel event = - output_string channel (event_to_json event); - output_char channel '\n'; - flush channel - - let stderr_json () = channel_json stderr - - let test_capture () = - let events = ref [] in - let sink event = events := event :: !events in - (sink, fun () -> List.rev !events) end type t = diff --git a/lib/scribe.mli b/lib/scribe.mli index df11be8..786a5bd 100644 --- a/lib/scribe.mli +++ b/lib/scribe.mli @@ -66,15 +66,6 @@ module Sink : sig val noop : t (** A sink that discards every event. *) - - val channel_json : out_channel -> t - (** [channel_json channel] writes each event as one JSON object followed by a newline, then flushes [channel]. *) - - val stderr_json : unit -> t - (** [stderr_json ()] writes JSON lines to [stderr]. *) - - val test_capture : unit -> t * (unit -> Event.t list) - (** [test_capture ()] returns a sink and a snapshot function for assertions. Events are returned in emission order. *) end type t diff --git a/test/dune b/test/dune index 7b374b7..42ae35e 100644 --- a/test/dune +++ b/test/dune @@ -1,3 +1,3 @@ (test (name test_scribe) - (libraries scribe alcotest)) + (libraries scribe scribe.json alcotest)) diff --git a/test/test_scribe.ml b/test/test_scribe.ml index b5b60a2..cdace0c 100644 --- a/test/test_scribe.ml +++ b/test/test_scribe.ml @@ -26,8 +26,13 @@ let require_one events = let event_field_pair field = (Scribe.Field.key field, Scribe.Field.value field) +let capture_sink () = + let events = ref [] in + let sink = Scribe.Sink.make (fun event -> events := event :: !events) in + (sink, fun () -> List.rev !events) + let test_level_filtering () = - let sink, events = Scribe.Sink.test_capture () in + let sink, events = capture_sink () in let logger = Scribe.create ~level:Scribe.Level.Warning ~sink in Scribe.app logger "app" []; Scribe.error logger "error" []; @@ -41,7 +46,7 @@ let test_level_filtering () = levels let test_context_and_override () = - let sink, events = Scribe.Sink.test_capture () in + let sink, events = capture_sink () in let logger = Scribe.create ~level:Scribe.Level.Debug ~sink |> Scribe.with_field (Scribe.Field.string "component" "parser") @@ -64,7 +69,7 @@ let test_context_and_override () = let test_json_sink () = let path = Filename.temp_file "scribe-json-" ".log" in let channel = open_out_bin path in - let sink = Scribe.Sink.channel_json channel in + let sink = Scribe_json.channel channel in let logger = Scribe.create ~level:Scribe.Level.Warning ~sink in Scribe.warn logger "metadata\nparse failed" [ Scribe.Field.string "reason" "malformed \"directive\"" From 1e3c24a116ae8aa3db9bc4f6cf1a1638b11723c0 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:27:37 +0000 Subject: [PATCH 04/13] Document JSON adapter usage Co-authored-by: GPT-5 Codex --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7005ef4..06a2a98 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,17 @@ ## Quick Start +Use the core logger with the JSON adapter: + +```lisp +(libraries scribe scribe.json) +``` + ```ocaml let logger = Scribe.create ~level:Scribe.Level.Warning - ~sink:(Scribe.Sink.stderr_json ()) + ~sink:(Scribe_json.stderr ()) |> Scribe.with_field (Scribe.Field.string "component" "mir.parser") let () = @@ -30,7 +36,7 @@ The JSON sink writes one object per line: ```ocaml let logger = - Scribe.create ~level:Scribe.Level.Info ~sink:(Scribe.Sink.stderr_json ()) + Scribe.create ~level:Scribe.Level.Info ~sink:(Scribe_json.stderr ()) |> Scribe.with_field (Scribe.Field.string "component" "worker") let job_logger = @@ -52,4 +58,4 @@ let parse ?(logger = Scribe.noop) path = ## MVP Scope -The first version focuses on value-based structured logging, JSON lines, stderr output, and test capture sinks. Adapters for other logging systems are intentionally left out of the MVP. +The first version focuses on value-based structured logging and a small JSON lines adapter. Other logging adapters are intentionally left out of the MVP. From 3ca646688e648d1445d703cdc10acfcaa8776360 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:31:56 +0000 Subject: [PATCH 05/13] Introduce sinks sublibrary Co-authored-by: GPT-5 Codex --- lib/json/dune | 4 ---- lib/json/scribe_json.mli | 10 ---------- lib/scribe.ml | 3 +-- lib/scribe.mli | 3 --- lib/sinks/dune | 4 ++++ lib/{json/scribe_json.ml => sinks/scribe_sinks.ml} | 10 ++++++---- lib/sinks/scribe_sinks.mli | 13 +++++++++++++ test/dune | 2 +- test/test_scribe.ml | 11 +++++++++-- 9 files changed, 34 insertions(+), 26 deletions(-) delete mode 100644 lib/json/dune delete mode 100644 lib/json/scribe_json.mli create mode 100644 lib/sinks/dune rename lib/{json/scribe_json.ml => sinks/scribe_sinks.ml} (90%) create mode 100644 lib/sinks/scribe_sinks.mli diff --git a/lib/json/dune b/lib/json/dune deleted file mode 100644 index 8864285..0000000 --- a/lib/json/dune +++ /dev/null @@ -1,4 +0,0 @@ -(library - (name scribe_json) - (public_name scribe.json) - (libraries scribe)) diff --git a/lib/json/scribe_json.mli b/lib/json/scribe_json.mli deleted file mode 100644 index 665e5e0..0000000 --- a/lib/json/scribe_json.mli +++ /dev/null @@ -1,10 +0,0 @@ -(** JSON line sinks for Scribe events. *) - -val string_of_event : Scribe.Event.t -> string -(** [string_of_event event] renders [event] as a compact JSON object. *) - -val channel : out_channel -> Scribe.Sink.t -(** [channel output] writes each event as one JSON object followed by a newline, then flushes [output]. *) - -val stderr : unit -> Scribe.Sink.t -(** [stderr ()] writes JSON lines to standard error. *) diff --git a/lib/scribe.ml b/lib/scribe.ml index 9841c5d..5b23c23 100644 --- a/lib/scribe.ml +++ b/lib/scribe.ml @@ -47,7 +47,6 @@ module Sink = struct type t = Event.t -> unit let make emit = emit - let noop _event = () let emit sink event = sink event end @@ -57,7 +56,7 @@ type t = ; fields : Field.t list } -let noop = { level = Level.Debug; sink = Sink.noop; fields = [] } +let noop = { level = Level.Debug; sink = Sink.make (fun _event -> ()); fields = [] } let create ~level ~sink = { level; sink; fields = [] } let with_field field logger = { logger with fields = logger.fields @ [ field ] } let with_fields fields logger = { logger with fields = logger.fields @ fields } diff --git a/lib/scribe.mli b/lib/scribe.mli index 786a5bd..d6fd5dc 100644 --- a/lib/scribe.mli +++ b/lib/scribe.mli @@ -63,9 +63,6 @@ module Sink : sig val make : (Event.t -> unit) -> t (** [make emit] creates a sink from an event callback. *) - - val noop : t - (** A sink that discards every event. *) end type t diff --git a/lib/sinks/dune b/lib/sinks/dune new file mode 100644 index 0000000..9f2fff6 --- /dev/null +++ b/lib/sinks/dune @@ -0,0 +1,4 @@ +(library + (name scribe_sinks) + (public_name scribe.sinks) + (libraries scribe)) diff --git a/lib/json/scribe_json.ml b/lib/sinks/scribe_sinks.ml similarity index 90% rename from lib/json/scribe_json.ml rename to lib/sinks/scribe_sinks.ml index f724821..7c16e53 100644 --- a/lib/json/scribe_json.ml +++ b/lib/sinks/scribe_sinks.ml @@ -1,3 +1,5 @@ +let noop = Scribe.Sink.make (fun _event -> ()) + let add_json_string buffer value = Buffer.add_char buffer '"'; String.iter @@ -31,7 +33,7 @@ let add_fields buffer fields = fields; Buffer.add_char buffer '}' -let string_of_event event = +let json_string_of_event event = let buffer = Buffer.create 128 in Buffer.add_string buffer "{\"level\":"; add_json_string buffer (Scribe.Level.to_string (Scribe.Event.level event)); @@ -42,10 +44,10 @@ let string_of_event event = Buffer.add_char buffer '}'; Buffer.contents buffer -let channel output = +let json output = Scribe.Sink.make (fun event -> - output_string output (string_of_event event); + output_string output (json_string_of_event event); output_char output '\n'; flush output) -let stderr () = channel stderr +let stderr_json () = json stderr diff --git a/lib/sinks/scribe_sinks.mli b/lib/sinks/scribe_sinks.mli new file mode 100644 index 0000000..58f4bf2 --- /dev/null +++ b/lib/sinks/scribe_sinks.mli @@ -0,0 +1,13 @@ +(** Common sinks for Scribe. *) + +val noop : Scribe.Sink.t +(** A sink that discards every event. *) + +val json_string_of_event : Scribe.Event.t -> string +(** [json_string_of_event event] renders [event] as a compact JSON object. *) + +val json : out_channel -> Scribe.Sink.t +(** [json output] writes each event as one JSON object followed by a newline, then flushes [output]. *) + +val stderr_json : unit -> Scribe.Sink.t +(** [stderr_json ()] writes JSON lines to standard error. *) diff --git a/test/dune b/test/dune index 42ae35e..9f19b71 100644 --- a/test/dune +++ b/test/dune @@ -1,3 +1,3 @@ (test (name test_scribe) - (libraries scribe scribe.json alcotest)) + (libraries scribe scribe.sinks alcotest)) diff --git a/test/test_scribe.ml b/test/test_scribe.ml index cdace0c..062a87b 100644 --- a/test/test_scribe.ml +++ b/test/test_scribe.ml @@ -69,7 +69,7 @@ let test_context_and_override () = let test_json_sink () = let path = Filename.temp_file "scribe-json-" ".log" in let channel = open_out_bin path in - let sink = Scribe_json.channel channel in + let sink = Scribe_sinks.json channel in let logger = Scribe.create ~level:Scribe.Level.Warning ~sink in Scribe.warn logger "metadata\nparse failed" [ Scribe.Field.string "reason" "malformed \"directive\"" @@ -88,6 +88,10 @@ let test_noop () = Scribe.warn Scribe.noop "ignored" [ Scribe.Field.string "component" "test" ]; Scribe.info Scribe.noop "ignored" [] +let test_noop_sink () = + let logger = Scribe.create ~level:Scribe.Level.Debug ~sink:Scribe_sinks.noop in + Scribe.debug logger "ignored" [] + let () = Alcotest.run "scribe" [ ( "logger" @@ -95,5 +99,8 @@ let () = ; Alcotest.test_case "context fields and override" `Quick test_context_and_override ; Alcotest.test_case "noop logger" `Quick test_noop ] ) - ; ("sink", [ Alcotest.test_case "json sink" `Quick test_json_sink ]) + ; ( "sink" + , [ Alcotest.test_case "json sink" `Quick test_json_sink + ; Alcotest.test_case "noop sink" `Quick test_noop_sink + ] ) ] From dbdba83aa45b50d14791c19b1667710d9a1876a1 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:32:43 +0000 Subject: [PATCH 06/13] Document sinks sublibrary usage Co-authored-by: GPT-5 Codex --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 06a2a98..580003c 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ Use the core logger with the JSON adapter: ```lisp -(libraries scribe scribe.json) +(libraries scribe scribe.sinks) ``` ```ocaml let logger = Scribe.create ~level:Scribe.Level.Warning - ~sink:(Scribe_json.stderr ()) + ~sink:(Scribe_sinks.stderr_json ()) |> Scribe.with_field (Scribe.Field.string "component" "mir.parser") let () = @@ -36,7 +36,7 @@ The JSON sink writes one object per line: ```ocaml let logger = - Scribe.create ~level:Scribe.Level.Info ~sink:(Scribe_json.stderr ()) + Scribe.create ~level:Scribe.Level.Info ~sink:(Scribe_sinks.stderr_json ()) |> Scribe.with_field (Scribe.Field.string "component" "worker") let job_logger = @@ -58,4 +58,4 @@ let parse ?(logger = Scribe.noop) path = ## MVP Scope -The first version focuses on value-based structured logging and a small JSON lines adapter. Other logging adapters are intentionally left out of the MVP. +The first version focuses on value-based structured logging and a small sink collection with noop and JSON lines sinks. Other logging adapters are intentionally left out of the MVP. From 6d3882abc2b24a2febdd917fa9a9c3692d12feb5 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:35:03 +0000 Subject: [PATCH 07/13] Make noop sink a constructor Co-authored-by: GPT-5 Codex --- lib/sinks/scribe_sinks.ml | 2 +- lib/sinks/scribe_sinks.mli | 4 ++-- test/test_scribe.ml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/sinks/scribe_sinks.ml b/lib/sinks/scribe_sinks.ml index 7c16e53..5128354 100644 --- a/lib/sinks/scribe_sinks.ml +++ b/lib/sinks/scribe_sinks.ml @@ -1,4 +1,4 @@ -let noop = Scribe.Sink.make (fun _event -> ()) +let noop () = Scribe.Sink.make (fun _event -> ()) let add_json_string buffer value = Buffer.add_char buffer '"'; diff --git a/lib/sinks/scribe_sinks.mli b/lib/sinks/scribe_sinks.mli index 58f4bf2..5fd1979 100644 --- a/lib/sinks/scribe_sinks.mli +++ b/lib/sinks/scribe_sinks.mli @@ -1,7 +1,7 @@ (** Common sinks for Scribe. *) -val noop : Scribe.Sink.t -(** A sink that discards every event. *) +val noop : unit -> Scribe.Sink.t +(** [noop ()] returns a sink that discards every event. *) val json_string_of_event : Scribe.Event.t -> string (** [json_string_of_event event] renders [event] as a compact JSON object. *) diff --git a/test/test_scribe.ml b/test/test_scribe.ml index 062a87b..ac7b84e 100644 --- a/test/test_scribe.ml +++ b/test/test_scribe.ml @@ -89,7 +89,7 @@ let test_noop () = Scribe.info Scribe.noop "ignored" [] let test_noop_sink () = - let logger = Scribe.create ~level:Scribe.Level.Debug ~sink:Scribe_sinks.noop in + let logger = Scribe.create ~level:Scribe.Level.Debug ~sink:(Scribe_sinks.noop ()) in Scribe.debug logger "ignored" [] let () = From ee56a1f48b79f639b7cb6ace440067f4462154b6 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:39:57 +0000 Subject: [PATCH 08/13] Split sinks into nested modules Co-authored-by: GPT-5 Codex --- lib/sinks/{scribe_sinks.ml => json.ml} | 10 ++++------ lib/sinks/json.mli | 10 ++++++++++ lib/sinks/noop.ml | 1 + lib/sinks/noop.mli | 4 ++++ lib/sinks/scribe_sinks.mli | 13 ------------- test/test_scribe.ml | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) rename lib/sinks/{scribe_sinks.ml => json.ml} (90%) create mode 100644 lib/sinks/json.mli create mode 100644 lib/sinks/noop.ml create mode 100644 lib/sinks/noop.mli delete mode 100644 lib/sinks/scribe_sinks.mli diff --git a/lib/sinks/scribe_sinks.ml b/lib/sinks/json.ml similarity index 90% rename from lib/sinks/scribe_sinks.ml rename to lib/sinks/json.ml index 5128354..f724821 100644 --- a/lib/sinks/scribe_sinks.ml +++ b/lib/sinks/json.ml @@ -1,5 +1,3 @@ -let noop () = Scribe.Sink.make (fun _event -> ()) - let add_json_string buffer value = Buffer.add_char buffer '"'; String.iter @@ -33,7 +31,7 @@ let add_fields buffer fields = fields; Buffer.add_char buffer '}' -let json_string_of_event event = +let string_of_event event = let buffer = Buffer.create 128 in Buffer.add_string buffer "{\"level\":"; add_json_string buffer (Scribe.Level.to_string (Scribe.Event.level event)); @@ -44,10 +42,10 @@ let json_string_of_event event = Buffer.add_char buffer '}'; Buffer.contents buffer -let json output = +let channel output = Scribe.Sink.make (fun event -> - output_string output (json_string_of_event event); + output_string output (string_of_event event); output_char output '\n'; flush output) -let stderr_json () = json stderr +let stderr () = channel stderr diff --git a/lib/sinks/json.mli b/lib/sinks/json.mli new file mode 100644 index 0000000..e42c224 --- /dev/null +++ b/lib/sinks/json.mli @@ -0,0 +1,10 @@ +(** JSON line sinks. *) + +val string_of_event : Scribe.Event.t -> string +(** [string_of_event event] renders [event] as a compact JSON object. *) + +val channel : out_channel -> Scribe.Sink.t +(** [channel output] writes each event as one JSON object followed by a newline, then flushes [output]. *) + +val stderr : unit -> Scribe.Sink.t +(** [stderr ()] writes JSON lines to standard error. *) diff --git a/lib/sinks/noop.ml b/lib/sinks/noop.ml new file mode 100644 index 0000000..ffee609 --- /dev/null +++ b/lib/sinks/noop.ml @@ -0,0 +1 @@ +let create () = Scribe.Sink.make (fun _event -> ()) diff --git a/lib/sinks/noop.mli b/lib/sinks/noop.mli new file mode 100644 index 0000000..d4998d7 --- /dev/null +++ b/lib/sinks/noop.mli @@ -0,0 +1,4 @@ +(** Sink that discards events. *) + +val create : unit -> Scribe.Sink.t +(** [create ()] returns a sink that discards every event. *) diff --git a/lib/sinks/scribe_sinks.mli b/lib/sinks/scribe_sinks.mli deleted file mode 100644 index 5fd1979..0000000 --- a/lib/sinks/scribe_sinks.mli +++ /dev/null @@ -1,13 +0,0 @@ -(** Common sinks for Scribe. *) - -val noop : unit -> Scribe.Sink.t -(** [noop ()] returns a sink that discards every event. *) - -val json_string_of_event : Scribe.Event.t -> string -(** [json_string_of_event event] renders [event] as a compact JSON object. *) - -val json : out_channel -> Scribe.Sink.t -(** [json output] writes each event as one JSON object followed by a newline, then flushes [output]. *) - -val stderr_json : unit -> Scribe.Sink.t -(** [stderr_json ()] writes JSON lines to standard error. *) diff --git a/test/test_scribe.ml b/test/test_scribe.ml index ac7b84e..595c8c1 100644 --- a/test/test_scribe.ml +++ b/test/test_scribe.ml @@ -69,7 +69,7 @@ let test_context_and_override () = let test_json_sink () = let path = Filename.temp_file "scribe-json-" ".log" in let channel = open_out_bin path in - let sink = Scribe_sinks.json channel in + let sink = Scribe_sinks.Json.channel channel in let logger = Scribe.create ~level:Scribe.Level.Warning ~sink in Scribe.warn logger "metadata\nparse failed" [ Scribe.Field.string "reason" "malformed \"directive\"" @@ -89,7 +89,7 @@ let test_noop () = Scribe.info Scribe.noop "ignored" [] let test_noop_sink () = - let logger = Scribe.create ~level:Scribe.Level.Debug ~sink:(Scribe_sinks.noop ()) in + let logger = Scribe.create ~level:Scribe.Level.Debug ~sink:(Scribe_sinks.Noop.create ()) in Scribe.debug logger "ignored" [] let () = From bb0bf271103e147ea78c6f6d213662d39beea871 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:40:35 +0000 Subject: [PATCH 09/13] Document nested sink modules Co-authored-by: GPT-5 Codex --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 580003c..5a84e39 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Use the core logger with the JSON adapter: let logger = Scribe.create ~level:Scribe.Level.Warning - ~sink:(Scribe_sinks.stderr_json ()) + ~sink:(Scribe_sinks.Json.stderr ()) |> Scribe.with_field (Scribe.Field.string "component" "mir.parser") let () = @@ -36,7 +36,7 @@ The JSON sink writes one object per line: ```ocaml let logger = - Scribe.create ~level:Scribe.Level.Info ~sink:(Scribe_sinks.stderr_json ()) + Scribe.create ~level:Scribe.Level.Info ~sink:(Scribe_sinks.Json.stderr ()) |> Scribe.with_field (Scribe.Field.string "component" "worker") let job_logger = From 953f2a001b3699a222adf6d86e1ecb462a6192b4 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:48:51 +0000 Subject: [PATCH 10/13] Configure ocamlformat Co-authored-by: GPT-5 Codex --- .ocamlformat | 2 ++ lib/scribe.ml | 30 +++++++++++++++++----- lib/scribe.mli | 62 +++++++++++++++++++++++++-------------------- lib/sinks/json.ml | 34 +++++++++++++++---------- lib/sinks/json.mli | 6 ++--- lib/sinks/noop.mli | 2 +- test/test_scribe.ml | 53 ++++++++++++++++++++++++++------------ 7 files changed, 121 insertions(+), 68 deletions(-) create mode 100644 .ocamlformat diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..67b077f --- /dev/null +++ b/.ocamlformat @@ -0,0 +1,2 @@ +version = 0.29.0 +profile = janestreet diff --git a/lib/scribe.ml b/lib/scribe.ml index 5b23c23..aa4ce06 100644 --- a/lib/scribe.ml +++ b/lib/scribe.ml @@ -1,5 +1,10 @@ module Level = struct - type t = App | Error | Warning | Info | Debug + type t = + | App + | Error + | Warning + | Info + | Debug let rank = function | App -> 0 @@ -7,6 +12,7 @@ module Level = struct | Warning -> 2 | Info -> 3 | Debug -> 4 + ;; let enabled ~configured level = rank level <= rank configured @@ -16,11 +22,19 @@ module Level = struct | Warning -> "warning" | Info -> "info" | Debug -> "debug" + ;; end module Field = struct - type value = String of string | Int of int | Bool of bool - type t = { key : string; value : value } + type value = + | String of string + | Int of int + | Bool of bool + + type t = + { key : string + ; value : value + } let make key value = { key; value } let string key value = make key (String value) @@ -65,17 +79,21 @@ let merge_fields logger_fields call_fields = let seen = Hashtbl.create 16 in let merge acc field = let key = Field.key field in - if Hashtbl.mem seen key then acc + if Hashtbl.mem seen key + then acc else ( Hashtbl.add seen key (); field :: acc) in List.fold_left merge [] (List.rev_append call_fields (List.rev logger_fields)) +;; let log logger level message fields = - if Level.enabled ~configured:logger.level level then + if Level.enabled ~configured:logger.level level + then ( let fields = merge_fields logger.fields fields in - Sink.emit logger.sink (Event.make ~level ~message ~fields) + Sink.emit logger.sink (Event.make ~level ~message ~fields)) +;; let app logger message fields = log logger Level.App message fields let error logger message fields = log logger Level.Error message fields diff --git a/lib/scribe.mli b/lib/scribe.mli index d6fd5dc..80c7482 100644 --- a/lib/scribe.mli +++ b/lib/scribe.mli @@ -7,93 +7,99 @@ module Level : sig Levels are ordered from least verbose to most verbose: [App], [Error], [Warning], [Info], then [Debug]. A logger configured at [Warning] emits [App], [Error], and [Warning] events, and filters [Info] and [Debug]. *) - type t = App | Error | Warning | Info | Debug - (** The level of a log event. *) + type t = + | App + | Error + | Warning + | Info + | Debug - val to_string : t -> string (** [to_string level] returns the stable lowercase representation used by JSON sinks. *) + val to_string : t -> string end module Field : sig (** Structured fields attached to log events. *) - type value = String of string | Int of int | Bool of bool - (** Supported field values. *) + type value = + | String of string + | Int of int + | Bool of bool - type t (** A key-value field. *) + type t - val string : string -> string -> t (** [string key value] creates a string field. *) + val string : string -> string -> t - val int : string -> int -> t (** [int key value] creates an integer field. *) + val int : string -> int -> t - val bool : string -> bool -> t (** [bool key value] creates a boolean field. *) + val bool : string -> bool -> t - val key : t -> string (** [key field] returns the field key. *) + val key : t -> string - val value : t -> value (** [value field] returns the field value. *) + val value : t -> value end module Event : sig (** Events delivered to sinks. *) - type t (** A fully materialized log event. *) + type t - val level : t -> Level.t (** [level event] returns the event level. *) + val level : t -> Level.t - val message : t -> string (** [message event] returns the event message. *) + val message : t -> string - val fields : t -> Field.t list (** [fields event] returns merged context and call-site fields. *) + val fields : t -> Field.t list end module Sink : sig (** Event destinations. *) - type t (** A sink consumes emitted events. *) + type t - val make : (Event.t -> unit) -> t (** [make emit] creates a sink from an event callback. *) + val make : (Event.t -> unit) -> t end -type t (** A logger value. *) +type t -val noop : t (** A logger that discards every event. *) +val noop : t -val create : level:Level.t -> sink:Sink.t -> t (** [create ~level ~sink] creates a logger with no context fields. *) +val create : level:Level.t -> sink:Sink.t -> t -val with_field : Field.t -> t -> t (** [with_field field logger] returns [logger] with [field] appended to its context. *) +val with_field : Field.t -> t -> t -val with_fields : Field.t list -> t -> t (** [with_fields fields logger] returns [logger] with [fields] appended to its context in order. *) +val with_fields : Field.t list -> t -> t -val log : t -> Level.t -> string -> Field.t list -> unit (** [log logger level message fields] emits an event when [level] is enabled for [logger]. Logger context fields are merged before call-site [fields], and later fields override earlier fields with the same key. *) +val log : t -> Level.t -> string -> Field.t list -> unit -val app : t -> string -> Field.t list -> unit (** Emit an application-level event. *) +val app : t -> string -> Field.t list -> unit -val error : t -> string -> Field.t list -> unit (** Emit an error event. *) +val error : t -> string -> Field.t list -> unit -val warn : t -> string -> Field.t list -> unit (** Emit a warning event. *) +val warn : t -> string -> Field.t list -> unit -val info : t -> string -> Field.t list -> unit (** Emit an informational event. *) +val info : t -> string -> Field.t list -> unit -val debug : t -> string -> Field.t list -> unit (** Emit a debug event. *) +val debug : t -> string -> Field.t list -> unit diff --git a/lib/sinks/json.ml b/lib/sinks/json.ml index f724821..ed45c08 100644 --- a/lib/sinks/json.ml +++ b/lib/sinks/json.ml @@ -2,34 +2,38 @@ let add_json_string buffer value = Buffer.add_char buffer '"'; String.iter (fun char -> - match char with - | '"' -> Buffer.add_string buffer "\\\"" - | '\\' -> Buffer.add_string buffer "\\\\" - | '\b' -> Buffer.add_string buffer "\\b" - | '\012' -> Buffer.add_string buffer "\\f" - | '\n' -> Buffer.add_string buffer "\\n" - | '\r' -> Buffer.add_string buffer "\\r" - | '\t' -> Buffer.add_string buffer "\\t" - | char when Char.code char < 0x20 -> Buffer.add_string buffer (Printf.sprintf "\\u%04x" (Char.code char)) - | char -> Buffer.add_char buffer char) + match char with + | '"' -> Buffer.add_string buffer "\\\"" + | '\\' -> Buffer.add_string buffer "\\\\" + | '\b' -> Buffer.add_string buffer "\\b" + | '\012' -> Buffer.add_string buffer "\\f" + | '\n' -> Buffer.add_string buffer "\\n" + | '\r' -> Buffer.add_string buffer "\\r" + | '\t' -> Buffer.add_string buffer "\\t" + | char when Char.code char < 0x20 -> + Buffer.add_string buffer (Printf.sprintf "\\u%04x" (Char.code char)) + | char -> Buffer.add_char buffer char) value; Buffer.add_char buffer '"' +;; let add_value buffer = function | Scribe.Field.String value -> add_json_string buffer value | Scribe.Field.Int value -> Buffer.add_string buffer (string_of_int value) | Scribe.Field.Bool value -> Buffer.add_string buffer (string_of_bool value) +;; let add_fields buffer fields = Buffer.add_char buffer '{'; List.iteri (fun index field -> - if index > 0 then Buffer.add_char buffer ','; - add_json_string buffer (Scribe.Field.key field); - Buffer.add_char buffer ':'; - add_value buffer (Scribe.Field.value field)) + if index > 0 then Buffer.add_char buffer ','; + add_json_string buffer (Scribe.Field.key field); + Buffer.add_char buffer ':'; + add_value buffer (Scribe.Field.value field)) fields; Buffer.add_char buffer '}' +;; let string_of_event event = let buffer = Buffer.create 128 in @@ -41,11 +45,13 @@ let string_of_event event = add_fields buffer (Scribe.Event.fields event); Buffer.add_char buffer '}'; Buffer.contents buffer +;; let channel output = Scribe.Sink.make (fun event -> output_string output (string_of_event event); output_char output '\n'; flush output) +;; let stderr () = channel stderr diff --git a/lib/sinks/json.mli b/lib/sinks/json.mli index e42c224..f54fe1e 100644 --- a/lib/sinks/json.mli +++ b/lib/sinks/json.mli @@ -1,10 +1,10 @@ (** JSON line sinks. *) -val string_of_event : Scribe.Event.t -> string (** [string_of_event event] renders [event] as a compact JSON object. *) +val string_of_event : Scribe.Event.t -> string -val channel : out_channel -> Scribe.Sink.t (** [channel output] writes each event as one JSON object followed by a newline, then flushes [output]. *) +val channel : out_channel -> Scribe.Sink.t -val stderr : unit -> Scribe.Sink.t (** [stderr ()] writes JSON lines to standard error. *) +val stderr : unit -> Scribe.Sink.t diff --git a/lib/sinks/noop.mli b/lib/sinks/noop.mli index d4998d7..4f1fa7e 100644 --- a/lib/sinks/noop.mli +++ b/lib/sinks/noop.mli @@ -1,4 +1,4 @@ (** Sink that discards events. *) -val create : unit -> Scribe.Sink.t (** [create ()] returns a sink that discards every event. *) +val create : unit -> Scribe.Sink.t diff --git a/test/test_scribe.ml b/test/test_scribe.ml index 595c8c1..fff3f72 100644 --- a/test/test_scribe.ml +++ b/test/test_scribe.ml @@ -1,5 +1,6 @@ let pp_level formatter level = Format.pp_print_string formatter (Scribe.Level.to_string level) +;; let level = Alcotest.testable pp_level ( = ) @@ -7,6 +8,7 @@ let pp_field_value formatter = function | Scribe.Field.String value -> Format.fprintf formatter "String %S" value | Scribe.Field.Int value -> Format.fprintf formatter "Int %d" value | Scribe.Field.Bool value -> Format.fprintf formatter "Bool %b" value +;; let field_value = Alcotest.testable pp_field_value ( = ) let field_pair = Alcotest.pair Alcotest.string field_value @@ -16,20 +18,23 @@ let read_file path = Fun.protect ~finally:(fun () -> close_in channel) (fun () -> - let length = in_channel_length channel in - really_input_string channel length) + let length = in_channel_length channel in + really_input_string channel length) +;; let require_one events = match events with | [ event ] -> event | events -> Alcotest.failf "expected one event, got %d" (List.length events) +;; -let event_field_pair field = (Scribe.Field.key field, Scribe.Field.value field) +let event_field_pair field = Scribe.Field.key field, Scribe.Field.value field let capture_sink () = let events = ref [] in let sink = Scribe.Sink.make (fun event -> events := event :: !events) in - (sink, fun () -> List.rev !events) + sink, fun () -> List.rev !events +;; let test_level_filtering () = let sink, events = capture_sink () in @@ -44,6 +49,7 @@ let test_level_filtering () = "emitted levels" [ Scribe.Level.App; Scribe.Level.Error; Scribe.Level.Warning ] levels +;; let test_context_and_override () = let sink, events = capture_sink () in @@ -52,26 +58,29 @@ let test_context_and_override () = |> Scribe.with_field (Scribe.Field.string "component" "parser") |> Scribe.with_field (Scribe.Field.string "request_id" "context") in - Scribe.warn logger "metadata parse failed" - [ Scribe.Field.string "request_id" "call" - ; Scribe.Field.int "line" 42 - ]; + Scribe.warn + logger + "metadata parse failed" + [ Scribe.Field.string "request_id" "call"; Scribe.Field.int "line" 42 ]; let event = require_one (events ()) in let fields = List.map event_field_pair (Scribe.Event.fields event) in Alcotest.(check (list field_pair)) "merged fields" - [ ("component", Scribe.Field.String "parser") - ; ("request_id", Scribe.Field.String "call") - ; ("line", Scribe.Field.Int 42) + [ "component", Scribe.Field.String "parser" + ; "request_id", Scribe.Field.String "call" + ; "line", Scribe.Field.Int 42 ] fields +;; let test_json_sink () = let path = Filename.temp_file "scribe-json-" ".log" in let channel = open_out_bin path in let sink = Scribe_sinks.Json.channel channel in let logger = Scribe.create ~level:Scribe.Level.Warning ~sink in - Scribe.warn logger "metadata\nparse failed" + Scribe.warn + logger + "metadata\nparse failed" [ Scribe.Field.string "reason" "malformed \"directive\"" ; Scribe.Field.int "line" 42 ; Scribe.Field.bool "ok" false @@ -81,22 +90,33 @@ let test_json_sink () = Sys.remove path; Alcotest.(check string) "json line" - "{\"level\":\"warning\",\"message\":\"metadata\\nparse failed\",\"fields\":{\"reason\":\"malformed \\\"directive\\\"\",\"line\":42,\"ok\":false}}\n" + "{\"level\":\"warning\",\"message\":\"metadata\\nparse \ + failed\",\"fields\":{\"reason\":\"malformed \ + \\\"directive\\\"\",\"line\":42,\"ok\":false}}\n" output +;; let test_noop () = Scribe.warn Scribe.noop "ignored" [ Scribe.Field.string "component" "test" ]; Scribe.info Scribe.noop "ignored" [] +;; let test_noop_sink () = - let logger = Scribe.create ~level:Scribe.Level.Debug ~sink:(Scribe_sinks.Noop.create ()) in + let logger = + Scribe.create ~level:Scribe.Level.Debug ~sink:(Scribe_sinks.Noop.create ()) + in Scribe.debug logger "ignored" [] +;; let () = - Alcotest.run "scribe" + Alcotest.run + "scribe" [ ( "logger" , [ Alcotest.test_case "level filtering" `Quick test_level_filtering - ; Alcotest.test_case "context fields and override" `Quick test_context_and_override + ; Alcotest.test_case + "context fields and override" + `Quick + test_context_and_override ; Alcotest.test_case "noop logger" `Quick test_noop ] ) ; ( "sink" @@ -104,3 +124,4 @@ let () = ; Alcotest.test_case "noop sink" `Quick test_noop_sink ] ) ] +;; From 3fc20e242299f42030777f8c70e09304e6f85f34 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:49:43 +0000 Subject: [PATCH 11/13] Add GitHub CI and Dependabot Co-authored-by: GPT-5 Codex --- .github/dependabot.yml | 8 ++++++ .github/workflows/ci.yml | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ab28393 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "main" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d0b88a9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up OCaml + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: "5.4" + dune-cache: true + + - name: Install dependencies + run: opam install . --deps-only --with-test + + - name: Build + run: opam exec -- dune build + + - name: Test + run: opam exec -- dune runtest + + format: + name: Format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up OCaml + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: "5.4" + dune-cache: true + + - name: Install dependencies + run: opam install . --deps-only --with-test + + - name: Install formatter + run: opam install ocamlformat.0.29.0 + + - name: Check formatting + run: opam exec -- dune build @fmt From 1a55cb0debc2af136f358fb0c6efa9758b0b6982 Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 06:52:34 +0000 Subject: [PATCH 12/13] Refine CI dependencies Co-authored-by: GPT-5 Codex --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0b88a9..81e0026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up OCaml uses: ocaml/setup-ocaml@v3 @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up OCaml uses: ocaml/setup-ocaml@v3 @@ -52,7 +52,7 @@ jobs: dune-cache: true - name: Install dependencies - run: opam install . --deps-only --with-test + run: opam install . --deps-only - name: Install formatter run: opam install ocamlformat.0.29.0 From 22ed00884e74033eb37c944d725218b6c8e8c95e Mon Sep 17 00:00:00 2001 From: Henry Lee Date: Tue, 2 Jun 2026 07:07:55 +0000 Subject: [PATCH 13/13] Document repository conventions Co-authored-by: GPT-5 Codex --- AGENTS.md | 19 +++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 20 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..578d610 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# AGENTS.md + +## Repository Rules + +- Keep `main` clean; do implementation work on a separate branch. +- Include a co-authored-by trailer when creating commits. +- Do not hard wrap Markdown files. + +## OCaml Project Conventions + +- Use `dune` as the build and test driver. +- Use `Alcotest` for tests. +- Use `ocamlformat` with the repository `.ocamlformat`; formatting should pass `dune build @fmt`. +- Run `dune build`, `dune runtest`, and `dune build @install` before committing changes. + +## Library Design Conventions + +- Keep the core `Scribe` library focused on value-based structured logging: levels, fields, events, the sink abstraction, and loggers. +- Keep common concrete sinks outside the core library in `scribe.sinks`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file