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