// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A JSON shape other tools already read.
pub(all) enum Schema {
Serlog
Pino
Bunyan
Zap
Slog
Structlog
Logstash
Ecs
Gelf
Otel
} derive(Eq, Debug)
///|
pub extend Schema with Eq::{equal, not_equal}
///|
pub extend Schema with Debug::{to_repr}
///|
/// The name used in configuration files and vector files.
pub fn Schema::name(self : Schema) -> StringView {
match self {
Serlog => "serlog"
Pino => "pino"
Bunyan => "bunyan"
Zap => "zap"
Slog => "slog"
Structlog => "structlog"
Logstash => "logstash"
Ecs => "ecs"
Gelf => "gelf"
Otel => "otel"
}
}
///|
/// A schema by name: `serlog`, `pino`, `bunyan`, `zap`, `slog`, `structlog`,
/// `logstash`, `ecs`, `gelf` or `otel`.
pub fn Schema::parse(name : StringView) -> Schema raise Refused {
match name {
"serlog" => Serlog
"pino" => Pino
"bunyan" => Bunyan
"zap" => Zap
"slog" => Slog
"structlog" => Structlog
"logstash" => Logstash
"ecs" => Ecs
"gelf" => Gelf
"otel" => Otel
_ => raise refuse(0, name.to_owned(), "a JSON schema name")
}
}
///|
/// How levels are written, when a schema is told to write them another way.
pub(all) enum Levels {
Name
Number
} derive(Eq, Debug)
///|
pub extend Levels with Eq::{equal, not_equal}
///|
pub extend Levels with Debug::{to_repr}
///|
/// `name` or `number`: how a JSON line writes the level.
pub fn Levels::parse(name : StringView) -> Levels raise Refused {
match name {
"name" => Name
"number" => Number
_ => raise refuse(0, name.to_owned(), "name or number")
}
}
///|
/// One record as a JSON object in a schema, with any key renamed.
pub struct JsonLine {
schema : Schema
keys : Array[(String, String)]
mut levels : Levels?
mut nest : String?
}
///|
/// Lines in `schema`, with its own key names and level spelling.
pub fn JsonLine::new(schema : Schema) -> JsonLine {
{ schema, keys: [], levels: None, nest: None, }
}
///|
/// Writes `key` as `to`, such as `msg` as `message`.
pub fn JsonLine::rename(self : JsonLine, key : String, to : String) -> JsonLine {
self.keys.push((key, to))
self
}
///|
/// Writes levels by name or by number, whatever the schema does by default.
pub fn JsonLine::levels(self : JsonLine, levels : Levels) -> JsonLine {
self.levels = Some(levels)
self
}
///|
/// Puts the fields under one key instead of at the top.
pub fn JsonLine::nest(self : JsonLine, key : String) -> JsonLine {
self.nest = Some(key)
self
}
///|
pub impl Layout for JsonLine with fn render(self, record, scene) {
self.encode(record, scene)
}
///|
pub extend JsonLine with Layout::{render}
///|
/// An object being written, which knows the renamed keys.
priv struct Obj {
out : StringBuilder
keys : Array[(String, String)]
mut first : Bool
}
///|
fn Obj::open(out : StringBuilder, keys : Array[(String, String)]) -> Obj {
out.write_char('{')
{ out, keys, first: true, }
}
///|
fn Obj::key(self : Obj, name : String) -> Unit {
if !self.first {
self.out.write_char(',')
}
self.first = false
let mut written = name
for (from, to) in self.keys {
if from == name {
written = to
}
}
write_json_string(self.out, written)
self.out.write_char(':')
}
///|
fn quote_open(out : StringBuilder) -> Unit {
out.write_char('"')
}
///|
fn quote_close(out : StringBuilder) -> Unit {
out.write_char('"')
}
///|
fn Obj::str(self : Obj, name : String, value : String) -> Unit {
self.key(name)
write_json_string(self.out, value)
}
///|
fn Obj::raw(self : Obj, name : String, value : String) -> Unit {
self.key(name)
self.out.write_string(value)
}
///|
fn Obj::text(self : Obj, name : String, value : String) -> Unit {
self.str(name, value)
}
///|
fn Obj::opt(self : Obj, name : String, value : String?) -> Unit {
match value {
Some(text) => self.str(name, text)
None => ()
}
}
///|
fn Obj::nonempty(self : Obj, name : String, value : String) -> Unit {
if value.length() > 0 {
self.str(name, value)
}
}
///|
fn Obj::close(self : Obj) -> Unit {
self.out.write_char('}')
}
///|
/// Writes one record as a JSON object.
pub fn JsonLine::encode(
self : JsonLine,
record : Record,
scene : Scene,
) -> String {
let out = StringBuilder()
let o = Obj::open(out, self.keys)
let identity = scene.identity
let level = record.level
match self.schema {
Serlog => {
o.key("time")
quote_open(o.out)
write_iso(o.out, record.time)
quote_close(o.out)
self.name_or(o, "level", level)
o.str("target", record.target)
o.str("msg", record.msg)
o.opt("file", record.caller.file)
match record.caller.line {
Some(line) => o.raw("line", line.to_string())
None => ()
}
write_object_fields(o, record, "error", self.nest, false)
o.nonempty("app", identity.app)
o.nonempty("env", identity.env)
o.nonempty("instance", identity.instance)
}
Pino => {
match self.levels {
Some(Name) => o.str("level", level.name())
_ => o.raw("level", level.value.to_string())
}
o.raw("time", floor_div(record.time, 1_000_000L).to_string())
o.raw("pid", identity.pid.to_string())
o.text("hostname", identity.host)
write_object_fields(o, record, "err", self.nest, false)
o.str("msg", record.msg)
}
Bunyan => {
o.str("name", identity.app)
o.text("hostname", identity.host)
o.raw("pid", identity.pid.to_string())
match self.levels {
Some(Name) => o.str("level", level.name())
_ => o.raw("level", level.value.to_string())
}
write_object_fields(o, record, "err", self.nest, false)
o.str("msg", record.msg)
o.key("time")
quote_open(o.out)
write_iso(o.out, record.time)
quote_close(o.out)
o.raw("v", "0")
}
Zap => {
self.name_or(o, "level", level)
o.raw("ts", seconds_with_millis(record.time))
o.str("logger", record.target)
o.opt("caller", file_line(record))
o.str("msg", record.msg)
write_object_fields(o, record, "error", self.nest, false)
}
Slog => {
o.key("time")
quote_open(o.out)
write_strftime(
o.out,
record.time,
(identity.offset)(record.time),
"%Y-%m-%dT%H:%M:%S%.9f%:z",
)
quote_close(o.out)
self.name_or(o, "level", level.named(label(level, Profile::Slog)))
o.str("msg", record.msg)
write_object_fields(o, record, "error", self.nest, false)
}
Structlog => {
o.str("event", record.msg)
write_object_fields(o, record, "error", self.nest, false)
self.name_or(o, "level", level)
o.str("logger", record.target)
o.key("timestamp")
quote_open(o.out)
write_iso(o.out, record.time)
quote_close(o.out)
}
Logstash => {
o.key("@timestamp")
quote_open(o.out)
write_strftime(
o.out,
record.time,
(identity.offset)(record.time),
"%Y-%m-%dT%H:%M:%S%.3f%:z",
)
quote_close(o.out)
o.str("@version", "1")
o.str("message", record.msg)
o.str("logger_name", record.target)
o.opt("thread_name", record.thread)
self.name_or(o, "level", level.named(label(level, Profile::Logback)))
o.raw("level_value", level.logstash().to_string())
write_object_fields(o, record, "stack_trace", self.nest, false)
}
Ecs => {
o.key("@timestamp")
quote_open(o.out)
write_iso(o.out, record.time)
quote_close(o.out)
self.name_or(o, "log.level", level)
o.str("message", record.msg)
o.str("ecs.version", "8.11.0")
o.str("log.logger", record.target)
o.opt("log.origin.file.name", record.caller.file)
match record.caller.line {
Some(line) => o.raw("log.origin.file.line", line.to_string())
None => ()
}
o.raw("process.pid", identity.pid.to_string())
o.text("host.hostname", identity.host)
o.nonempty("service.name", identity.app)
o.nonempty("service.environment", identity.env)
o.nonempty("service.node.name", identity.instance)
write_object_fields(o, record, "error", self.nest, false)
}
Gelf => {
o.str("version", "1.1")
o.text("host", identity.host)
o.str("short_message", record.msg)
o.raw("timestamp", seconds_with_millis(record.time))
o.raw("level", level.syslog().to_string())
o.str("_target", record.target)
write_object_fields(o, record, "error", self.nest, true)
}
Otel => {
o.key("timestamp")
quote_open(o.out)
write_iso_nanos(o.out, record.time)
quote_close(o.out)
o.str("severity_text", label(level, Profile::Serlog))
o.raw("severity_number", level.otel().to_string())
o.str("body", record.msg)
o.key("attributes")
let attrs = Obj::open(o.out, self.keys)
attrs.str("code.namespace", record.target)
attrs.opt("code.filepath", record.caller.file)
match record.caller.line {
Some(line) => attrs.raw("code.lineno", line.to_string())
None => ()
}
for pair in record.fields {
attrs.key(pair.0)
pair.1.write_json(attrs.out)
}
attrs.close()
o.key("resource")
let resource = Obj::open(o.out, self.keys)
resource.nonempty("service.name", identity.app)
resource.nonempty("deployment.environment", identity.env)
resource.nonempty("service.instance.id", identity.instance)
resource.close()
}
}
o.close()
out.to_string()
}
///|
/// Writes the level as its name or its number, whichever the layout was told.
fn JsonLine::name_or(
self : JsonLine,
o : Obj,
key : String,
level : Level,
) -> Unit {
match self.levels {
Some(Number) => o.raw(key, level.value.to_string())
_ => o.str(key, level.name())
}
}
///|
/// How a profile's logger writes a level, as a name.
fn label(level : Level, profile : Profile) -> String {
level.label(profile)
}
///|
fn file_line(record : Record) -> String? {
match record.caller.file {
Some(file) =>
match record.caller.line {
Some(line) => Some(file + ":" + line.to_string())
None => Some(file)
}
None => None
}
}
///|
/// Seconds since the epoch with three decimals, as zap's `ts` and GELF's
/// `timestamp` want.
fn seconds_with_millis(time : Int64) -> String {
let seconds = floor_div(time, 1_000_000_000L)
let millis = floor_div(time - seconds * 1_000_000_000L, 1_000_000L)
let out = StringBuilder()
out.write_string(seconds.to_string())
out.write_char('.')
write_digits(out, millis.to_int(), 3)
out.to_string()
}
///|
/// The fields, under `nest` when the layout was told to nest them; a field
/// called `error` is written under `error_key`, and `id` is left out of a GELF
/// document, which reserves it.
fn write_object_fields(
o : Obj,
record : Record,
error_key : String,
nest : String?,
prefixed : Bool,
) -> Unit {
if record.fields.length() == 0 {
return
}
match nest {
Some(key) => {
o.key(key)
let inner = Obj::open(o.out, o.keys)
each_field(inner, record, error_key, prefixed)
inner.close()
}
None => each_field(o, record, error_key, prefixed)
}
}
///|
fn each_field(
o : Obj,
record : Record,
error_key : String,
prefixed : Bool,
) -> Unit {
for (name, value) in record.fields {
if prefixed && name == "id" {
continue
}
let key = if name == "error" { error_key } else { name }
if prefixed {
o.key("_" + key)
} else {
o.key(key)
}
value.write_json(o.out)
}
}