Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-log

Structured event logging for Pudu: message templates, enrichment, filtering, formatting, and sinks

0.1.0Apache-2.01

InstallClose

Clef.pudu

Pudu147 lines6.1 KB

GitHub ↗
1/** @Log.Domain.Clef.Module — compact JSON lines read back into events */2module PuduLangLog.Domain.Clef34import Std.Decimal as Decimal5import Std.Json as Json6import Std.List as List7import Std.Option as Option8import Std.Time.Format as Format9import PuduLangLog.Domain.Display as Display10import PuduLangLog.Domain.Levels as Levels11import PuduLangLog.Domain.Parser as Parser12import PuduLangLog as Log1314/// Members the format reserves; they never become properties.15const RESERVED: Array[Str] = ["@t", "@mt", "@m", "@l", "@x", "@i", "@r", "@tr", "@sp"]1617/// The prefix of a trace line in failure text.18const TRACE_PREFIX: Str = "   at "1920/// The prefix of a cause line in failure text.21const CAUSE_PREFIX: Str = " ---> "2223/// The event one line of compact JSON describes, or why it cannot be read.24export fn line(text: Str) -> Result[Log.Event, Str] {25  match Json.decode(text) {26    case Ok(document) => event(&document)27    case Err(problem) => Err("The line is not JSON: " + Json.explain(&problem))28  }29}3031/// The event a compact JSON object describes. `@t` is required; without `@mt` the message `@m`32/// becomes a template with its braces escaped; the level defaults to `Information`; members named33/// with a doubled `@@` are properties named with one `@`.34export fn event(document: &Json.Json) -> Result[Log.Event, Str] {35  let members = match Json.asObject(document) {36    case Some(found) => found37    case None => { return Err("The event is not a JSON object") }38  }39  let stamp = textMember(&members, "@t") ?40  let timestamp = match stamp {41    case Some(written) => moment(written) ?42    case None => { return Err("The event has no `@t` timestamp") }43  }44  let level = match textMember(&members, "@l") ? {45    case Some(name) => Option.okOr(Levels.parse(name), "Unknown level `" + name + "`") ?46    case None => Log.Information47  }48  let rendered = Option.unwrapOr(textMember(&members, "@m") ?, "")49  let template = match textMember(&members, "@mt") ? {50    case Some(written) => written51    case None => rendered.replace("\{", "\{\{").replace("\}", "\}\}")52  }53  let failure = Option.map(textMember(&members, "@x") ?, |written: Str| failureOf(written))54  var properties: Array[Log.Property] = []55  for (name, held) in members {56    if name.startsWith("@@") {57      properties = properties.push(Log.Property{name: name.drop(1), value: valueOf(&held)})58    } else if !RESERVED.contains(name) {59      properties = properties.push(Log.Property{name: name, value: valueOf(&held)})60    }61  }62  Ok(Log.Event {63      timestamp: timestamp,64      level: level,65      template: Parser.parse(template),66      properties: properties,67      failure: failure,68      traceId: textMember(&members, "@tr") ?,69      spanId: textMember(&members, "@sp") ?70    })71}7273/// A reserved member's text, none when absent, or an error when it is not text.74fn textMember(members: &Array[(Str, Json.Json)], name: Str) -> Result[Option[Str], Str] {75  match List.find(members, |member: (Str, Json.Json)| member[0] == name) {76    case Some(found) => match Json.asText(&found[1]) {77      case Some(text) => Ok(Some(text))78      case None => Err("The `" + name + "` member is not text")79    }80    case None => Ok(None)81  }82}8384/// The moment an RFC 3339 timestamp names, keeping its offset in minutes.85fn moment(written: Str) -> Result[Log.Timestamp, Str] {86  let millis = match Format.fromRfc3339(written) {87    case Ok(found) => found88    case Err(_) => { return Err("The timestamp `" + written + "` is not RFC 3339") }89  }90  Ok(Log.Timestamp{millis: millis, offset: offsetOf(written)})91}9293/// The offset in minutes of a timestamp ending in `Z` or `±hh:mm`.94fn offsetOf(written: Str) -> Int {95  let tail = written.drop(written.length() - 6)96  let hours = Option.unwrapOr(tail.slice(1, 3).toInt(), 0)97  let minutes = Option.unwrapOr(tail.slice(4, 6).toInt(), 0)98  if tail.startsWith("+") { hours * 60 + minutes } else if tail.startsWith("-") { 0 - hours * 60 - minutes } else { 0 }99}100101/// A captured value from JSON: whole numbers as integers, fractions as exact numbers, and objects102/// as structures tagged by their `$type` member.103fn valueOf(held: &Json.Json) -> Log.Value {104  match held {105    case Json.Null => Log.Scalar(Log.Null)106    case Json.Boolean(flag) => Log.Scalar(Log.Boolean(flag))107    case Json.Number(number) => Log.Scalar(Log.Integer(number))108    case Json.Fractional(digits) => match Decimal.parse(digits) {109      case Some(exact) => Log.Scalar(Log.Exact(exact))110      case None => Log.Scalar(Log.Text(digits))111    }112    case Json.Text(text) => Log.Scalar(Log.Text(text))113    case Json.List(items) => Log.Sequence(items.map(|item: Json.Json| valueOf(&item)))114    case Json.Object(members) => {115      let tag = match List.find(&members, |member: (Str, Json.Json)| member[0] == Display.TYPE_TAG) {116        case Some(found) => Json.asText(&found[1])117        case None => None118      }119      let kept = members.filter(|member: (Str, Json.Json)| member[0] != Display.TYPE_TAG)120      Log.Structure(Log.Structure{tag: tag, properties: kept.map(|member: (Str, Json.Json)| Log.Property{name: member[0], value: valueOf(&member[1])})})121    }122  }123}124125/// A failure read from its text: `Kind: message` on the first line, message lines until the first126/// trace line, trace lines, and each cause line opening the cause of the one before.127fn failureOf(written: Str) -> Log.Failure {128  let lines = written.split("\n")129  let causeAt = match List.findIndex(&List.drop(&lines, 1), |text: Str| text.startsWith(CAUSE_PREFIX)) {130    case Some(found) => found + 1131    case None => lines.length()132  }133  let own = List.take(&lines, causeAt)134  let traceAt = Option.unwrapOr(List.findIndex(&own, |text: Str| text.startsWith(TRACE_PREFIX)), own.length())135  let heading = List.take(&own, traceAt).join("\n")136  let (kind, message) = match heading.indexOf(": ") {137    case -1 => (heading, "")138    case at => (heading.take(at), heading.drop(at + 2))139  }140  let trace = List.drop(&own, traceAt).map(|text: Str| text.drop(TRACE_PREFIX.length()))141  let causes = if causeAt == lines.length() { [] } else {142    let rest = List.drop(&lines, causeAt)143    [failureOf(rest.join("\n").drop(CAUSE_PREFIX.length()))]144  }145  Log.Failure{kind: kind, message: message, trace: trace, causes: causes}146}147