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

Settings.pudu

Pudu297 lines13.0 KB

GitHub ↗
1/** @Log.Domain.Settings.Module — key-value settings read into a logging plan */2module PuduLangLog.Domain.Settings34import Std.Json as Json5import Std.List as List6import Std.Map as Map7import Std.Option as Option8import PuduLangLog.Domain.Levels as Levels9import PuduLangLog as Log1011/** @Log.Domain.Settings.Target — a named sink or enricher and its arguments */12export type Target = { name: Str, arguments: Array[(Str, Str)] }1314/** @Log.Domain.Settings.Filter — an expression that keeps or drops events */15export type Filter = { including: Bool, expression: Str }1617/** @Log.Domain.Settings.Plan — every decision the settings make */18export type Plan = {19  minimum: Option[Log.Level],20  controlledBy: Option[Str],21  switches: Array[(Str, Log.Level)],22  overrides: Array[(Str, Str)],23  properties: Array[(Str, Str)],24  enrichers: Array[Target],25  sinks: Array[Target],26  audits: Array[Target],27  filters: Array[Filter],28  maximumDepth: Option[Int],29  maximumStringLength: Option[Int],30  maximumCollectionCount: Option[Int],31  scalars: Array[Str]32}3334/** @Log.Domain.Settings.Read — a plan and the settings it could not use */35export type Read = { plan: Plan, problems: Array[Str] }3637/** @Log.Domain.Settings.Group — one numbered or named entry of a section */38type Group = { key: Str, target: Target }3940/** @Log.Domain.Settings.State — the plan so far and the entries of each section */41type State = { plan: Plan, problems: Array[Str], enrichers: Array[Group], sinks: Array[Group], audits: Array[Group] }4243/// The rank of each rolling interval name, keyed in lower case.44const INTERVALS: Map[Str, Int] = mapOf([("infinite", 0), ("year", 1), ("month", 2), ("day", 3), ("hour", 4), ("minute", 5)])4546/// The separator of nested keys in environment variable names.47const ENVIRONMENT_SEPARATOR: Str = "__"4849/// The prefix naming a level switch where a level could stand.50const SWITCH_MARK: Str = "$"5152/// The sink argument naming the level switch that controls it.53export const LEVEL_SWITCH_ARGUMENT: Str = "levelSwitch"5455/// A plan that decides nothing.56export fn empty() -> Plan {57  Plan {58    minimum: None,59    controlledBy: None,60    switches: [],61    overrides: [],62    properties: [],63    enrichers: [],64    sinks: [],65    audits: [],66    filters: [],67    maximumDepth: None,68    maximumStringLength: None,69    maximumCollectionCount: None,70    scalars: []71  }72}7374/// The plan colon-separated keys describe, and every setting that is unknown, malformed, or names75/// a level switch never declared. Section and argument names ignore case; a later value for the76/// same key replaces an earlier one; numbered and named entries keep the order they first appear.77export fn read(pairs: &Array[(Str, Str)]) -> Read {78  var state = State{plan: empty(), problems: [], enrichers: [], sinks: [], audits: []}79  for pair in *pairs { state = setting(&state, pair[0], pair[1]) }80  let plan = Plan{..state.plan, enrichers: targetsOf(&state.enrichers), sinks: targetsOf(&state.sinks), audits: targetsOf(&state.audits)}81  Read{plan: plan, problems: state.problems.concat(unresolved(&plan))}82}8384/// The state with one setting applied.85fn setting(state: &State, key: Str, value: Str) -> State {86  let parts = key.split(":").map(|part: Str| part.trim())87  let section = Option.unwrapOr(List.first(&parts), "").toLower()88  let rest = List.drop(&parts, 1)89  let plan = state.plan90  if section == "minimumlevel" { return minimumLevel(state, key, &rest, value) }91  if section == "levelswitches" && rest.length() == 1 {92    let level = match levelOf(value) {93      case Some(found) => found94      case None => { return refused(state, key) }95    }96    return State{..*state, plan: Plan{..plan, switches: put(&plan.switches, switchName(rest[0]), level)}}97  }98  if section == "properties" && rest.length() == 1 && !rest[0].isEmpty() { return State{..*state, plan: Plan{..plan, properties: put(&plan.properties, rest[0], value)}} }99  if section == "enrich" { return grouped(state, key, &rest, value, 0) }100  if section == "writeto" { return grouped(state, key, &rest, value, 1) }101  if section == "auditto" { return grouped(state, key, &rest, value, 2) }102  if section == "filter" && rest.length() == 2 {103    let kind = rest[1].toLower()104    if kind == "byexcluding" || kind == "byincludingonly" { return State{..*state, plan: Plan{..plan, filters: plan.filters.push(Filter{including: kind == "byincludingonly", expression: value})}} }105  }106  if section == "destructure" { return destructure(state, key, &rest, value) }107  refused(state, key)108}109110/// The state with a `MinimumLevel` setting applied: the default level, the switch controlling it,111/// or the level or switch of a source prefix.112fn minimumLevel(state: &State, key: Str, rest: &Array[Str], value: Str) -> State {113  let plan = state.plan114  let first = Option.unwrapOr(List.first(rest), "default").toLower()115  if rest.length() < 2 && first == "default" {116    let level = match levelOf(value) {117      case Some(found) => found118      case None => { return refused(state, key) }119    }120    return State{..*state, plan: Plan{..plan, minimum: Some(level)}}121  }122  if rest.length() == 1 && first == "controlledby" { return State{..*state, plan: Plan{..plan, controlledBy: Some(switchName(value))}} }123  if first == "override" {124    let source = List.drop(rest, 1).join(":")125    let chosen = value.trim()126    if source.isEmpty() || (!chosen.startsWith(SWITCH_MARK) && levelOf(chosen) == None) { return refused(state, key) }127    return State{..*state, plan: Plan{..plan, overrides: put(&plan.overrides, source, chosen)}}128  }129  refused(state, key)130}131132/// The state with an entry of `Enrich` (section 0), `WriteTo` (1), or `AuditTo` (2) set: `Section:i`133/// or `Section:i:Name` names it, and `Section:i:Args:name` sets one of its arguments.134fn grouped(state: &State, key: Str, rest: &Array[Str], value: Str, which: Int) -> State {135  let groups = if which == 0 { state.enrichers } else if which == 1 { state.sinks } else { state.audits }136  let entry = Option.unwrapOr(List.first(rest), "").toLower()137  let field = Option.unwrapOr(List.get(rest, 1), "").toLower()138  if entry.isEmpty() { return refused(state, key) }139  let current = match List.find(&groups, |group: Group| group.key == entry) {140    case Some(found) => found.target141    case None => Target{name: "", arguments: []}142  }143  let updated = if rest.length() == 1 || (rest.length() == 2 && field == "name") {144    Target{..current, name: value.trim()}145  } else if rest.length() == 3 && field == "args" && !rest[2].isEmpty() {146    Target{..current, arguments: put(&current.arguments, rest[2], value)}147  } else {148    return refused(state, key)149  }150  let others = groups.filter(|group: Group| group.key != entry)151  let changed = if others.length() == groups.length() { groups.push(Group{key: entry, target: updated}) } else { groups.map(|group: Group| if group.key == entry { Group{key: entry, target: updated} } else { group }) }152  if which == 0 { State{..*state, enrichers: changed} } else if which == 1 { State{..*state, sinks: changed} } else { State{..*state, audits: changed} }153}154155/// The state with a `Destructure` setting applied: a capture limit, or a tag captured as text.156fn destructure(state: &State, key: Str, rest: &Array[Str], value: Str) -> State {157  let plan = state.plan158  let first = Option.unwrapOr(List.first(rest), "").toLower()159  if rest.length() == 2 && first == "asscalar" { return State{..*state, plan: Plan{..plan, scalars: plan.scalars.push(value.trim())}} }160  if rest.length() != 1 { return refused(state, key) }161  let limit = match count(value) {162    case Some(found) => found163    case None => { return refused(state, key) }164  }165  if first == "maximumdepth" { return State{..*state, plan: Plan{..plan, maximumDepth: Some(limit)}} }166  if first == "maximumstringlength" { return State{..*state, plan: Plan{..plan, maximumStringLength: Some(limit)}} }167  if first == "maximumcollectioncount" { return State{..*state, plan: Plan{..plan, maximumCollectionCount: Some(limit)}} }168  refused(state, key)169}170171/// The state with a setting reported as unusable.172fn refused(state: &State, key: Str) -> State { State{..*state, problems: state.problems.push("Unknown or malformed setting `" + key + "`")} }173174/// The targets of a section's entries, in order.175fn targetsOf(groups: &Array[Group]) -> Array[Target] { groups.map(|group: Group| group.target) }176177/// Every entry naming nothing and every reference to an undeclared level switch.178fn unresolved(plan: &Plan) -> Array[Str] {179  var problems: Array[Str] = []180  let declared = plan.switches.map(|entry: (Str, Log.Level)| entry[0])181  for (section, targets) in [("Enrich", plan.enrichers), ("WriteTo", plan.sinks), ("AuditTo", plan.audits)] {182    for target in targets {183      if target.name.isEmpty() { problems = problems.push("An entry of `" + section + "` names nothing") }184    }185  }186  var references: Array[Str] = []187  if let Some(name) = plan.controlledBy { references = references.push(name) }188  for entry in plan.overrides {189    if entry[1].startsWith(SWITCH_MARK) { references = references.push(switchName(entry[1])) }190  }191  for target in plan.sinks.concat(plan.audits) {192    if let Some(name) = argument(&target, LEVEL_SWITCH_ARGUMENT) { references = references.push(switchName(name)) }193  }194  for name in references {195    if !declared.contains(name) { problems = problems.push("The level switch `" + name + "` is not declared") }196  }197  problems198}199200/// A switch name without its leading `$`, in lower case.201export fn switchName(text: Str) -> Str {202  let trimmed = text.trim()203  (if trimmed.startsWith(SWITCH_MARK) { trimmed.drop(1) } else { trimmed }).toLower()204}205206/// The level a setting's text names.207export fn levelOf(text: Str) -> Option[Log.Level] { Levels.parse(text) }208209/// The rolling interval a setting's text names, ignoring case.210export fn intervalOf(text: Str) -> Option[Log.RollingInterval] {211  let rank = Map.get(&INTERVALS, text.trim().toLower()) ?212  Some(Log.ROLLING_INTERVALS[rank])213}214215/// A count of zero or more from a setting's text.216export fn count(text: Str) -> Option[Int] {217  let number = text.trim().toInt() ?218  if number < 0 { None } else { Some(number) }219}220221/// `true` or `false` from a setting's text, ignoring case.222export fn flag(text: Str) -> Option[Bool] {223  let lowered = text.trim().toLower()224  if lowered == "true" { Some(true) } else if lowered == "false" { Some(false) } else { None }225}226227/// A target's argument, its name ignoring case.228export fn argument(target: &Target, name: Str) -> Option[Str] {229  let found = List.find(&target.arguments, |entry: (Str, Str)| entry[0].toLower() == name.toLower()) ?230  Some(found[1])231}232233/// The pairs with a key replaced when present, ignoring case, and added otherwise.234fn put[T](pairs: &Array[(Str, T)], key: Str, value: T) -> Array[(Str, T)] {235  let others = pairs.filter(|entry: (Str, T)| entry[0].toLower() != key.toLower())236  if others.length() == pairs.length() { return pairs.push((key, value)) }237  pairs.map(|entry: (Str, T)| if entry[0].toLower() == key.toLower() { (key, value) } else { entry })238}239240/// The settings of JSON text under a top-level section, its name ignoring case; the whole document241/// when the section is empty. Objects nest with `:` and lists by index.242export fn fromJson(text: Str, section: Str) -> Result[Array[(Str, Str)], Str] {243  match Json.decode(text) {244    case Ok(document) => Ok(flatten(&document, section))245    case Err(problem) => Err("The settings are not JSON: " + Json.explain(&problem))246  }247}248249/// The settings a JSON document holds under a top-level section.250fn flatten(document: &Json.Json, section: Str) -> Array[(Str, Str)] {251  if section.isEmpty() { return entries(document, "") }252  let members = match Json.asObject(document) {253    case Some(found) => found254    case None => { return [] }255  }256  match List.find(&members, |member: (Str, Json.Json)| member[0].toLower() == section.toLower()) {257    case Some(found) => entries(&found[1], "")258    case None => []259  }260}261262/// A JSON value's settings under a key prefix.263fn entries(held: &Json.Json, prefix: Str) -> Array[(Str, Str)] {264  match held {265    case Json.Object(members) => {266      var found: Array[(Str, Str)] = []267      for member in members { found = found.concat(entries(&member[1], joined(prefix, member[0]))) }268      found269    }270    case Json.List(items) => {271      var found: Array[(Str, Str)] = []272      var index = 0273      for item in items {274        found = found.concat(entries(&item, joined(prefix, show(index))))275        index = index + 1276      }277      found278    }279    case Json.Null => [(prefix, "")]280    case Json.Boolean(truth) => [(prefix, if truth { "true" } else { "false" })]281    case Json.Number(number) => [(prefix, show(number))]282    case Json.Fractional(digits) => [(prefix, digits)]283    case Json.Text(text) => [(prefix, text)]284  }285}286287/// A nested key.288fn joined(prefix: Str, name: Str) -> Str { if prefix.isEmpty() { name } else { prefix + ":" + name } }289290/// The setting key of an environment variable named `PREFIX__Section__Key`, the prefix ignoring291/// case, or none for a variable outside the prefix.292export fn environmentKey(name: Str, prefix: Str) -> Option[Str] {293  let head = prefix + ENVIRONMENT_SEPARATOR294  if name.length() <= head.length() || name.take(head.length()).toLower() != head.toLower() { return None }295  Some(name.drop(head.length()).replace(ENVIRONMENT_SEPARATOR, ":"))296}297