// witness
// Copyright (C) 2026 Olivia Streun and contributors.
//
// This software is licensed under the European Union Public Licence (EUPL) v1.2.
// You may not use this work except in compliance with the Licence.
// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
//
// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
// See LICENSE file in the repository root for full details.
//
//
// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND.
// See the Licence for the specific language governing permissions and limitations.
import gleam/bool
import gleam/dict
import gleam/float
import gleam/int
import gleam/io
import gleam/json
import gleam/list
import gleam/option
import gleam/string
import gleam/time/calendar
import gleam/time/timestamp
import logging
// LOGGING ----------------------------------------------------------------------
pub fn this(
level level: logging.LogLevel,
message message: String,
fields fields: List(Field),
) -> Nil {
let config = get_config(default_config())
// prepend global fields
let fields = list.append(config.global_fields, fields)
// prepend process level fields
let fields = list.append(get_process_fields([]), fields)
// log to em
config.sinks
|> dict.each(fn(format, sinks) {
let message = format_message(level, format, message, fields)
list.map(sinks, fn(sink) { sink(level, message) })
})
Nil
}
fn format_message(
level: logging.LogLevel,
format: Format,
message: String,
fields: List(Field),
) -> String {
case format {
Json -> {
[
string(
"timestamp",
timestamp.to_rfc3339(timestamp.system_time(), calendar.utc_offset),
),
string("level", level_to_string(level)),
string("message", message),
..fields
]
|> list.map(field_to_json)
|> json.object
|> json.to_string
}
Text -> {
let message =
timestamp_to_short_string(timestamp.system_time()) <> " " <> message
fields
|> list.fold(message, fn(acc, field) {
acc <> "\n " <> fields_to_string(field)
})
}
}
}
/// Turn a log level into a "[LEVEL]" string
///
pub fn level_to_short_string(level: logging.LogLevel) -> String {
case level {
logging.Emergency -> "[EMERG]"
logging.Alert -> "[ALERT]"
logging.Critical -> "[CRIT]"
logging.Error -> "[ERROR]"
logging.Warning -> "[WARN]"
logging.Notice -> "[NOTICE]"
logging.Info -> "[INFO]"
logging.Debug -> "[DEBUG]"
}
}
fn timestamp_to_short_string(timestamp: timestamp.Timestamp) -> String {
let #(_, calendar.TimeOfDay(hours:, minutes:, seconds:, nanoseconds: _)) =
timestamp.to_calendar(timestamp, calendar.local_offset())
string.pad_start(int.to_string(hours), to: 2, with: "0")
<> ":"
<> string.pad_start(int.to_string(minutes), to: 2, with: "0")
<> ":"
<> string.pad_start(int.to_string(seconds), to: 2, with: "0")
}
fn level_to_string(level: logging.LogLevel) -> String {
case level {
logging.Emergency -> "emergency"
logging.Alert -> "alert"
logging.Critical -> "critical"
logging.Error -> "error"
logging.Warning -> "warning"
logging.Notice -> "notice"
logging.Info -> "info"
logging.Debug -> "debug"
}
}
// FIELDS -----------------------------------------------------------------------
/// A unit of structured data
///
/// Use these to create them:
/// ```gleam
/// witness.string
/// witness.int
/// witness.float
/// witness.bool
/// ```
///
pub opaque type Field {
WString(name: String, value: String)
WInt(name: String, value: Int)
WFloat(name: String, value: Float)
WBool(name: String, value: Bool)
}
fn field_to_json(field: Field) -> #(String, json.Json) {
case field {
WString(name:, value:) -> #(name, json.string(value))
WInt(name:, value:) -> #(name, json.int(value))
WFloat(name:, value:) -> #(name, json.float(value))
WBool(name:, value:) -> #(name, json.bool(value))
}
}
fn fields_to_string(field: Field) -> String {
case field {
WString(name:, value:) -> {
name <> ": " <> value
}
WInt(name:, value:) -> {
name <> ": " <> int.to_string(value)
}
WFloat(name:, value:) -> {
name <> ": " <> float.to_string(value)
}
WBool(name:, value:) -> {
name <> ": " <> bool.to_string(value)
}
}
}
/// Create a string field
///
/// # Example
///
/// ```gleam
/// witness.this(logging.Info, "Something happened", [witness.string("what", "i dont know, honestly")])
/// ```
///
pub fn string(name name: String, value value: String) -> Field {
WString(name:, value:)
}
/// Create an int field
///
/// # Example
///
/// ```gleam
/// witness.this(logging.Info, "Things happened", [witness.int("how_many", 21)])
/// ```
///
pub fn int(name name: String, value value: Int) -> Field {
WInt(name:, value:)
}
/// Create a float field
///
/// # Example
///
/// ```gleam
/// witness.this(logging.Info, "Things happened", [witness.float("how_many", 3.14159)])
/// ```
///
pub fn float(name name: String, value value: Float) -> Field {
WFloat(name:, value:)
}
/// Create a bool field
///
/// # Example
///
/// ```gleam
/// witness.this(logging.Info, "Things happened", [witness.bool("really", True)])
/// ```
///
pub fn bool(name name: String, value value: Bool) -> Field {
WBool(name:, value:)
}
// CONFIG -----------------------------------------------------------------------
/// The default config logging to `io.println` with format `witness.Text`
///
pub fn default_config() -> Config {
dict.new()
|> dict.upsert(Text, upsert_sink(fn(_, message) { io.println(message) }))
|> Config([])
}
fn upsert_sink(sink: Sink) -> fn(option.Option(List(Sink))) -> List(Sink) {
fn(x) {
case x {
option.Some(x) -> [sink, ..x]
option.None -> [sink]
}
}
}
pub opaque type Config {
Config(sinks: dict.Dict(Format, List(Sink)), global_fields: List(Field))
}
/// The currently available formats
///
pub type Format {
/// (Pretty printed for reading convenience)
/// ```json
/// {
/// "timestamp": "2026-05-28T18:30:40.020522296Z",
/// "level": "info",
/// "message": "Some log message",
/// "example": "you can add structured data",
/// "also_bools": true,
/// "and_floats": 1.23,
/// "ints_as_well": 3
/// }
/// ```
///
Json
/// ```
/// [INFO] 20:30:40 Some log message
/// example: you can add structured data
/// also_bools: True
/// and_floats: 1.23
/// ints_as_well: 3
/// ```
///
Text
}
@external(erlang, "witness_ffi", "get_config")
fn get_config(default: Config) -> Config
/// Set your config globally
///
/// # Example
///
/// ```gleam
/// witness.empty_config()
/// |> with_sink(witness.Json, logging.log)
/// |> witness.set_config
/// ```
///
@external(erlang, "witness_ffi", "set_config")
pub fn set_config(config: Config) -> Nil
/// Get a new empty config
///
/// # Example
///
/// ```gleam
/// witness.empty_config()
/// |> with_sink(witness.Json, logging.log)
/// |> witness.set_config
/// ```
///
pub fn empty_config() -> Config {
Config(dict.new(), [])
}
pub type Sink =
fn(logging.LogLevel, String) -> Nil
/// Add a sink with a specific formatting to a config
///
/// # Example
///
/// ```gleam
/// witness.empty_config()
/// |> with_sink(witness.Json, logging.log)
/// |> witness.set_config
/// ```
///
pub fn with_sink(config: Config, format: Format, sink: Sink) -> Config {
let sinks =
config.sinks
|> dict.upsert(format, upsert_sink(sink))
Config(..config, sinks:)
}
/// These fields will be prepended to every message logged through witness.
/// Does not replace fields from previous calls.
///
/// # Example
///
/// ```gleam
///
/// witness.empty_config()
/// |> with_sink(witness.Json, logging.log)
/// |> witness.with_global_fields([
/// witness.string("application", "my_application"),
/// witness.string("version", "1.0.0"),
/// ])
/// |> witness.set_config
/// ```
///
pub fn with_global_fields(config: Config, fields: List(Field)) -> Config {
let global_fields = list.append(config.global_fields, fields)
Config(..config, global_fields:)
}
/// Put per process fields into the process dictionary.
/// Overwrites all previously set fields for the process.
///
/// These fields will be prepended to every message that process logs.
///
/// # Example
///
/// ```gleam
/// witness.set_process_fields([
/// witness.string("process_name", "my special process"),
/// ])
/// ```
///
@external(erlang, "witness_ffi", "set_process_fields")
pub fn set_process_fields(fields: List(Field)) -> Nil
/// Add per process fields into the process dictionary.
/// Does not replace fields from previous calls.
///
/// These fields will be prepended to every message that process logs.
///
/// # Example
///
/// ```gleam
/// witness.set_process_fields([
/// witness.string("process_name", "my special process"),
/// ])
/// ```
///
pub fn add_process_fields(fields: List(Field)) {
list.append(get_process_fields([]), fields)
|> set_process_fields
}
@external(erlang, "witness_ffi", "get_process_fields")
fn get_process_fields(fields: List(Field)) -> List(Field)