llv 0.1.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 +7 -0
- data/README.md +130 -0
- data/bin/llv +6 -0
- data/lib/llv/ansi.rb +154 -0
- data/lib/llv/cli.rb +121 -0
- data/lib/llv/group_store.rb +166 -0
- data/lib/llv/parser.rb +211 -0
- data/lib/llv/public/app.js +198 -0
- data/lib/llv/public/index.html +40 -0
- data/lib/llv/public/styles.css +239 -0
- data/lib/llv/tailer.rb +105 -0
- data/lib/llv/tui.rb +521 -0
- data/lib/llv/version.rb +5 -0
- data/lib/llv/web.rb +104 -0
- data/lib/llv.rb +8 -0
- metadata +153 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '009573ab40ee86ba88dd8daee97a3035d9a24ab0d923c774c8550313f4a999ff'
|
|
4
|
+
data.tar.gz: f569e2357a526127d941da5d4b0d5555fd6faca19a97e15a4ecf5bc4de1c73c4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c5d6f573055cd148fbeb20c1b66e8e82ce040e619a7031255b83804d871fbca26db9486b2b7edb936bb6c55bb41cef7fe87ec5334ec24c976ffa14c196d11a9f
|
|
7
|
+
data.tar.gz: d0e4c3c993f3cf2662a27b49de67c933f55624f4624ea3bcc7329ad7c8f3ab37e9a530c3408c46dc70ce3cbb23f0ffb6760e773b1fb447672e734d446ece434f
|
data/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# llv
|
|
2
|
+
|
|
3
|
+
Rails has ~"beautiful"~ trashy noisy unreadable garbage logs that it vomits uselessly into your terminal. We can do better.
|
|
4
|
+
|
|
5
|
+
Local log viewer for Rails `development.log`. Tails the file, groups lines by request (or background job), and shows them in a master/detail view — browser by default, or a TUI.
|
|
6
|
+
|
|
7
|
+
This is vide-coded because solving this years-old frustration now only takes 30mins between other work. Feel free to enhance it.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
In your Rails app, enable tagged-logging with the request id so HTTP lines can be grouped reliably. Edit `config/environments/development.rb`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
config.log_tags = [:request_id]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
ActiveJob lines already carry their own tags, no change needed for those.
|
|
18
|
+
|
|
19
|
+
Install `llv` globally — it's a standalone CLI, not something you bundle into your app. Once published to RubyGems:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
gem install llv
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
In the meantime, from a clone of this repo:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
bundle install && rake install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Run
|
|
32
|
+
|
|
33
|
+
Browser UI (default):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
llv path/to/development.log
|
|
37
|
+
# opens http://127.0.0.1:9292
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Terminal UI:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
llv --tui path/to/development.log
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Useful flags:
|
|
47
|
+
|
|
48
|
+
- `--tail` — only show lines appended after launch. By default `llv` replays the whole file first so you see existing requests too.
|
|
49
|
+
- `--limit N` — keep at most N items in memory (default 500).
|
|
50
|
+
- `--port`, `--host` — change the web server bind.
|
|
51
|
+
- `--no-open` — don't auto-launch the browser.
|
|
52
|
+
|
|
53
|
+
## TUI keys
|
|
54
|
+
|
|
55
|
+
I don't use many terminal apps so please contribute enhancements to make it behave more as you'd expect.
|
|
56
|
+
|
|
57
|
+
The TUI is a two-pane master/detail with one focused pane at a time. Cursor keys and the vi-style `hjkl` both work.
|
|
58
|
+
|
|
59
|
+
| key | when focus is **LIST** (left) | when focus is **DETAIL** (right) |
|
|
60
|
+
|------------------------------|-------------------------------|----------------------------------|
|
|
61
|
+
| `↑` / `k` | previous request | scroll one line up |
|
|
62
|
+
| `↓` / `j` | next request | scroll one line down |
|
|
63
|
+
| `→` / `l` / `enter` | move focus to DETAIL | — |
|
|
64
|
+
| `←` / `h` / `esc` | — | move focus back to LIST |
|
|
65
|
+
| `tab` | swap focus | swap focus |
|
|
66
|
+
| `PgUp` / `PgDn` / space | page through the list | page through detail |
|
|
67
|
+
| `Ctrl-U` / `Ctrl-D` | — | half-page up / down |
|
|
68
|
+
| `g` / `Home` | first item | scroll to top |
|
|
69
|
+
| `G` / `End` | last item | scroll to bottom |
|
|
70
|
+
| `s` | toggle SQL `↳` source lines | toggle SQL `↳` source lines |
|
|
71
|
+
| `H` / `J` / `C` | toggle http / jobs / cable | toggle http / jobs / cable |
|
|
72
|
+
| `q` / `Ctrl-C` | quit | quit |
|
|
73
|
+
|
|
74
|
+
The header shows which pane has focus (`[LIST]` or `[DETAIL]`) and the divider thickens (`│` → `┃`) when the detail pane is active.
|
|
75
|
+
|
|
76
|
+
## What you see
|
|
77
|
+
|
|
78
|
+
- **Left pane** — newest-first list of items. Each item is one HTTP request (with method, path, status, duration), one ActiveJob run, or one ActionCable (`/cable`) connection.
|
|
79
|
+
- **Right pane** — the log lines that belong to the selected item, with the ANSI colours Rails already emits (bold cyan for SQL labels, bold blue for the SQL itself, etc.). SQL source `↳` lines are revealed alongside their query.
|
|
80
|
+
|
|
81
|
+
## Filtering noise
|
|
82
|
+
|
|
83
|
+
ActionCable (`/cable`) requests are very chatty in development and are usually not what you're trying to debug. Each kind (`http`, `jobs`, `cable`) has its own independent toggle:
|
|
84
|
+
|
|
85
|
+
- **Browser**: three buttons in the header — click one to toggle its requests on/off. They're independent, so "HTTP + Jobs but not Cable" is one click on the Cable button.
|
|
86
|
+
- **TUI**: `H` toggles HTTP, `J` toggles Jobs, `C` toggles Cable. The header shows the current state and the total reflects items visible / items captured.
|
|
87
|
+
|
|
88
|
+
## Layout
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
lib/llv/
|
|
92
|
+
ansi.rb # SGR <-> HTML span
|
|
93
|
+
parser.rb # line classifier, stateful for untagged-HTTP grouping
|
|
94
|
+
group_store.rb # thread-safe ring buffer, pub/sub
|
|
95
|
+
tailer.rb # listen-based file follower, rotation-aware
|
|
96
|
+
web.rb # Sinatra + SSE
|
|
97
|
+
tui.rb # bubbletea Model + lipgloss layout
|
|
98
|
+
public/ # vanilla HTML/JS/CSS
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Tests
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bundle exec rake test
|
|
105
|
+
# or:
|
|
106
|
+
bundle exec ruby -Ilib -Itest test/parser_test.rb
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Releasing to RubyGems
|
|
110
|
+
|
|
111
|
+
For maintainers. The Rakefile pulls in `bundler/gem_tasks`, which gives you `rake build` and `rake release`.
|
|
112
|
+
|
|
113
|
+
One-time setup — sign in to RubyGems so credentials land in `~/.gem/credentials`:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
gem signin
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Cutting a new release:
|
|
120
|
+
|
|
121
|
+
1. Bump `Llv::VERSION` in `lib/llv/version.rb` (`llv.gemspec` reads from there, so it's the single source of truth).
|
|
122
|
+
2. Commit and push the version bump.
|
|
123
|
+
3. `bundle exec rake release` — this builds `pkg/llv-<version>.gem`, tags `v<version>` in git, pushes the tag, then pushes the gem to rubygems.org.
|
|
124
|
+
|
|
125
|
+
If you want to do it by hand instead:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
bundle exec rake build # writes pkg/llv-<version>.gem
|
|
129
|
+
gem push pkg/llv-<version>.gem
|
|
130
|
+
```
|
data/bin/llv
ADDED
data/lib/llv/ansi.rb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Llv
|
|
6
|
+
# Translates a small subset of ANSI SGR escape codes (the ones Rails actually
|
|
7
|
+
# emits in development.log) into either nothing (strip) or HTML spans (to_html).
|
|
8
|
+
module Ansi
|
|
9
|
+
SGR = /\e\[([0-9;]*)m/
|
|
10
|
+
|
|
11
|
+
FG = {
|
|
12
|
+
30 => "black", 31 => "red", 32 => "green", 33 => "yellow",
|
|
13
|
+
34 => "blue", 35 => "magenta", 36 => "cyan", 37 => "white",
|
|
14
|
+
90 => "bright-black", 91 => "bright-red", 92 => "bright-green",
|
|
15
|
+
93 => "bright-yellow", 94 => "bright-blue", 95 => "bright-magenta",
|
|
16
|
+
96 => "bright-cyan", 97 => "bright-white"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
BG = {
|
|
20
|
+
40 => "black", 41 => "red", 42 => "green", 43 => "yellow",
|
|
21
|
+
44 => "blue", 45 => "magenta", 46 => "cyan", 47 => "white"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def strip(str)
|
|
27
|
+
str.gsub(SGR, "")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Truncate `str` to at most `max_visible` printable characters, ignoring
|
|
31
|
+
# SGR escape codes for measurement and preserving every SGR encountered up
|
|
32
|
+
# to the cut so colours don't bleed past the truncation. Adds an ellipsis
|
|
33
|
+
# and a final reset when the string is shortened.
|
|
34
|
+
def truncate(str, max_visible)
|
|
35
|
+
return str if max_visible <= 0
|
|
36
|
+
return str if strip(str).length <= max_visible
|
|
37
|
+
|
|
38
|
+
out = +""
|
|
39
|
+
visible = 0
|
|
40
|
+
cut = false
|
|
41
|
+
|
|
42
|
+
scan(str) do |segment|
|
|
43
|
+
case segment
|
|
44
|
+
in [:sgr, codes]
|
|
45
|
+
out << "\e[#{codes.join(";")}m"
|
|
46
|
+
in [:text, text]
|
|
47
|
+
break if cut
|
|
48
|
+
|
|
49
|
+
remaining = max_visible - visible
|
|
50
|
+
if text.length <= remaining
|
|
51
|
+
out << text
|
|
52
|
+
visible += text.length
|
|
53
|
+
else
|
|
54
|
+
out << text[0, [remaining - 1, 0].max]
|
|
55
|
+
out << "…"
|
|
56
|
+
visible = max_visible
|
|
57
|
+
cut = true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
out << "\e[0m" if cut
|
|
63
|
+
out
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Turn raw text containing SGR codes into HTML. Adjacent text under the same
|
|
67
|
+
# state is wrapped in one <span class="..."> per state-change boundary.
|
|
68
|
+
def to_html(str)
|
|
69
|
+
out = +""
|
|
70
|
+
state = { bold: false, italic: false, underline: false, fg: nil, bg: nil }
|
|
71
|
+
open = false
|
|
72
|
+
|
|
73
|
+
scan(str) do |segment|
|
|
74
|
+
case segment
|
|
75
|
+
in [:text, text]
|
|
76
|
+
next if text.empty?
|
|
77
|
+
|
|
78
|
+
out << open_span(state) unless open || empty_state?(state)
|
|
79
|
+
open = true unless empty_state?(state)
|
|
80
|
+
out << CGI.escape_html(text)
|
|
81
|
+
in [:sgr, codes]
|
|
82
|
+
if open
|
|
83
|
+
out << "</span>"
|
|
84
|
+
open = false
|
|
85
|
+
end
|
|
86
|
+
apply(state, codes)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
out << "</span>" if open
|
|
91
|
+
out
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Yields [:text, "..."] and [:sgr, [int, ...]] segments in order.
|
|
95
|
+
def scan(str)
|
|
96
|
+
pos = 0
|
|
97
|
+
str.scan(SGR) do
|
|
98
|
+
match = Regexp.last_match
|
|
99
|
+
if match.begin(0) > pos
|
|
100
|
+
yield [:text, str[pos...match.begin(0)]]
|
|
101
|
+
end
|
|
102
|
+
codes = match[1].to_s.split(";").map { |c| c.empty? ? 0 : c.to_i }
|
|
103
|
+
codes = [0] if codes.empty?
|
|
104
|
+
yield [:sgr, codes]
|
|
105
|
+
pos = match.end(0)
|
|
106
|
+
end
|
|
107
|
+
yield [:text, str[pos..]] if pos < str.length
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def apply(state, codes)
|
|
111
|
+
i = 0
|
|
112
|
+
while i < codes.length
|
|
113
|
+
code = codes[i]
|
|
114
|
+
case code
|
|
115
|
+
when 0
|
|
116
|
+
state[:bold] = false
|
|
117
|
+
state[:italic] = false
|
|
118
|
+
state[:underline] = false
|
|
119
|
+
state[:fg] = nil
|
|
120
|
+
state[:bg] = nil
|
|
121
|
+
when 1 then state[:bold] = true
|
|
122
|
+
when 3 then state[:italic] = true
|
|
123
|
+
when 4 then state[:underline] = true
|
|
124
|
+
when 22 then state[:bold] = false
|
|
125
|
+
when 23 then state[:italic] = false
|
|
126
|
+
when 24 then state[:underline] = false
|
|
127
|
+
when 30..37, 90..97
|
|
128
|
+
state[:fg] = FG[code]
|
|
129
|
+
when 39
|
|
130
|
+
state[:fg] = nil
|
|
131
|
+
when 40..47
|
|
132
|
+
state[:bg] = BG[code]
|
|
133
|
+
when 49
|
|
134
|
+
state[:bg] = nil
|
|
135
|
+
end
|
|
136
|
+
i += 1
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def empty_state?(state)
|
|
141
|
+
!state[:bold] && !state[:italic] && !state[:underline] && state[:fg].nil? && state[:bg].nil?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def open_span(state)
|
|
145
|
+
classes = []
|
|
146
|
+
classes << "ansi-bold" if state[:bold]
|
|
147
|
+
classes << "ansi-italic" if state[:italic]
|
|
148
|
+
classes << "ansi-underline" if state[:underline]
|
|
149
|
+
classes << "ansi-fg-#{state[:fg]}" if state[:fg]
|
|
150
|
+
classes << "ansi-bg-#{state[:bg]}" if state[:bg]
|
|
151
|
+
%(<span class="#{classes.join(" ")}">)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/llv/cli.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Llv
|
|
6
|
+
class CLI
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
port: 9292,
|
|
9
|
+
host: "127.0.0.1",
|
|
10
|
+
limit: 500,
|
|
11
|
+
# Replay the whole file by default — when you launch llv you almost
|
|
12
|
+
# always want to see what's already there. Pass --tail to opt out.
|
|
13
|
+
from_start: true,
|
|
14
|
+
tui: false,
|
|
15
|
+
open_browser: true
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.start(argv)
|
|
19
|
+
new(argv).run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(argv)
|
|
23
|
+
@argv = argv.dup
|
|
24
|
+
@options = DEFAULTS.dup
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
parse!
|
|
29
|
+
path = @argv.shift
|
|
30
|
+
unless path
|
|
31
|
+
warn "usage: llv [options] PATH/TO/development.log"
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
store = GroupStore.new(limit: @options[:limit])
|
|
36
|
+
parser = Parser.new
|
|
37
|
+
tailer = Tailer.new(path, from_start: @options[:from_start])
|
|
38
|
+
|
|
39
|
+
# Tailer#each_line returns immediately after starting the listener +
|
|
40
|
+
# poll threads, so we don't need an extra wrapper thread.
|
|
41
|
+
tailer.each_line do |line|
|
|
42
|
+
event = parser.parse(line)
|
|
43
|
+
store.ingest(event)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@tailer = tailer
|
|
47
|
+
if @options[:tui]
|
|
48
|
+
require_relative "tui"
|
|
49
|
+
install_traps
|
|
50
|
+
Tui::Program.new(store: store).run
|
|
51
|
+
else
|
|
52
|
+
require_relative "web"
|
|
53
|
+
start_web(store)
|
|
54
|
+
end
|
|
55
|
+
ensure
|
|
56
|
+
@tailer&.stop
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install_traps
|
|
60
|
+
shutdown = lambda do |_signo = nil|
|
|
61
|
+
$stderr.puts "\nllv: stopping"
|
|
62
|
+
# Process.exit! is the only way out of Puma's Launcher loop once it has
|
|
63
|
+
# decided to "gracefully" stop and is blocked on its own threads.
|
|
64
|
+
Process.exit!(0)
|
|
65
|
+
end
|
|
66
|
+
Signal.trap("INT", &shutdown)
|
|
67
|
+
Signal.trap("TERM", &shutdown)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse!
|
|
73
|
+
OptionParser.new do |o|
|
|
74
|
+
o.banner = "usage: llv [options] PATH/TO/development.log"
|
|
75
|
+
o.on("--tui", "Use the terminal UI instead of the browser") { @options[:tui] = true }
|
|
76
|
+
o.on("--tail", "Only show lines appended after launch (default: replay the whole file first)") { @options[:from_start] = false }
|
|
77
|
+
o.on("--from-start", "Replay the entire log file (this is the default)") { @options[:from_start] = true }
|
|
78
|
+
o.on("--limit N", Integer, "Max number of items to keep in memory (default: 500)") { |n| @options[:limit] = n }
|
|
79
|
+
o.on("--port PORT", Integer, "Web server port (default: 9292)") { |p| @options[:port] = p }
|
|
80
|
+
o.on("--host HOST", "Web server host (default: 127.0.0.1)") { |h| @options[:host] = h }
|
|
81
|
+
o.on("--no-open", "Don't open the browser automatically") { @options[:open_browser] = false }
|
|
82
|
+
o.on("-h", "--help", "Show this help") do
|
|
83
|
+
puts o
|
|
84
|
+
exit
|
|
85
|
+
end
|
|
86
|
+
end.parse!(@argv)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def start_web(store)
|
|
90
|
+
Web.store = store
|
|
91
|
+
# Disable Sinatra's own INT/TERM traps. We can't stop Puma::Launcher from
|
|
92
|
+
# installing its handlers, so we run Puma on a background thread and
|
|
93
|
+
# install our own traps *after* Puma is up — last-trap-wins.
|
|
94
|
+
Web.set :traps, false
|
|
95
|
+
url = "http://#{@options[:host]}:#{@options[:port]}/"
|
|
96
|
+
puts "llv listening on #{url}"
|
|
97
|
+
|
|
98
|
+
server_thread = Thread.new do
|
|
99
|
+
Web.run!(host: @options[:host], port: @options[:port], quiet: true)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
$stderr.puts "llv web server error: #{e.class}: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Give Puma a moment to install its launcher signal handlers, then
|
|
105
|
+
# override them with ours.
|
|
106
|
+
sleep 0.5
|
|
107
|
+
install_traps
|
|
108
|
+
|
|
109
|
+
if @options[:open_browser]
|
|
110
|
+
Thread.new do
|
|
111
|
+
require "launchy"
|
|
112
|
+
Launchy.open(url)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
# the URL is printed above; user can open it manually
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
server_thread.join
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Llv
|
|
6
|
+
# Thread-safe store of grouped log items. Maintains insertion order
|
|
7
|
+
# newest-first, evicts oldest beyond `limit`, and publishes events to any
|
|
8
|
+
# number of subscribers (web SSE, TUI, tests).
|
|
9
|
+
class GroupStore
|
|
10
|
+
include MonitorMixin
|
|
11
|
+
|
|
12
|
+
Group = Struct.new(
|
|
13
|
+
:id, :kind, :title, :started_at, :finished_at, :status, :duration_ms, :lines,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
) do
|
|
16
|
+
def summary
|
|
17
|
+
{
|
|
18
|
+
id: id,
|
|
19
|
+
kind: kind,
|
|
20
|
+
title: title,
|
|
21
|
+
started_at: started_at,
|
|
22
|
+
finished_at: finished_at,
|
|
23
|
+
status: status,
|
|
24
|
+
duration_ms: duration_ms,
|
|
25
|
+
line_count: lines.length
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h_full
|
|
30
|
+
summary.merge(lines: lines.map(&:dup))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Line = Struct.new(:type, :payload, :raw, :plain, :at, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
def initialize(limit: 500)
|
|
37
|
+
super()
|
|
38
|
+
@limit = limit
|
|
39
|
+
@by_id = {}
|
|
40
|
+
@order = [] # newest first
|
|
41
|
+
@subscribers = []
|
|
42
|
+
@seq = 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ingest(event)
|
|
46
|
+
synchronize do
|
|
47
|
+
gid = event.group_id || "untagged"
|
|
48
|
+
new_group = !@by_id.key?(gid)
|
|
49
|
+
group = @by_id[gid] ||= build_group(gid, event)
|
|
50
|
+
|
|
51
|
+
if new_group
|
|
52
|
+
@order.unshift(gid)
|
|
53
|
+
enforce_limit
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
update_title(group, event)
|
|
57
|
+
update_lifecycle(group, event)
|
|
58
|
+
append_line(group, event)
|
|
59
|
+
|
|
60
|
+
broadcast(new_group ? :group_created : :group_updated, group)
|
|
61
|
+
broadcast(:group_completed, group) if event.type == :request_completed || event.type == :job_performed
|
|
62
|
+
group
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def list(limit: nil, kind: nil)
|
|
67
|
+
synchronize do
|
|
68
|
+
ids = @order
|
|
69
|
+
ids = ids.select { |id| @by_id[id].kind == kind } if kind
|
|
70
|
+
ids = ids.first(limit) if limit
|
|
71
|
+
ids.map { |id| @by_id[id].summary }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fetch(id)
|
|
76
|
+
synchronize { @by_id[id]&.to_h_full }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def subscribe(&block)
|
|
80
|
+
synchronize { @subscribers << block }
|
|
81
|
+
block
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def unsubscribe(handle)
|
|
85
|
+
synchronize { @subscribers.delete(handle) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def size
|
|
89
|
+
synchronize { @order.length }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def build_group(gid, event)
|
|
95
|
+
Group.new(
|
|
96
|
+
id: gid,
|
|
97
|
+
kind: event.group_kind,
|
|
98
|
+
title: event.group_title || default_title(event),
|
|
99
|
+
started_at: Time.now,
|
|
100
|
+
finished_at: nil,
|
|
101
|
+
status: nil,
|
|
102
|
+
duration_ms: nil,
|
|
103
|
+
lines: []
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def default_title(event)
|
|
108
|
+
case event.group_kind
|
|
109
|
+
when :http then "HTTP request"
|
|
110
|
+
when :job then "Job"
|
|
111
|
+
else "Untagged"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def update_title(group, event)
|
|
116
|
+
group.title = event.group_title if event.group_title && (group.title.nil? || group.title == default_title(event))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def update_lifecycle(group, event)
|
|
120
|
+
case event.type
|
|
121
|
+
when :request_started
|
|
122
|
+
group.started_at = Time.now
|
|
123
|
+
when :request_completed
|
|
124
|
+
group.finished_at = Time.now
|
|
125
|
+
group.status = event.payload[:status]
|
|
126
|
+
group.duration_ms = event.payload[:duration_ms]
|
|
127
|
+
when :job_performing
|
|
128
|
+
group.started_at = Time.now
|
|
129
|
+
when :job_performed
|
|
130
|
+
group.finished_at = Time.now
|
|
131
|
+
group.status = "ok"
|
|
132
|
+
group.duration_ms = event.payload[:duration_ms]
|
|
133
|
+
when :job_retry_stopped
|
|
134
|
+
group.status = "stopped"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def append_line(group, event)
|
|
139
|
+
group.lines << Line.new(
|
|
140
|
+
type: event.type,
|
|
141
|
+
payload: event.payload,
|
|
142
|
+
raw: event.raw,
|
|
143
|
+
plain: event.plain,
|
|
144
|
+
at: (@seq += 1)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def enforce_limit
|
|
149
|
+
while @order.length > @limit
|
|
150
|
+
evicted = @order.pop
|
|
151
|
+
@by_id.delete(evicted)
|
|
152
|
+
broadcast(:group_evicted, Group.new(id: evicted))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def broadcast(type, group)
|
|
157
|
+
@subscribers.each do |sub|
|
|
158
|
+
begin
|
|
159
|
+
sub.call(type, group)
|
|
160
|
+
rescue StandardError
|
|
161
|
+
# don't let one bad subscriber kill ingestion
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|