// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Writes records to a sink, carrying the trace it was written inside.
///
/// A logger holds what every line from one part of a program shares: the scope it
/// names, the attributes it always carries, the severity below which nothing is
/// written, and the span the work is happening in. What changes per line is handed
/// to `log`.
///
/// It reads no clock of its own. `now` is the caller's, so a replay can write a
/// timeline it chose and a service can hand in whichever clock it already trusts.
pub struct Logger {
sink : &Sink
now : () -> Int64
scope : String
attributes : Array[(String, @trace.Value)]
current : @trace.TraceContext?
floor : Severity?
trace_based : Bool
limits : @trace.Limits
}
///|
/// A logger writing to `sink`, timed by `now`.
///
/// `floor` drops everything less urgent than it, `None` keeping every severity:
/// the default is to keep what the program writes and let the sink decide what to
/// do with it. `trace_based` drops records written inside a trace that was not
/// sampled, which is what keeps a sampled-down service from paying for log lines
/// about work no backend will ever see; records outside any trace are unaffected.
///
/// `limits` is the span limits type, and a logger reads two of its numbers:
/// `attributes` and `attribute_value_length`.
pub fn Logger::new(
sink~ : &Sink,
now~ : () -> Int64,
scope~ : String = "",
attributes~ : Array[(String, @trace.Value)] = [],
floor? : Severity,
trace_based~ : Bool = false,
limits~ : @trace.Limits = @trace.trace_limits,
) -> Logger {
{ sink, now, scope, attributes, current: None, floor, trace_based, limits, }
}
///|
/// The same logger, naming `scope` instead. Nesting a scope is how a program says
/// "this line came from the payment part of the order service".
pub fn Logger::under(self : Logger, scope : String) -> Logger {
{ ..self, scope, }
}
///|
/// The same logger with one more attribute on every record it writes, replacing a
/// value under the same key.
pub fn Logger::with_attribute(
self : Logger,
key : String,
value : @trace.Value,
) -> Logger {
let attributes : Array[(String, @trace.Value)] = []
let mut replaced = false
for attribute in self.attributes {
if attribute.0 == key {
attributes.push((key, value))
replaced = true
} else {
attributes.push(attribute)
}
}
if !replaced {
attributes.push((key, value))
}
{ ..self, attributes, }
}
///|
/// The same logger writing inside `span`: every record it writes names that span's
/// trace and span ids.
pub fn Logger::inside(self : Logger, span : @trace.Span) -> Logger {
{ ..self, current: Some(span.context()), }
}
///|
/// The same logger writing inside `context`, which is what a caller that read a
/// `traceparent` off a request has rather than a span of its own.
pub fn Logger::with_context(
self : Logger,
context : @trace.TraceContext,
) -> Logger {
{ ..self, current: Some(context), }
}
///|
/// Whether a record of this severity would be written: false below the floor, and
/// false inside a trace that was not sampled when `trace_based` is on.
///
/// Instrumented code can ask before it builds an expensive body.
pub fn Logger::enabled(self : Logger, severity : Severity) -> Bool {
match self.floor {
Some(floor) => if !severity.at_least(floor) { return false }
None => ()
}
if self.trace_based {
match self.current {
Some(context) => if !context.sampled() { return false }
None => ()
}
}
true
}
///|
/// The scope this logger names.
pub fn Logger::scope(self : Logger) -> String {
self.scope
}
///|
/// Write one record.
///
/// `at` is when the event happened and `observed` is when the program noticed;
/// both default to the logger's clock. `error` adds the three exception attributes
/// — `exception.type`, `exception.message`, `exception.stacktrace` — and the
/// caller's own `attributes` win over them, as the specification asks: a producer
/// that knows better than the generic mapping keeps its value.
pub fn Logger::log(
self : Logger,
severity : Severity,
body : @trace.Value,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
observed? : Int64,
event_name? : String,
error? : Error,
) -> Unit {
guard self.enabled(severity) else { return }
let at = at.unwrap_or((self.now)())
let mut record = LogRecord::new(
severity~,
body~,
at~,
observed=observed.unwrap_or(at),
scope=self.scope,
)
// The logger's own attributes first, then what the failure says, then the call's:
// each knows more about the line than the one before it, so a key given twice
// ends up with the value from the closest hand.
let merged : Array[(String, @trace.Value)] = []
for attribute in self.attributes {
merged.push(attribute)
}
match error {
Some(error) => merged.push(("exception.message", @trace.Str("\{error}")))
None => ()
}
for attribute in attributes {
let mut at = None
for i, existing in merged {
if existing.0 == attribute.0 {
at = Some(i)
break
}
}
match at {
Some(i) => merged[i] = attribute
None => merged.push(attribute)
}
}
let (kept, dropped) = bounded(merged, self.limits)
record = { ..record, attributes: kept, dropped, }
match self.current {
Some(context) => record = record.with_context(context)
None => ()
}
match event_name {
Some(name) => record = record.with_event_name(name)
None => ()
}
self.sink.accept(record)
}
///|
/// Write a record whose body is plain text.
pub fn Logger::write(
self : Logger,
severity : Severity,
message : String,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
observed? : Int64,
event_name? : String,
error? : Error,
) -> Unit {
self.log(
severity,
@trace.Str(message),
attributes~,
at?,
observed?,
event_name?,
error?,
)
}
///|
/// Write a `TRACE` record.
pub fn Logger::trace(
self : Logger,
message : String,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
) -> Unit {
self.write(Trace, message, attributes~, at?)
}
///|
/// Write a `DEBUG` record.
pub fn Logger::debug(
self : Logger,
message : String,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
) -> Unit {
self.write(Debug, message, attributes~, at?)
}
///|
/// Write an `INFO` record.
pub fn Logger::info(
self : Logger,
message : String,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
) -> Unit {
self.write(Info, message, attributes~, at?)
}
///|
/// Write a `WARN` record.
pub fn Logger::warn(
self : Logger,
message : String,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
) -> Unit {
self.write(Warn, message, attributes~, at?)
}
///|
/// Write an `ERROR` record.
pub fn Logger::error(
self : Logger,
message : String,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
error? : Error,
) -> Unit {
self.write(Error, message, attributes~, at?, error?)
}
///|
/// Write a `FATAL` record.
pub fn Logger::fatal(
self : Logger,
message : String,
attributes~ : Array[(String, @trace.Value)] = [],
at? : Int64,
) -> Unit {
self.write(Fatal, message, attributes~, at?)
}
// ------------------------------------------------------------------ internals
///|
/// The attributes a record may keep, and how many were refused.
///
/// The limit is per record and counts attributes, not bytes, exactly as the
/// specification's attribute limits do. A producer that loops over user data must
/// not be able to make one record unbounded.
fn bounded(
attributes : Array[(String, @trace.Value)],
limits : @trace.Limits,
) -> (Array[(String, @trace.Value)], Int32) {
let kept : Array[(String, @trace.Value)] = []
let mut dropped = 0
for attribute in attributes {
if kept.length() >= limits.attributes {
dropped = dropped + 1
} else {
kept.push(
(attribute.0, truncate(attribute.1, limits.attribute_value_length)),
)
}
}
(kept, dropped)
}
///|
/// A string attribute longer than the limit is cut to it, counting each character
/// as one, as OpenTelemetry's attribute limits ask. Every other type passes
/// through.
fn truncate(value : @trace.Value, limit : Int32?) -> @trace.Value {
match (value, limit) {
(@trace.Str(s), Some(n)) if s.length() > n =>
@trace.Str(s.exact_view(start=0, end=n).to_owned())
(@trace.Strs(xs), Some(n)) =>
@trace.Strs(
xs.map(s => {
if s.length() > n {
s.exact_view(start=0, end=n).to_owned()
} else {
s
}
}),
)
_ => value
}
}