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

Capture.pudu

Pudu203 lines8.9 KB

GitHub ↗
1/** @Log.Domain.Capture.Module — arguments bound to holes under capture policies */2module PuduLangLog.Domain.Capture34import Std.List as List5import Std.Math as Math6import Std.Option as Option7import PuduLangLog.Domain.Display as Display8import PuduLangLog.Domain.Json as Json9import PuduLangLog.Domain.Properties as Properties10import PuduLangLog as Log1112/** @Log.Domain.Capture.Policy — limits and rules applied while capturing */13export type Policy = {14  maximumDepth: Int,15  maximumStringLength: Int,16  maximumCollectionCount: Int,17  scalars: Array[Str],18  transforms: Array[Transform],19  custom: Array[fn(&Log.Value) -> Option[Log.Value]]20}2122/** @Log.Domain.Capture.Transform — a replacement for structures of one tag */23export type Transform = { tag: Str, apply: fn(Log.Value) -> Log.Value }2425/** @Log.Domain.Capture.Bound — the properties bound and any problems met */26export type Bound = { properties: Array[Log.Property], problems: Array[Str] }2728/// The largest limit, standing for no limit.29export const UNLIMITED: Int = 21474836473031/// Byte strings longer than this are captured as a hexadecimal prefix and their length.32const LONGEST_BYTES: Int = 10243334/// How many leading bytes a long byte string keeps.35const KEPT_BYTES: Int = 163637/// The self-log line for nesting past the maximum depth.38const DEPTH_REACHED: Str = "Maximum destructuring depth reached."3940/// A depth of ten, and no limit on text length or collection size.41export fn defaults() -> Policy {42  Policy{maximumDepth: 10, maximumStringLength: UNLIMITED, maximumCollectionCount: UNLIMITED, scalars: [], transforms: [], custom: []}43}4445/// A value captured under a hint: `Stringify` renders it to text, `Destructure` keeps structures,46/// and `Default` keeps scalars and collections but renders structures to text. Limits apply at47/// every depth.48export fn capture(policy: &Policy, held: &Log.Value, hint: Log.Hint) -> Log.Value {49  let (value, _cut) = captured(policy, held, hint, 1)50  value51}5253/// A value captured at a depth, and whether the depth limit cut part of it.54fn captured(policy: &Policy, held: &Log.Value, hint: Log.Hint, depth: Int) -> (Log.Value, Bool) {55  if depth > policy.maximumDepth { return (Log.Scalar(Log.Null), true) }56  if hint == Log.Stringify { return (Log.Scalar(Log.Text(truncated(policy, stringified(held)))), false) }57  match held {58    case Log.Scalar(atom) => (Log.Scalar(scalarOf(policy, &atom)), false)59    case Log.Sequence(items) => {60      var members: Array[Log.Value] = []61      var cut = false62      for item in List.take(&items, policy.maximumCollectionCount) {63        let (member, deeper) = captured(policy, &item, hint, depth + 1)64        members = members.push(member)65        cut = cut || deeper66      }67      (Log.Sequence(members), cut)68    }69    case Log.Dictionary(entries) => {70      var members: Array[Log.Entry] = []71      var cut = false72      for entry in List.take(&entries, policy.maximumCollectionCount) {73        let (member, deeper) = captured(policy, &entry.value, hint, depth + 1)74        members = members.push(Log.Entry{key: scalarOf(policy, &entry.key), value: member})75        cut = cut || deeper76      }77      (Log.Dictionary(members), cut)78    }79    case Log.Structure(shape) => structureOf(policy, held, &shape, hint, depth)80  }81}8283/// A structure captured under a hint: rendered to text unless destructured, replaced by the first84/// custom rule or tag transform that answers, and otherwise kept with its members captured. A85/// replacement is not replaced again; its members are captured as usual.86fn structureOf(policy: &Policy, held: &Log.Value, shape: &Log.Structure, hint: Log.Hint, depth: Int) -> (Log.Value, Bool) {87  let scalarTag = match shape.tag {88    case Some(tag) => policy.scalars.contains(tag)89    case None => false90  }91  if hint != Log.Destructure || scalarTag { return (Log.Scalar(Log.Text(truncated(policy, stringified(held)))), false) }92  match replacementOf(policy, held, shape) {93    case Some(Log.Structure(replaced)) => membersOf(policy, &replaced, depth)94    case Some(replaced) => captured(policy, &replaced, Log.Destructure, depth)95    case None => membersOf(policy, shape, depth)96  }97}9899/// The answer of the first custom rule that answers, or else of the transform for the tag.100fn replacementOf(policy: &Policy, held: &Log.Value, shape: &Log.Structure) -> Option[Log.Value] {101  for rule in policy.custom {102    if let Some(replaced) = rule(held) { return Some(replaced) }103  }104  let tag = shape.tag ?105  let transform = List.find(&policy.transforms, |candidate: Transform| candidate.tag == tag) ?106  Some((transform.apply)(*held))107}108109/// A structure kept with its members destructured one level deeper.110fn membersOf(policy: &Policy, shape: &Log.Structure, depth: Int) -> (Log.Value, Bool) {111  var members: Array[Log.Property] = []112  var cut = false113  for property in shape.properties {114    let (member, deeper) = captured(policy, &property.value, Log.Destructure, depth + 1)115    members = members.push(Log.Property{name: property.name, value: member})116    cut = cut || deeper117  }118  (Log.Structure(Log.Structure{tag: shape.tag, properties: members}), cut)119}120121/// A scalar with text truncated and long byte strings shortened to a readable prefix.122fn scalarOf(policy: &Policy, atom: &Log.Scalar) -> Log.Scalar {123  match atom {124    case Log.Text(text) => Log.Text(truncated(policy, text))125    case Log.Binary(bytes) => {126      if bytes.length() <= LONGEST_BYTES { return *atom }127      Log.Text(Json.hexOf(&bytes.take(KEPT_BYTES)) + "... (" + show(bytes.length()) + " bytes)")128    }129    case _ => *atom130  }131}132133/// A value as the text a stringified hole holds: text as it is, anything else as displayed.134fn stringified(held: &Log.Value) -> Str {135  match held {136    case Log.Scalar(Log.Text(text)) => text137    case _ => Display.plain(&Display.value(held, &Some("l")))138  }139}140141/// Text cut to the maximum length, ending in an ellipsis when cut.142fn truncated(policy: &Policy, text: Str) -> Str {143  if text.length() <= policy.maximumStringLength { return text }144  text.take(Math.max(0, policy.maximumStringLength - 1)) + "…"145}146147/// The template's holes bound to the arguments. Numbered templates bind by position; others bind148/// left to right, and arguments beyond the holes are kept as `__0`, `__1`, and so on by index.149export fn bind(policy: &Policy, template: &Log.Template, arguments: &Array[Log.Value]) -> Bound {150  if arguments.isEmpty() {151    let problems = if template.holes.isEmpty() { [] } else { ["Required properties not provided for: " + template.text] }152    return Bound{properties: [], problems: problems}153  }154  match template.binding {155    case Log.Unbound => Bound{properties: [], problems: ["Parameters provided for message template with no properties: " + template.text]}156    case Log.Positional => positional(policy, template, arguments)157    case Log.Named => named(policy, template, arguments)158  }159}160161/// Arguments bound to numbered holes by their numbers; unused arguments are dropped.162fn positional(policy: &Policy, template: &Log.Template, arguments: &Array[Log.Value]) -> Bound {163  var slots: Array[Option[Log.Property]] = arguments.map(|_argument: Log.Value| None)164  var problems: Array[Str] = []165  for hole in template.holes {166    let position = Option.unwrapOr(hole.position, 0)167    match List.get(arguments, position) {168      case Some(argument) => {169        let (held, cut) = captured(policy, &argument, hole.hint, 1)170        if cut { problems = problems.push(DEPTH_REACHED) }171        slots = List.take(&slots, position).push(Some(Log.Property{name: hole.name, value: held})).concat(List.drop(&slots, position + 1))172      }173      case None => { problems = problems.push("Unassigned positional value " + show(position) + " in: " + template.text) }174    }175  }176  var properties: Array[Log.Property] = []177  for slot in slots {178    if let Some(property) = slot { properties = properties.push(property) }179  }180  if properties.length() != arguments.length() { problems = problems.push("Positional property count does not match parameter count: " + template.text) }181  Bound{properties: properties, problems: problems}182}183184/// Arguments bound to holes left to right; a repeated name keeps its last value.185fn named(policy: &Policy, template: &Log.Template, arguments: &Array[Log.Value]) -> Bound {186  var problems: Array[Str] = []187  if List.any(&template.holes, |hole: Log.Hole| hole.position != None) { problems = problems.push("Message template is malformed: " + template.text) }188  if template.holes.length() != arguments.length() { problems = problems.push("Named property count does not match parameter count: " + template.text) }189  var properties: Array[Log.Property] = []190  var index = 0191  for argument in arguments {192    let (name, hint) = match List.get(&template.holes, index) {193      case Some(hole) => (hole.name, hole.hint)194      case None => ("__" + show(index), Log.Default)195    }196    let (held, cut) = captured(policy, &argument, hint, 1)197    if cut { problems = problems.push(DEPTH_REACHED) }198    properties = Properties.put(&properties, name, held)199    index = index + 1200  }201  Bound{properties: properties, problems: problems}202}203