notch.bend

bend-kit-notch@0.4.0.0 · 284 lines · 44 definitions

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
2import Base
3import bend-kit-time@0.3.0.0/time.bend as Time
4
5type Level is Data:
6  Debug{}
7  Info{}
8  Warn{}
9  Error{}
10
11type Value is Data:
12  Str{s: String}
13  Num{n: U32}
14  Flag{b: Bool}
15
16type Field is Data:
17  Field{key: String, val: Value}
18
19# Logfmt is written as Go's slog TextHandler writes it. Json is one object per line.
20type Format is Data:
21  Logfmt{}
22  Json{}
23
24# ctx holds the fields from with, oldest first. A line puts them before its own fields.
25type Logger is Data:
26  Logger{min: Level, format: Format, ctx: List<&2, Field>}
27
28# Levels
29# ------
30
31def 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
42def 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
56def text(min: Level) -> Logger:
57  Logger{min, Logfmt{}, Nil{}}
58
59def 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.
63def 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
68def 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
73def str(k: String, v: String) -> Field:
74  Field{k, Str{v}}
75
76def num(k: String, n: U32) -> Field:
77  Field{k, Num{n}}
78
79def flag(k: String, b: Bool) -> Field:
80  Field{k, Flag{b}}
81
82# Rendering
83# ---------
84
85def hex(+n: U32) -> Char:
86  Chr{Bool.pick(U32, U32.is_lt(n, 10), (48 + n : U32), (87 + n : U32))}
87
88def 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.
97def 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
112def 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
119def 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.
123def 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
130def 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
137def bare(s: String) -> Bool:
138  match s:
139    case SNil{}:
140      False{}
141    case SCon{h, t}:
142      bare.go(SCon{h, t})
143
144def 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.
152def tq(+s: String) -> String:
153  tq.if(bare(s), s)
154
155def Bool.word(b: Bool) -> String:
156  match b:
157    case True{}:
158      "true"
159    case False{}:
160      "false"
161
162def 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
171def 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
180def 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
187def 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
194def time.text(t: Maybe<&2, String>) -> String:
195  match t:
196    case None{}:
197      ""
198    case Some{s}:
199      "time=" ++ tq(s) ++ " "
200
201def time.json(t: Maybe<&2, String>) -> String:
202  match t:
203    case None{}:
204      ""
205    case Some{s}:
206      quote("time") ++ ":" ++ quote(s) ++ ","
207
208def render.as(f: Format, t: Maybe<&2, String>, lvl: Level, msg: String,
209  fs: List<&2, Field>) -> String:
210  match f:
211    case Logfmt{}:
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.
218def 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
224def 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.
233def 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
240def stamp(lg: Logger, lvl: Level, msg: String, fs: List<&2, Field>) -> IO(String):
241  do IO<String>:
242    i : Time.Instant <- Time.now()
243    return render(lg, Time.rfc3339(i), lvl, msg, fs)
244
245def 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<Unit>:
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.
255def 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.
260def 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<File & Result<&1, &1, U32 & String, Unit>>:
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
270def 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
274def debug(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
275  log(lg, Debug{}, msg, fs)
276
277def info(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
278  log(lg, Info{}, msg, fs)
279
280def warn(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
281  log(lg, Warn{}, msg, fs)
282
283def error(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
284  log(lg, Error{}, msg, fs)