1 # Leveled, structured logging: a logger value, key-value fields, and logfmt or JSON-lines output. Source: https://github.com/paymog/bend-kit/tree/main/notch 2 import Base 3 # bend-kit-time@0.1.0.0 4 import 0x3bfb4ae4d3b87b90f01bfcd298211455/time.bend as Time 5 6 type Level is Data: 7 Debug{} 8 Info{} 9 Warn{} 10 Error{} 11 12 type Value is Data: 13 Str{s: String} 14 Num{n: U32} 15 Flag{b: Bool} 16 17 type Field is Data: 18 Field{key: String, val: Value} 19 20 # Text is logfmt, as Go's slog TextHandler writes it. Json is one object per line. 21 type Format is Data: 22 Text{} 23 Json{} 24 25 # ctx holds the fields from with, oldest first. A line puts them before its own fields. 26 type Logger is Data: 27 Logger{min: Level, format: Format, ctx: List<&2, Field>} 28 29 # Levels 30 # ------ 31 32 def Level.rank(l: Level) -> Nat: 33 match l: 34 case Debug{}: 35 0n 36 case Info{}: 37 1n 38 case Warn{}: 39 2n 40 case Error{}: 41 3n 42 43 def Level.name(l: Level) -> String: 44 match l: 45 case Debug{}: 46 "DEBUG" 47 case Info{}: 48 "INFO" 49 case Warn{}: 50 "WARN" 51 case Error{}: 52 "ERROR" 53 54 # Loggers and fields 55 # ------------------ 56 57 def text(min: Level) -> Logger: 58 Logger{min, Text{}, Nil{}} 59 60 def json(min: Level) -> Logger: 61 Logger{min, Json{}, Nil{}} 62 63 # A logger that adds fs to every line, after the fields it already adds. 64 def with(lg: Logger, fs: List<&2, Field>) -> Logger: 65 match lg: 66 case Logger{m, f, c}: 67 Logger{m, f, List.append(&2, Field, c, fs)} 68 69 def enabled(lg: Logger, lvl: Level) -> Bool: 70 match lg: 71 case Logger{m, f, c}: 72 Nat.is_ge(Level.rank(lvl), Level.rank(m)) 73 74 def str(k: String, v: String) -> Field: 75 Field{k, Str{v}} 76 77 def num(k: String, n: U32) -> Field: 78 Field{k, Num{n}} 79 80 def flag(k: String, b: Bool) -> Field: 81 Field{k, Flag{b}} 82 83 # Rendering 84 # --------- 85 86 def hex(+n: U32) -> Char: 87 Chr{Bool.pick(U32, U32.is_lt(n, 10), (48 + n : U32), (87 + n : U32))} 88 89 def esc.ctl(ctl: Bool, +c: U32, rest: String) -> String: 90 match ctl: 91 case True{}: 92 SCon{Chr{92}, SCon{'u', SCon{'0', SCon{'0', SCon{hex(U32.shrn(c, 4n)), 93 SCon{hex((c .&. 15 : U32)), rest}}}}}} 94 case False{}: 95 SCon{Chr{c}, rest} 96 97 # JSON (RFC 8259 ยง7): quote, backslash and control chars are escaped. 98 def esc.c(+c: U32, rest: String) -> String: 99 match c: 100 case 34: 101 SCon{Chr{92}, SCon{Chr{34}, rest}} 102 case 92: 103 SCon{Chr{92}, SCon{Chr{92}, rest}} 104 case 10: 105 SCon{Chr{92}, SCon{'n', rest}} 106 case 13: 107 SCon{Chr{92}, SCon{'r', rest}} 108 case 9: 109 SCon{Chr{92}, SCon{'t', rest}} 110 case _: 111 esc.ctl(U32.is_lt(c, 32), c, rest) 112 113 def esc(s: String, rest: String) -> String: 114 match s: 115 case SNil{}: 116 rest 117 case SCon{Chr{x}, t}: 118 esc.c(x, esc(t, rest)) 119 120 def quote(s: String) -> String: 121 SCon{Chr{34}, esc(s, SCon{Chr{34}, SNil{}})} 122 123 # A bare logfmt token has no whitespace, controls, '=', quote or DEL. 124 def bare.c(+c: U32) -> Bool: 125 U32.is_lt(32, c) && U32.is_ne(c, 61) && U32.is_ne(c, 34) && U32.is_ne(c, 127) && 126 U32.is_ne(c, 133) && U32.is_ne(c, 160) && U32.is_ne(c, 5760) && 127 Bool.not(Bool.and(U32.is_ge(c, 8192), U32.is_le(c, 8202))) && 128 Bool.not(Bool.and(U32.is_ge(c, 8232), U32.is_le(c, 8233))) && 129 U32.is_ne(c, 8239) && U32.is_ne(c, 8287) && U32.is_ne(c, 12288) 130 131 def bare.go(s: String) -> Bool: 132 match s: 133 case SNil{}: 134 True{} 135 case SCon{Chr{x}, t}: 136 Bool.and(bare.c(x), bare.go(t)) 137 138 def bare(s: String) -> Bool: 139 match s: 140 case SNil{}: 141 False{} 142 case SCon{h, t}: 143 bare.go(SCon{h, t}) 144 145 def tq.if(ok: Bool, s: String) -> String: 146 match ok: 147 case True{}: 148 s 149 case False{}: 150 quote(s) 151 152 # A logfmt key or string value: bare when it can be, else a JSON string. 153 def tq(+s: String) -> String: 154 tq.if(bare(s), s) 155 156 def Bool.word(b: Bool) -> String: 157 match b: 158 case True{}: 159 "true" 160 case False{}: 161 "false" 162 163 def Value.text(v: Value) -> String: 164 match v: 165 case Str{s}: 166 tq(s) 167 case Num{n}: 168 U32.show(n) 169 case Flag{b}: 170 Bool.word(b) 171 172 def Value.json(v: Value) -> String: 173 match v: 174 case Str{s}: 175 quote(s) 176 case Num{n}: 177 U32.show(n) 178 case Flag{b}: 179 Bool.word(b) 180 181 def fields.text(fs: List<&2, Field>) -> String: 182 match fs: 183 case Nil{}: 184 "" 185 case Con{Field{k, v}, t}: 186 " " ++ tq(k) ++ "=" ++ Value.text(v) ++ fields.text(t) 187 188 def fields.json(fs: List<&2, Field>) -> String: 189 match fs: 190 case Nil{}: 191 "" 192 case Con{Field{k, v}, t}: 193 "," ++ quote(k) ++ ":" ++ Value.json(v) ++ fields.json(t) 194 195 def time.text(t: Maybe<&2, String>) -> String: 196 match t: 197 case None{}: 198 "" 199 case Some{s}: 200 "time=" ++ tq(s) ++ " " 201 202 def time.json(t: Maybe<&2, String>) -> String: 203 match t: 204 case None{}: 205 "" 206 case Some{s}: 207 quote("time") ++ ":" ++ quote(s) ++ "," 208 209 def render.as(f: Format, t: Maybe<&2, String>, lvl: Level, msg: String, 210 fs: List<&2, Field>) -> String: 211 match f: 212 case Text{}: 213 time.text(t) ++ "level=" ++ Level.name(lvl) ++ " msg=" ++ tq(msg) ++ fields.text(fs) 214 case Json{}: 215 "{" ++ time.json(t) ++ quote("level") ++ ":" ++ quote(Level.name(lvl)) ++ "," ++ 216 quote("msg") ++ ":" ++ quote(msg) ++ fields.json(fs) ++ "}" 217 218 # One line, without its newline, whatever the level. t is the time text; None leaves it out. 219 def render(lg: Logger, t: Maybe<&2, String>, lvl: Level, msg: String, 220 fs: List<&2, Field>) -> String: 221 match lg: 222 case Logger{m, f, c}: 223 render.as(f, t, lvl, msg, List.append(&2, Field, c, fs)) 224 225 def line.if(on: Bool, lg: Logger, t: Maybe<&2, String>, lvl: Level, msg: String, 226 fs: List<&2, Field>) -> Maybe<&2, String>: 227 match on: 228 case True{}: 229 Some{render(lg, t, lvl, msg, fs)} 230 case False{}: 231 None{} 232 233 # The line, or None when lvl is below the logger's level. A dropped line is never rendered. 234 def line(+lg: Logger, t: Maybe<&2, String>, +lvl: Level, msg: String, 235 fs: List<&2, Field>) -> Maybe<&2, String>: 236 line.if(enabled(lg, lvl), lg, t, lvl, msg, fs) 237 238 # Output 239 # ------ 240 241 def stamp(lg: Logger, lvl: Level, msg: String, fs: List<&2, Field>) -> IO(String): 242 do IO: 243 i : Time.Instant <- Time.now() 244 return render(lg, Time.rfc3339(i), lvl, msg, fs) 245 246 def log.if(on: Bool, lg: Logger, lvl: Level, msg: String, fs: List<&2, Field>) -> IO(Unit): 247 match on: 248 case True{}: 249 do IO: 250 s : String <- stamp(lg, lvl, msg, fs) 251 IO.print_err(s) 252 case False{}: 253 IO.pure(Unit, Unit{}) 254 255 # Writes the line to stderr, stamped with the wall clock in RFC 3339. Below the logger's level, it reads no clock. 256 def log(+lg: Logger, +lvl: Level, msg: String, fs: List<&2, Field>) -> IO(Unit): 257 log.if(enabled(lg, lvl), lg, lvl, msg, fs) 258 259 # Write a stamped line and newline to a File; the returned handle must be kept or closed. 260 # Below the logger's level, returns the same handle and Done without a clock read or write. 261 def file.if(on: Bool, h: File, lg: Logger, lvl: Level, msg: String, 262 fs: List<&2, Field>) -> IO(File & Result<&1, &1, U32 & String, Unit>): 263 match on: 264 case True{}: 265 do IO>: 266 s : String <- stamp(lg, lvl, msg, fs) 267 File.write(h, s ++ "\n") 268 case False{}: 269 IO.pure(File & Result<&1, &1, U32 & String, Unit>, (h, Done{Unit{}})) 270 271 def file(h: File, +lg: Logger, +lvl: Level, msg: String, 272 fs: List<&2, Field>) -> IO(File & Result<&1, &1, U32 & String, Unit>): 273 file.if(enabled(lg, lvl), h, lg, lvl, msg, fs) 274 275 def debug(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit): 276 log(lg, Debug{}, msg, fs) 277 278 def info(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit): 279 log(lg, Info{}, msg, fs) 280 281 def warn(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit): 282 log(lg, Warn{}, msg, fs) 283 284 def error(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit): 285 log(lg, Error{}, msg, fs)