kino 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 129f2e9c0c20ba89c1f2f1e854d32f2b746858546b0c6fdb1a88a3fcb14e05cf
4
- data.tar.gz: 66b465f2e5989e621487752d0b4dac48a9da0857b5290effaac9af803bd315e2
3
+ metadata.gz: 96ea3372ae6524dd1243f75354485613d0e49c38005f870bc83c57a46034e579
4
+ data.tar.gz: c71ac26d45838d6d5ad573e929ad72612f970799a50686b55324304e65a8daae
5
5
  SHA512:
6
- metadata.gz: ac6656c0cd168c5c43dd5d1585b639b93f2489b5e017cbdd297c383c19d083c391cd7ac60034dbcd825bc525b42d7a966f884aa9d03eb0185e7bf8b5539a7781
7
- data.tar.gz: 4392ca122bb8d865eec08393a6f339343bb2f5fac209b82e845ec6319d04f71a7a309a70d5d6518a70d70fc701c50dbd482646250b4d46f3e0d313e497bf4e3d
6
+ metadata.gz: 5f918bd194f00189487a4229f9cffd6005a4b60c6357b21acb6d06e496b9d27ba4487002f8a7fea28f8e2f21324353d3e2fc175e9fcf04906b581d4f2c5309f9
7
+ data.tar.gz: 30b42185d524fc97c4f16f13e27573215ff25d5af6f91881ae91a3c8450945c3d380130aec7acf897d19e2707df5a387f62cdcca9513aa34a148960dff5f420b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,48 @@
1
+ ## [0.4.0] - 2026-08-22
2
+
3
+ - Rack handler: `rails server -u kino` and `rackup -s kino` boot Kino
4
+ through `Rackup::Handler::Kino`, reading the same config file as the
5
+ `kino` CLI with the host's flags on top; `rackup -s kino --help` lists
6
+ the `-O` options (Workers, Threads, Mode, Config).
7
+ - The config file is also looked up at `config/kino.rb` (the Rails
8
+ layout) when there is no `kino.rb`, by the CLI and the handler alike.
9
+ - `Kino::Server#run` serves an already built server the way
10
+ `Kino::Server.run` does (banner, signal traps, block until shutdown),
11
+ and syncs stdout there so the banner is never held back by block
12
+ buffering under a pipe, whichever entry point booted the server.
13
+ - `workers` now defaults to `Kino.available_parallelism`, the CPUs the
14
+ process may actually use: the affinity mask and, in a container, the
15
+ cgroup CPU quota (a pod limited to 2 CPUs on a 64-core node gets 2
16
+ workers, not 64). `Etc.nprocessors` only ever saw the mask.
17
+ - `bind "unix:///path/to.sock"` listens on a unix domain socket, the
18
+ usual shape behind nginx: a stale socket file is reclaimed, a live one
19
+ is refused, and the file is removed on shutdown. `port` is unused on
20
+ it and TLS is rejected (terminate TLS at the proxy). Requests arriving
21
+ over the socket report `REMOTE_ADDR` 127.0.0.1.
22
+ - `Kino::Server#url`, `#control_url`, and `#unix?` report where a started
23
+ server and its control plane listen.
24
+ - The access log is two records per request: an arrival line queued
25
+ before the app runs (a hang shows as an arrow with no answer) and a
26
+ status-tinted completion line with a timing breakdown of `ruby`,
27
+ `kino`, and `wait`, the `ruby` part carrying the GC pause and objects
28
+ allocated where one request at a time can own the VM's counters
29
+ (`:threaded`, or `:ractor` with `workers 1`). Local timestamps with
30
+ their UTC offset; a blank line between requests. The former one-line
31
+ format is gone.
32
+ - A failed request is reported as `500 GET /path · Class: message (site)`
33
+ followed by its backtrace relative to the working directory, the app's
34
+ own frames first, the rest folded into `… N more`.
35
+ - Every line Kino logs about itself (draining, a crash and its respawn,
36
+ hook failures, quarantine, the USR1 stats line, `rack.errors`) reads
37
+ `kino[<pid>] <source>: message`, the source naming the worker that
38
+ spoke (`worker-3`, `worker-3/thread-2`) or `main`; worker ractors and
39
+ threads now carry those names. The label is dim, yellow, or red by
40
+ level on color terminals. `Kino::Log.info`, `.warn`, and `.error` are
41
+ public, for hooks.
42
+ - The startup banner lists the Ruby build with its JIT and parser flags,
43
+ the environment, the topology, the pid, and the control-plane address
44
+ when one is bound.
45
+
1
46
  ## [0.3.0] - 2026-08-13
2
47
 
3
48
  - Queue-time histogram: `/metrics` exposes `kino_request_queue_seconds`, a
data/Cargo.lock CHANGED
@@ -42,7 +42,7 @@ version = "0.72.1"
42
42
  source = "registry+https://github.com/rust-lang/crates.io-index"
43
43
  checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
44
44
  dependencies = [
45
- "bitflags",
45
+ "bitflags 2.13.1",
46
46
  "cexpr",
47
47
  "clang-sys",
48
48
  "itertools",
@@ -54,6 +54,12 @@ dependencies = [
54
54
  "syn 2.0.119",
55
55
  ]
56
56
 
57
+ [[package]]
58
+ name = "bitflags"
59
+ version = "1.3.2"
60
+ source = "registry+https://github.com/rust-lang/crates.io-index"
61
+ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
62
+
57
63
  [[package]]
58
64
  name = "bitflags"
59
65
  version = "2.13.1"
@@ -108,6 +114,37 @@ dependencies = [
108
114
  "libloading",
109
115
  ]
110
116
 
117
+ [[package]]
118
+ name = "defmt"
119
+ version = "1.1.1"
120
+ source = "registry+https://github.com/rust-lang/crates.io-index"
121
+ checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
122
+ dependencies = [
123
+ "bitflags 1.3.2",
124
+ "defmt-macros",
125
+ ]
126
+
127
+ [[package]]
128
+ name = "defmt-macros"
129
+ version = "1.1.1"
130
+ source = "registry+https://github.com/rust-lang/crates.io-index"
131
+ checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
132
+ dependencies = [
133
+ "defmt-parser",
134
+ "proc-macro2",
135
+ "quote",
136
+ "syn 2.0.119",
137
+ ]
138
+
139
+ [[package]]
140
+ name = "defmt-parser"
141
+ version = "1.0.0"
142
+ source = "registry+https://github.com/rust-lang/crates.io-index"
143
+ checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
144
+ dependencies = [
145
+ "thiserror",
146
+ ]
147
+
111
148
  [[package]]
112
149
  name = "either"
113
150
  version = "1.17.0"
@@ -321,6 +358,43 @@ version = "1.0.18"
321
358
  source = "registry+https://github.com/rust-lang/crates.io-index"
322
359
  checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
323
360
 
361
+ [[package]]
362
+ name = "jiff"
363
+ version = "0.2.35"
364
+ source = "registry+https://github.com/rust-lang/crates.io-index"
365
+ checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
366
+ dependencies = [
367
+ "defmt",
368
+ "jiff-core",
369
+ "jiff-static",
370
+ "log",
371
+ "portable-atomic",
372
+ "portable-atomic-util",
373
+ "serde_core",
374
+ "windows-link",
375
+ ]
376
+
377
+ [[package]]
378
+ name = "jiff-core"
379
+ version = "0.1.0"
380
+ source = "registry+https://github.com/rust-lang/crates.io-index"
381
+ checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
382
+ dependencies = [
383
+ "defmt",
384
+ ]
385
+
386
+ [[package]]
387
+ name = "jiff-static"
388
+ version = "0.2.35"
389
+ source = "registry+https://github.com/rust-lang/crates.io-index"
390
+ checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
391
+ dependencies = [
392
+ "jiff-core",
393
+ "proc-macro2",
394
+ "quote",
395
+ "syn 2.0.119",
396
+ ]
397
+
324
398
  [[package]]
325
399
  name = "js-sys"
326
400
  version = "0.3.104"
@@ -333,16 +407,16 @@ dependencies = [
333
407
 
334
408
  [[package]]
335
409
  name = "kino"
336
- version = "0.3.0"
410
+ version = "0.4.0"
337
411
  dependencies = [
338
412
  "ahash",
339
413
  "bytes",
340
414
  "flume",
341
415
  "http",
342
416
  "http-body-util",
343
- "httpdate",
344
417
  "hyper",
345
418
  "hyper-util",
419
+ "jiff",
346
420
  "lru",
347
421
  "magnus",
348
422
  "mimalloc",
@@ -510,6 +584,21 @@ version = "0.2.17"
510
584
  source = "registry+https://github.com/rust-lang/crates.io-index"
511
585
  checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
512
586
 
587
+ [[package]]
588
+ name = "portable-atomic"
589
+ version = "1.15.0"
590
+ source = "registry+https://github.com/rust-lang/crates.io-index"
591
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
592
+
593
+ [[package]]
594
+ name = "portable-atomic-util"
595
+ version = "0.2.7"
596
+ source = "registry+https://github.com/rust-lang/crates.io-index"
597
+ checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
598
+ dependencies = [
599
+ "portable-atomic",
600
+ ]
601
+
513
602
  [[package]]
514
603
  name = "proc-macro2"
515
604
  version = "1.0.107"
@@ -576,7 +665,7 @@ version = "0.5.18"
576
665
  source = "registry+https://github.com/rust-lang/crates.io-index"
577
666
  checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
578
667
  dependencies = [
579
- "bitflags",
668
+ "bitflags 2.13.1",
580
669
  ]
581
670
 
582
671
  [[package]]
@@ -690,6 +779,26 @@ version = "0.3.6"
690
779
  source = "registry+https://github.com/rust-lang/crates.io-index"
691
780
  checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
692
781
 
782
+ [[package]]
783
+ name = "serde_core"
784
+ version = "1.0.229"
785
+ source = "registry+https://github.com/rust-lang/crates.io-index"
786
+ checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
787
+ dependencies = [
788
+ "serde_derive",
789
+ ]
790
+
791
+ [[package]]
792
+ name = "serde_derive"
793
+ version = "1.0.229"
794
+ source = "registry+https://github.com/rust-lang/crates.io-index"
795
+ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
796
+ dependencies = [
797
+ "proc-macro2",
798
+ "quote",
799
+ "syn 3.0.3",
800
+ ]
801
+
693
802
  [[package]]
694
803
  name = "shell-words"
695
804
  version = "1.1.1"
@@ -761,6 +870,26 @@ dependencies = [
761
870
  "unicode-ident",
762
871
  ]
763
872
 
873
+ [[package]]
874
+ name = "thiserror"
875
+ version = "2.0.20"
876
+ source = "registry+https://github.com/rust-lang/crates.io-index"
877
+ checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
878
+ dependencies = [
879
+ "thiserror-impl",
880
+ ]
881
+
882
+ [[package]]
883
+ name = "thiserror-impl"
884
+ version = "2.0.20"
885
+ source = "registry+https://github.com/rust-lang/crates.io-index"
886
+ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
887
+ dependencies = [
888
+ "proc-macro2",
889
+ "quote",
890
+ "syn 3.0.3",
891
+ ]
892
+
764
893
  [[package]]
765
894
  name = "tokio"
766
895
  version = "1.53.1"
data/README.md CHANGED
@@ -196,6 +196,12 @@ bundle exec kino # picks up config.ru + kino.rb, serves on :9292
196
196
  (After a standalone `gem install`, the `kino` command works without
197
197
  `bundle exec`.)
198
198
 
199
+ Prefer your framework's own command? Kino ships a Rack handler, so
200
+ `rails server -u kino` and `rackup -s kino` boot it too. They read the
201
+ same `kino.rb` (or `config/kino.rb`), the host's `-p`/`-b` flags win over
202
+ the file, and `rackup -s kino -O Workers=4 -O Mode=threaded` reaches the
203
+ rest (`rackup -s kino --help` lists them).
204
+
199
205
  No Rust compiler needed: released versions ship precompiled native gems
200
206
  for Linux (x86_64/aarch64, glibc and musl) and macOS (arm64). On other
201
207
  platforms the gem compiles at install time; that needs a Rust toolchain,
@@ -218,9 +224,9 @@ Or embedded, with everything spelled out:
218
224
 
219
225
  ```ruby
220
226
  server = Kino::Server.new(app,
221
- bind: "127.0.0.1",
227
+ bind: "127.0.0.1", # or "unix:///run/kino.sock" behind a proxy
222
228
  port: 9292, # 0 = ephemeral; read back via server.port
223
- workers: Etc.nprocessors, # ractors (parallelism)
229
+ workers: Kino.available_parallelism, # ractors (parallelism); the default
224
230
  threads: 1, # per worker; ractor default 1, threaded default 3
225
231
  mode: :auto, # :auto | :ractor | :threaded
226
232
  queue_depth: 1024, # bounded queue; overflow → 503
@@ -255,8 +261,10 @@ server.shutdown # graceful: drain → deadline → abort straggler
255
261
 
256
262
  ## Config file and CLI
257
263
 
258
- Settings can live in a Puma-style Ruby DSL file. Precedence: explicit
259
- kwargs and CLI flags > config file > defaults.
264
+ Settings can live in a Puma-style Ruby DSL file: `kino.rb` in the
265
+ working directory, or `config/kino.rb` (the Rails layout), is picked up
266
+ automatically; `-C PATH` names any other. Precedence: explicit kwargs
267
+ and CLI flags > config file > defaults.
260
268
 
261
269
  ```ruby
262
270
  # kino.rb
@@ -374,11 +382,11 @@ server.stats
374
382
  # plus lane_depths: [...] when lane dispatch is on
375
383
  ```
376
384
 
377
- From the outside, `kill -USR1 <pid>` prints the same snapshot as one line
385
+ From the outside, `kill -USR1 <pid>` logs the same snapshot as one line
378
386
  (pair it with `pidfile` to find the pid):
379
387
 
380
388
  ```
381
- Kino stats: mode=:ractor lanes=false workers=8 threads=1 batch=1 respawns=0 queued=0 in_flight=2 served=1041 rejected=0 timeouts=0
389
+ kino[4213] main: stats mode=:ractor lanes=false workers=8 threads=1 batch=1 respawns=0 queued=0 in_flight=2 served=1041 rejected=0 timeouts=0
382
390
  ```
383
391
 
384
392
  For pull-based monitoring, `control_bind "127.0.0.1:9293"` (or a
@@ -422,16 +430,28 @@ box). There are two native pieces. Both write through a lock-free
422
430
  channel to a Rust flusher thread, so request threads never take a log
423
431
  mutex and never make a write syscall:
424
432
 
425
- - **Access log** (`log_requests true`): one line per request to stdout,
426
- including the 503s that never reach your app. Recommended in
427
- development; cheap enough for production. On color terminals the
428
- lines are tinted by status class: 2xx green, 3xx yellow, 4xx maroon,
429
- 5xx bright red:
433
+ - **Access log** (`log_requests true`): two records per request to
434
+ stdout, including the 503s that never reach your app. The arrival line
435
+ is queued before the app runs, so a request that hangs shows as an
436
+ arrow with no answer; the completion line carries the status, the
437
+ total, and a timing breakdown: `ruby` is the time the request spent in
438
+ Ruby (with the GC pause and the objects allocated during it), `kino`
439
+ the server's own overhead, `wait` the queue time before a worker took
440
+ it. Recommended in development; cheap enough for production. On color
441
+ terminals the completion line is tinted by status class: 2xx green,
442
+ 3xx yellow, 4xx maroon, 5xx bright red:
430
443
 
431
444
  ```
432
- 127.0.0.1 [Tue, 10 Jun 2026 13:39:56 GMT] "GET / HTTP/1.1" 200 0.1ms
445
+ 2026-08-22 14:03:11 +0300 GET /users?q=1 from 127.0.0.1
446
+ 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)
433
447
  ```
434
448
 
449
+ The GC and allocation figures come from the VM's process-wide
450
+ counters, so they appear only where one request at a time can own
451
+ them: in `:threaded` mode, or in `:ractor` mode with `workers 1`.
452
+ Parallel ractors would bill each other's work, so there the breakdown
453
+ is `(ruby; kino; wait)` alone.
454
+
435
455
  - **`Kino::Logger`**: a `::Logger` over the same async sink, for your
436
456
  app's own logging (`Kino::Logger.new("log/production.log")`, or no
437
457
  argument for stdout). The raw IO-like device is `Kino::Logger::Device`,
@@ -473,6 +493,27 @@ lines/s), the sink drops lines instead of blocking request threads.
473
493
  These trade-offs are measured in
474
494
  [doc/benchmarks.md](doc/benchmarks.md#logging-costs).
475
495
 
496
+ **Server lines.** Everything Kino says about itself (draining, a crash
497
+ and its respawn, a hook that raised, quarantine, the stats line,
498
+ `rack.errors`) reads `kino[<pid>] <source>: message`, the source being
499
+ the worker that spoke, `worker-3` (or `worker-3/thread-2` in a
500
+ multi-threaded ractor), or `main`. On color terminals the label is dim
501
+ for notes, yellow for warnings, red for errors; the message stays plain.
502
+ A failed request gets a report instead of a bare backtrace: the request
503
+ line, the error, and where it raised in your code, then the trace with
504
+ your frames first, relative to the working directory, and the rest
505
+ folded:
506
+
507
+ ```
508
+ kino[4213] worker-2: 500 GET /boom · RuntimeError: kaboom (app.rb:12:in 'explode')
509
+ app.rb:12:in 'explode'
510
+ /usr/lib/ruby/gems/4.0.0/gems/rack-3.2.7/lib/rack/builder.rb:...
511
+ … 38 more
512
+ ```
513
+
514
+ Hooks can log through the same channel with `Kino::Log.info`, `.warn`,
515
+ and `.error`; it is safe inside worker ractors.
516
+
476
517
  ## Timer waits
477
518
 
478
519
  `Kino.sleep(seconds)` is a high-resolution sleep on the OS clock with
@@ -491,8 +532,9 @@ optional in Rack 3.
491
532
 
492
533
  ## Rails
493
534
 
494
- Rails (edge) runs on Kino today in `:threaded` mode; see
495
- `examples/rails-hello`. Ractor-mode Rails is blocked upstream. The exact
535
+ Rails (edge) runs on Kino today in `:threaded` mode (`rails server -u
536
+ kino`, or the `kino` CLI); see `examples/rails-hello`. Ractor-mode Rails
537
+ is blocked upstream. The exact
496
538
  blockers, the `Ruby::Box` findings, and what would unlock it are written
497
539
  up in [doc/rails-on-ractors.md](doc/rails-on-ractors.md). The example
498
540
  ships a probe script that re-tests against whatever Rails you bundle.
data/exe/kino CHANGED
@@ -9,10 +9,6 @@
9
9
  # process-global bits an executable owns.
10
10
 
11
11
  Warning[:experimental] = false
12
- # Startup output must land immediately even when stdout is a pipe or file
13
- # (process supervisors, `kino > server.log`); block buffering would hold
14
- # the banner back until exit.
15
- $stdout.sync = true
16
12
 
17
13
  # Running from a git checkout (no installed gem, no bundler context):
18
14
  # prefer the checkout's own lib so `require "kino"` resolves.
data/ext/kino/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kino"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -26,7 +26,9 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1"] }
26
26
  http = "1"
27
27
  http-body-util = "0.1"
28
28
  bytes = "1"
29
- httpdate = "1"
29
+ # Local-time stamps for the access log: the system zone only (no bundled
30
+ # tzdb, no serde), which keeps the dependency tree to jiff itself.
31
+ jiff = { version = "0.2", default-features = false, features = ["std", "tz-system", "tzdb-zoneinfo"] }
30
32
  # ring (not the default aws-lc-rs) as the rustls crypto provider: it
31
33
  # cross-compiles cleanly in rb-sys-dock containers, while aws-lc-sys wants
32
34
  # cmake and a per-target C toolchain.
@@ -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
+ }