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

RequestLogging.pudu

Pudu107 lines5.0 KB

GitHub ↗
1/** @Log.Web.RequestLogging.Middleware — one completion event per HTTP request */2module PuduLangLog.Web.RequestLogging34import Std.Decimal as Decimal5import Std.Http.Server.Route as Route6import Std.Http as Web7import Std.Option as Option8import Std.Time as Time9import PuduLangLog.Domain.Parser as Parser10import PuduLangLog as Log11import PuduLangLog.Logger as Logger12import PuduLangLog.Web.Diagnostic as Diagnostic1314/** @Log.Web.RequestLogging.Options — message, level, and properties of the event */15export type Options = {16  messageTemplate: Str,17  level: fn(&Route.Request, Int, Float64, Bool) -> Log.Level,18  enrich: fn(&Diagnostic.Collector, &Route.Request, &Web.Response) -> (),19  includeQueryInRequestPath: Bool,20  properties: fn(&Route.Request, Str, Float64, Int) -> Array[Log.Property]21}2223/// The source context of completion events.24export const SOURCE: Str = "PuduLangLog.Web.RequestLogging"2526/// The completion message of the defaults.27export const MESSAGE: Str = "HTTP \{RequestMethod\} \{RequestPath\} responded \{StatusCode\} in \{Elapsed:0.0000\} ms"2829/// The default message, `Error` for a failure or a status of 500 and above and `Information`30/// otherwise, no extra enrichment, and the path without its query.31export fn defaults() -> Options {32  Options {33    messageTemplate: MESSAGE,34    level: fn(_request: &Route.Request, status: Int, _elapsed: Float64, failed: Bool) -> Log.Level { levelFor(status, failed) },35    enrich: fn(_collector: &Diagnostic.Collector, _request: &Route.Request, _response: &Web.Response) -> () {},36    includeQueryInRequestPath: false,37    properties: standardProperties38  }39}4041/// `Error` for a failure or a status of 500 and above, `Information` otherwise.42export fn levelFor(status: Int, failed: Bool) -> Log.Level { if failed || status > 499 { Log.Error } else { Log.Information } }4344/// `RequestMethod`, `RequestPath`, `StatusCode`, and `Elapsed` in milliseconds.45export fn standardProperties(request: &Route.Request, path: Str, elapsed: Float64, status: Int) -> Array[Log.Property] {46  [47    Log.Property{name: "RequestMethod", value: Log.Scalar(Log.Text(Web.methodName(&request.message.method)))},48    Log.Property{name: "RequestPath", value: Log.Scalar(Log.Text(path))},49    Log.Property{name: "StatusCode", value: Log.Scalar(Log.Integer(status))},50    Log.Property{name: "Elapsed", value: Log.Scalar(Log.Real(elapsed))}51  ]52}5354/// A middleware writing one event when each request completes.55export fn middleware(logger: &Logger.Logger, options: Options) -> Route.Middleware {56  let shared = *logger57  fn(request: Route.Request, next: Route.Handler) -> Web.Response {58    respond(&shared, &options, &request, fn(_collector: &Diagnostic.Collector) -> Web.Response { next(request) })59  }60}6162/// A handler given a collector for properties and a failure to add to its request's completion event.63export fn handler(logger: &Logger.Logger, options: Options, inner: fn(Route.Request, &Diagnostic.Collector) -> Web.Response) -> Route.Handler {64  let shared = *logger65  fn(request: Route.Request) -> Web.Response {66    respond(&shared, &options, &request, fn(collector: &Diagnostic.Collector) -> Web.Response { inner(request, collector) })67  }68}6970/// Runs the request, then writes its completion event when the level is enabled.71fn respond(logger: &Logger.Logger, options: &Options, request: &Route.Request, run: fn(&Diagnostic.Collector) -> Web.Response) -> Web.Response {72  let started = Time.elapsed()73  let collector = Diagnostic.create()74  let response = run(&collector)75  let elapsed = Decimal.toFloat64(Decimal.fromInt(Time.elapsed() - started))76  let failure = Diagnostic.failure(&collector)77  let source = traced(&logger.forSource(SOURCE), request)78  let level = (options.level)(request, response.status.code, elapsed, failure != None)79  if !source.isEnabled(level) { return response }80  (options.enrich)(&collector, request, &response)81  var properties: Array[Log.Property] = []82  for entry in Diagnostic.entries(&collector) {83    if let Some(bound) = source.bindProperty(entry.name, entry.value, entry.destructure) { properties = properties.push(bound) }84  }85  let path = if options.includeQueryInRequestPath { request.message.target } else { request.path }86  let event = Log.Event {87    timestamp: (Logger.pipelineOf(&source).clock)(),88    level: level,89    template: Parser.parse(options.messageTemplate),90    properties: properties.concat((options.properties)(request, path, elapsed, response.status.code)),91    failure: failure,92    traceId: Option.map(source.trace, |pair: (Str, Str)| pair[0]),93    spanId: Option.map(source.trace, |pair: (Str, Str)| pair[1])94  }95  let _written = source.emit(event)96  response97}9899/// The logger carrying the trace and parent span of a `traceparent` header, when the request has a100/// well-formed one.101fn traced(logger: &Logger.Logger, request: &Route.Request) -> Logger.Logger {102  let header = Option.unwrapOr(Route.header(request, "traceparent"), "")103  let parts = header.trim().split("-")104  if parts.length() != 4 || parts[1].length() != 32 || parts[2].length() != 16 { return *logger }105  logger.withTrace(parts[1], parts[2])106}107