notch.bend

bend-kit-notch@0.1.0.1 · 285 lines · 0 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
3# bend-kit-time@0.1.0.0
4import bend-kit-time@0.1.2.1/time.bend as Time
5
6type Level is Data:
7  Debug{}
8  Info{}
9  Warn{}
10  Error{}
11
12type Value is Data:
13  Str{s: String}
14  Num{n: U32}
15  Flag{b: Bool}
16
17type 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.
21type 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.
26type Logger is Data:
27  Logger{min: Level, format: Format, ctx: List<&2, Field>}
28
29# Levels
30# ------
31
32def 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
43def 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
57def text(min: Level) -> Logger:
58  Logger{min, Text{}, Nil{}}
59
60def 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.
64def 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
69def 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
74def str(k: String, v: String) -> Field:
75  Field{k, Str{v}}
76
77def num(k: String, n: U32) -> Field:
78  Field{k, Num{n}}
79
80def flag(k: String, b: Bool) -> Field:
81  Field{k, Flag{b}}
82
83# Rendering
84# ---------
85
86def hex(+n: U32) -> Char:
87  Chr{Bool.pick(U32, U32.is_lt(n, 10), (48 + n : U32), (87 + n : U32))}
88
89def 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.
98def 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
113def 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
120def 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.
124def 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
131def 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
138def bare(s: String) -> Bool:
139  match s:
140    case SNil{}:
141      False{}
142    case SCon{h, t}:
143      bare.go(SCon{h, t})
144
145def 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.
153def tq(+s: String) -> String:
154  tq.if(bare(s), s)
155
156def Bool.word(b: Bool) -> String:
157  match b:
158    case True{}:
159      "true"
160    case False{}:
161      "false"
162
163def 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
172def 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
181def 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
188def 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
195def time.text(t: Maybe<&2, String>) -> String:
196  match t:
197    case None{}:
198      ""
199    case Some{s}:
200      "time=" ++ tq(s) ++ " "
201
202def time.json(t: Maybe<&2, String>) -> String:
203  match t:
204    case None{}:
205      ""
206    case Some{s}:
207      quote("time") ++ ":" ++ quote(s) ++ ","
208
209def 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.
219def 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
225def 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.
234def 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
241def stamp(lg: Logger, lvl: Level, msg: String, fs: List<&2, Field>) -> IO(String):
242  do IO<String>:
243    i : Time.Instant <- Time.now()
244    return render(lg, Time.rfc3339(i), lvl, msg, fs)
245
246def 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<Unit>:
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.
256def 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.
261def 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<File & Result<&1, &1, U32 & String, Unit>>:
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
271def 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
275def debug(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
276  log(lg, Debug{}, msg, fs)
277
278def info(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
279  log(lg, Info{}, msg, fs)
280
281def warn(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
282  log(lg, Warn{}, msg, fs)
283
284def error(lg: Logger, msg: String, fs: List<&2, Field>) -> IO(Unit):
285  log(lg, Error{}, msg, fs)