kino 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ //! The access log: two records per request on the async sink, so neither
2
+ //! costs the request path a write syscall. An arrival line is queued as
3
+ //! soon as the request head is parsed, before the app sees it, so a hang
4
+ //! shows as an arrow with no answer; a status-tinted completion line
5
+ //! follows the response head with the total and a timing breakdown:
6
+ //!
7
+ //! ```text
8
+ //! 2026-08-22 14:03:11 +0300 → GET /users?q=1 from 127.0.0.1
9
+ //! 2026-08-22 14:03:11 +0300 ← 200 GET /users?q=1 12.4ms (ruby 9.1ms [gc 0.8ms; 1.5k obj]; kino 3.2ms; wait 0.1ms)
10
+ //! ```
11
+ //!
12
+ //! `ruby` is the time the request spent in a Ruby worker (admit to
13
+ //! response head), with the GC pause and objects allocated during the app
14
+ //! call when the worker measured them; `kino` is the server's own
15
+ //! overhead (total minus ruby minus wait); `wait` is the queue time before
16
+ //! a worker took the request. A blank line sets one request apart from
17
+ //! the next.
18
+
19
+ use std::fmt::Write as _;
20
+ use std::net::IpAddr;
21
+ use std::time::Duration;
22
+
23
+ use crate::style::{self, Stream};
24
+
25
+ /// Per-request timing, measured on the way through and riding the
26
+ /// response as an extension so the intake side can log it.
27
+ #[derive(Clone, Copy, Debug)]
28
+ pub struct Timing {
29
+ /// Queue wait before a worker took the request.
30
+ pub wait: Duration,
31
+ /// Admit to response head: the request's time in Ruby.
32
+ pub ruby: Duration,
33
+ /// GC pause and objects allocated during the app call, when measured.
34
+ /// Left out where the VM's process-wide counters cannot be attributed
35
+ /// to one request (parallel ractors).
36
+ pub gc: Option<(Duration, u64)>,
37
+ }
38
+
39
+ /// The arrival record, stamped and styled for stdout.
40
+ pub fn arrival(method: &str, target: &str, ip: IpAddr) -> String {
41
+ let color = style::enabled(Stream::Stdout);
42
+ let record = style::sgr(style::BOLD_WHITE, &arrival_line(method, target, ip), color);
43
+ stamped(&record, color)
44
+ }
45
+
46
+ /// The completion record, stamped, tinted by status class, followed by the
47
+ /// dimmed breakdown when timing was measured (a 503 or 504 never reached a
48
+ /// worker, so it has none). Ends with an extra newline: with the sink's
49
+ /// own, that leaves the blank line after the record.
50
+ pub fn completion(
51
+ status: u16,
52
+ method: &str,
53
+ target: &str,
54
+ total: Duration,
55
+ timing: Option<Timing>,
56
+ ) -> String {
57
+ let color = style::enabled(Stream::Stdout);
58
+ let record = completion_line(status, method, target, total);
59
+ let record = match style::status_sgr(status) {
60
+ Some(code) => style::sgr(code, &record, color),
61
+ None => record,
62
+ };
63
+ let mut line = stamped(&record, color);
64
+ if let Some(timing) = timing {
65
+ line.push(' ');
66
+ if color {
67
+ let _ = write!(line, "\x1b[{}m", style::DIM);
68
+ }
69
+ write_breakdown(&mut line, total, &timing);
70
+ if color {
71
+ line.push_str("\x1b[0m");
72
+ }
73
+ }
74
+ line.push('\n');
75
+ line
76
+ }
77
+
78
+ /// Prefix a record with the local timestamp, dimmed when coloring so it
79
+ /// recedes behind the arrow and status.
80
+ fn stamped(record: &str, color: bool) -> String {
81
+ format!("{} {record}", style::sgr(style::DIM, &now_stamp(), color))
82
+ }
83
+
84
+ /// The local wall clock as `2026-08-22 14:03:11 +0300`: date, time, and
85
+ /// the numeric UTC offset, so a log file is unambiguous across machines.
86
+ fn now_stamp() -> String {
87
+ jiff::Zoned::now()
88
+ .strftime("%Y-%m-%d %H:%M:%S %z")
89
+ .to_string()
90
+ }
91
+
92
+ /// `→ METHOD target from IP`.
93
+ fn arrival_line(method: &str, target: &str, ip: IpAddr) -> String {
94
+ format!("\u{2192} {method} {target} from {ip}")
95
+ }
96
+
97
+ /// `← STATUS METHOD target N.Nms`.
98
+ fn completion_line(status: u16, method: &str, target: &str, total: Duration) -> String {
99
+ format!(
100
+ "\u{2190} {status} {method} {target} {:.1}ms",
101
+ millis(total)
102
+ )
103
+ }
104
+
105
+ /// Append the breakdown straight into the line buffer (no string of its
106
+ /// own): `(ruby N.Nms [gc N.Nms; N obj]; kino N.Nms; wait N.Nms)`, the gc
107
+ /// bracket only when measured. `kino` floors at zero: at sub-millisecond
108
+ /// totals clock granularity can make the parts exceed the whole.
109
+ fn write_breakdown(out: &mut String, total: Duration, timing: &Timing) {
110
+ let kino = total
111
+ .saturating_sub(timing.ruby)
112
+ .saturating_sub(timing.wait);
113
+ let _ = write!(out, "(ruby {:.1}ms", millis(timing.ruby));
114
+ if let Some((gc, allocs)) = timing.gc {
115
+ let _ = write!(out, " [gc {:.1}ms; ", millis(gc));
116
+ write_count(out, allocs);
117
+ out.push_str(" obj]");
118
+ }
119
+ let _ = write!(
120
+ out,
121
+ "; kino {:.1}ms; wait {:.1}ms)",
122
+ millis(kino),
123
+ millis(timing.wait)
124
+ );
125
+ }
126
+
127
+ fn millis(d: Duration) -> f64 {
128
+ d.as_secs_f64() * 1000.0
129
+ }
130
+
131
+ /// A humanized count: `523`, `1.5k`, `52k`.
132
+ fn write_count(out: &mut String, n: u64) {
133
+ if n < 1000 {
134
+ let _ = write!(out, "{n}");
135
+ } else if n < 10_000 {
136
+ let _ = write!(out, "{:.1}k", n as f64 / 1000.0);
137
+ } else {
138
+ let _ = write!(out, "{}k", n / 1000);
139
+ }
140
+ }
141
+
142
+ #[cfg(test)]
143
+ mod tests {
144
+ use super::{arrival_line, completion_line, now_stamp, write_breakdown, write_count, Timing};
145
+ use std::net::{IpAddr, Ipv4Addr};
146
+ use std::time::Duration;
147
+
148
+ fn breakdown(total: Duration, timing: &Timing) -> String {
149
+ let mut out = String::new();
150
+ write_breakdown(&mut out, total, timing);
151
+ out
152
+ }
153
+
154
+ fn count(n: u64) -> String {
155
+ let mut out = String::new();
156
+ write_count(&mut out, n);
157
+ out
158
+ }
159
+
160
+ #[test]
161
+ fn arrival_line_carries_method_target_and_ip() {
162
+ let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
163
+ assert_eq!(
164
+ arrival_line("GET", "/users?q=1", ip),
165
+ "\u{2192} GET /users?q=1 from 203.0.113.5"
166
+ );
167
+ }
168
+
169
+ #[test]
170
+ fn completion_line_carries_status_target_and_total() {
171
+ assert_eq!(
172
+ completion_line(200, "GET", "/users", Duration::from_millis(12)),
173
+ "\u{2190} 200 GET /users 12.0ms"
174
+ );
175
+ }
176
+
177
+ #[test]
178
+ fn breakdown_splits_the_total_into_ruby_kino_and_wait() {
179
+ let timing = Timing {
180
+ wait: Duration::from_micros(100),
181
+ ruby: Duration::from_millis(41),
182
+ gc: Some((Duration::from_micros(9_700), 52_000)),
183
+ };
184
+ // kino = total - ruby - wait = 45.2 - 41.0 - 0.1 = 4.1ms.
185
+ assert_eq!(
186
+ breakdown(Duration::from_micros(45_200), &timing),
187
+ "(ruby 41.0ms [gc 9.7ms; 52k obj]; kino 4.1ms; wait 0.1ms)"
188
+ );
189
+ }
190
+
191
+ #[test]
192
+ fn breakdown_leaves_out_the_gc_bracket_when_not_measured() {
193
+ let timing = Timing {
194
+ wait: Duration::from_millis(1),
195
+ ruby: Duration::from_millis(2),
196
+ gc: None,
197
+ };
198
+ assert_eq!(
199
+ breakdown(Duration::from_millis(4), &timing),
200
+ "(ruby 2.0ms; kino 1.0ms; wait 1.0ms)"
201
+ );
202
+ }
203
+
204
+ #[test]
205
+ fn breakdown_floors_kino_at_zero() {
206
+ let timing = Timing {
207
+ wait: Duration::from_millis(1),
208
+ ruby: Duration::from_millis(40),
209
+ gc: Some((Duration::ZERO, 10)),
210
+ };
211
+ assert_eq!(
212
+ breakdown(Duration::from_millis(40), &timing),
213
+ "(ruby 40.0ms [gc 0.0ms; 10 obj]; kino 0.0ms; wait 1.0ms)"
214
+ );
215
+ }
216
+
217
+ #[test]
218
+ fn count_humanizes_allocation_counts() {
219
+ assert_eq!(count(0), "0");
220
+ assert_eq!(count(523), "523");
221
+ assert_eq!(count(1500), "1.5k");
222
+ assert_eq!(count(52_000), "52k");
223
+ }
224
+
225
+ #[test]
226
+ fn timestamp_is_date_time_and_numeric_offset() {
227
+ // `YYYY-MM-DD HH:MM:SS +HHMM`, e.g. `2026-08-22 14:03:11 +0300`.
228
+ let ts = now_stamp();
229
+ let (datetime, offset) = ts.rsplit_once(' ').expect("offset field");
230
+ let (date, time) = datetime.split_once(' ').expect("date and time");
231
+ assert_eq!(date.len(), 10, "date {date}");
232
+ assert_eq!(&date[4..5], "-");
233
+ assert_eq!(time.len(), 8, "time {time}");
234
+ assert_eq!(&time[2..3], ":");
235
+ assert_eq!(offset.len(), 5, "offset {offset}");
236
+ assert!(matches!(&offset[0..1], "+" | "-"));
237
+ assert!(offset[1..].bytes().all(|b| b.is_ascii_digit()));
238
+ }
239
+
240
+ #[test]
241
+ fn completion_ends_with_the_blank_line_newline() {
242
+ let line = super::completion(200, "GET", "/", Duration::from_millis(1), None);
243
+ assert!(line.ends_with("\u{2190} 200 GET / 1.0ms\n") || line.ends_with("1.0ms\x1b[0m\n"));
244
+ }
245
+ }