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..81e0026 --- /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@v6 + + - 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@v6 + + - 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 + + - name: Install formatter + run: opam install ocamlformat.0.29.0 + + - name: Check formatting + run: opam exec -- dune build @fmt 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/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 diff --git a/README.md b/README.md index 748e4aa..5a84e39 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# 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 + +Use the core logger with the JSON adapter: + +```lisp +(libraries scribe scribe.sinks) +``` + +```ocaml +let logger = + Scribe.create + ~level:Scribe.Level.Warning + ~sink:(Scribe_sinks.Json.stderr ()) + |> 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_sinks.Json.stderr ()) + |> 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 and a small sink collection with noop and JSON lines sinks. Other logging adapters are intentionally left out of the MVP. diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..8b145a9 --- /dev/null +++ b/dune-project @@ -0,0 +1,27 @@ +(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)) + (alcotest :with-test)) + (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..aa4ce06 --- /dev/null +++ b/lib/scribe.ml @@ -0,0 +1,102 @@ +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 emit sink event = sink event +end + +type t = + { level : Level.t + ; sink : Sink.t + ; fields : Field.t list + } + +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 } + +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..80c7482 --- /dev/null +++ b/lib/scribe.mli @@ -0,0 +1,105 @@ +(** 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 + + (** [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 + + (** A key-value field. *) + type t + + (** [string key value] creates a string field. *) + val string : string -> string -> t + + (** [int key value] creates an integer field. *) + val int : string -> int -> t + + (** [bool key value] creates a boolean field. *) + val bool : string -> bool -> t + + (** [key field] returns the field key. *) + val key : t -> string + + (** [value field] returns the field value. *) + val value : t -> value +end + +module Event : sig + (** Events delivered to sinks. *) + + (** A fully materialized log event. *) + type t + + (** [level event] returns the event level. *) + val level : t -> Level.t + + (** [message event] returns the event message. *) + val message : t -> string + + (** [fields event] returns merged context and call-site fields. *) + val fields : t -> Field.t list +end + +module Sink : sig + (** Event destinations. *) + + (** A sink consumes emitted events. *) + type t + + (** [make emit] creates a sink from an event callback. *) + val make : (Event.t -> unit) -> t +end + +(** A logger value. *) +type t + +(** A logger that discards every event. *) +val noop : t + +(** [create ~level ~sink] creates a logger with no context fields. *) +val create : level:Level.t -> sink:Sink.t -> t + +(** [with_field field logger] returns [logger] with [field] appended to its context. *) +val with_field : Field.t -> t -> t + +(** [with_fields fields logger] returns [logger] with [fields] appended to its context in order. *) +val with_fields : Field.t list -> t -> t + +(** [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 + +(** Emit an application-level event. *) +val app : t -> string -> Field.t list -> unit + +(** Emit an error event. *) +val error : t -> string -> Field.t list -> unit + +(** Emit a warning event. *) +val warn : t -> string -> Field.t list -> unit + +(** Emit an informational event. *) +val info : t -> string -> Field.t list -> unit + +(** Emit a debug event. *) +val debug : t -> string -> Field.t list -> unit 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/sinks/json.ml b/lib/sinks/json.ml new file mode 100644 index 0000000..ed45c08 --- /dev/null +++ b/lib/sinks/json.ml @@ -0,0 +1,57 @@ +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/sinks/json.mli b/lib/sinks/json.mli new file mode 100644 index 0000000..f54fe1e --- /dev/null +++ b/lib/sinks/json.mli @@ -0,0 +1,10 @@ +(** JSON line sinks. *) + +(** [string_of_event event] renders [event] as a compact JSON object. *) +val string_of_event : Scribe.Event.t -> string + +(** [channel output] writes each event as one JSON object followed by a newline, then flushes [output]. *) +val channel : out_channel -> Scribe.Sink.t + +(** [stderr ()] writes JSON lines to standard error. *) +val stderr : unit -> Scribe.Sink.t 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..4f1fa7e --- /dev/null +++ b/lib/sinks/noop.mli @@ -0,0 +1,4 @@ +(** Sink that discards events. *) + +(** [create ()] returns a sink that discards every event. *) +val create : unit -> Scribe.Sink.t diff --git a/scribe.opam b/scribe.opam new file mode 100644 index 0000000..088803a --- /dev/null +++ b/scribe.opam @@ -0,0 +1,34 @@ +# 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"} + "alcotest" {with-test} + "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..9f19b71 --- /dev/null +++ b/test/dune @@ -0,0 +1,3 @@ +(test + (name test_scribe) + (libraries scribe scribe.sinks alcotest)) diff --git a/test/test_scribe.ml b/test/test_scribe.ml new file mode 100644 index 0000000..fff3f72 --- /dev/null +++ b/test/test_scribe.ml @@ -0,0 +1,127 @@ +let pp_level formatter level = + Format.pp_print_string formatter (Scribe.Level.to_string level) +;; + +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 + 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 -> Alcotest.failf "expected one event, got %d" (List.length 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 = capture_sink () 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 + Alcotest.(check (list level)) + "emitted levels" + [ Scribe.Level.App; Scribe.Level.Error; Scribe.Level.Warning ] + levels +;; + +let test_context_and_override () = + let sink, events = capture_sink () 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 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 +;; + +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.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; + Alcotest.(check string) + "json line" + "{\"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 + Scribe.debug logger "ignored" [] +;; + +let () = + 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 + ; Alcotest.test_case "noop sink" `Quick test_noop_sink + ] ) + ] +;;