Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "main"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .ocamlformat
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
version = 0.29.0
profile = janestreet
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions CLAUDE.md
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,61 @@
# scribe
# 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.
27 changes: 27 additions & 0 deletions dune-project
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
(lang dune 3.23)

(name scribe)

(generate_opam_files true)

(source
(github sambyeol/scribe))

(authors "Seokhyun Lee <gbvrcx@gmail.com>")

(maintainers "Seokhyun Lee <gbvrcx@gmail.com>")

(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")))
3 changes: 3 additions & 0 deletions lib/dune
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
(library
(name scribe)
(public_name scribe))
102 changes: 102 additions & 0 deletions lib/scribe.ml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading