Skip to main content

src/witness.gleam

// 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)