log_bench 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.
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ module Renderer
6
+ class RequestList
7
+ include Curses
8
+
9
+ # Layout constants
10
+ HEADER_Y_OFFSET = 2
11
+ COLUMN_HEADER_Y = 1
12
+ MIN_FILTER_X_POSITION = 20
13
+ FILTER_X_MARGIN = 3
14
+
15
+ # Column widths
16
+ METHOD_WIDTH = 8
17
+ STATUS_WIDTH = 8
18
+ PATH_MARGIN = 27
19
+
20
+ # Color constants
21
+ HEADER_CYAN = 1
22
+ DEFAULT_WHITE = 2
23
+ WARNING_YELLOW = 4
24
+ SELECTION_HIGHLIGHT = 10
25
+
26
+ def initialize(screen, state, scrollbar)
27
+ self.screen = screen
28
+ self.state = state
29
+ self.scrollbar = scrollbar
30
+ end
31
+
32
+ def draw
33
+ log_win.erase
34
+ log_win.box(0, 0)
35
+
36
+ draw_header
37
+ draw_column_headers
38
+ draw_rows
39
+ end
40
+
41
+ private
42
+
43
+ attr_accessor :screen, :state, :scrollbar
44
+
45
+ def draw_header
46
+ log_win.setpos(0, HEADER_Y_OFFSET)
47
+
48
+ if state.left_pane_focused?
49
+ log_win.attron(color_pair(HEADER_CYAN) | A_BOLD) { log_win.addstr(" Request Logs ") }
50
+ else
51
+ log_win.attron(color_pair(DEFAULT_WHITE) | A_DIM) { log_win.addstr(" Request Logs ") }
52
+ end
53
+
54
+ show_filter_in_header if show_filter?
55
+ end
56
+
57
+ def show_filter?
58
+ state.main_filter.present? || state.main_filter.active?
59
+ end
60
+
61
+ def show_filter_in_header
62
+ filter_text = "Filter: #{state.main_filter.cursor_display}"
63
+ filter_x = log_win.maxx - filter_text.length - FILTER_X_MARGIN
64
+
65
+ if filter_x > MIN_FILTER_X_POSITION
66
+ log_win.setpos(0, filter_x)
67
+ log_win.attron(color_pair(WARNING_YELLOW)) { log_win.addstr(filter_text) }
68
+ end
69
+ end
70
+
71
+ def draw_column_headers
72
+ log_win.setpos(COLUMN_HEADER_Y, HEADER_Y_OFFSET)
73
+ log_win.attron(color_pair(HEADER_CYAN) | A_DIM) do
74
+ log_win.addstr("METHOD".ljust(METHOD_WIDTH))
75
+ log_win.addstr("PATH".ljust(screen.panel_width - PATH_MARGIN))
76
+ log_win.addstr("STATUS".ljust(STATUS_WIDTH))
77
+ log_win.addstr("TIME")
78
+ end
79
+ end
80
+
81
+ def draw_rows
82
+ filtered_requests = state.filtered_requests
83
+ visible_height = log_win.maxy - 3
84
+
85
+ return draw_no_requests_message if filtered_requests.empty?
86
+
87
+ state.adjust_auto_scroll(visible_height)
88
+ state.adjust_scroll_bounds(visible_height)
89
+
90
+ visible_height.times do |i|
91
+ request_index = state.scroll_offset + i
92
+ break if request_index >= filtered_requests.size
93
+
94
+ draw_row(filtered_requests[request_index], request_index, i + 2)
95
+ end
96
+
97
+ # Draw scrollbar if needed
98
+ if filtered_requests.size > visible_height
99
+ scrollbar.draw(log_win, visible_height, state.scroll_offset, filtered_requests.size)
100
+ end
101
+ end
102
+
103
+ def draw_no_requests_message
104
+ log_win.setpos(log_win.maxy / 2, 3)
105
+ log_win.attron(A_DIM) { log_win.addstr("No requests found") }
106
+ end
107
+
108
+ def draw_row(request, request_index, y_position)
109
+ log_win.setpos(y_position, 1)
110
+
111
+ if request_index == state.selected
112
+ log_win.attron(color_pair(10) | A_DIM) do
113
+ log_win.addstr(" " * (screen.panel_width - 4))
114
+ end
115
+ log_win.setpos(y_position, 1)
116
+ end
117
+
118
+ draw_method_badge(request, request_index)
119
+ draw_path_column(request, request_index)
120
+ draw_status_column(request, request_index)
121
+ draw_duration_column(request, request_index)
122
+ end
123
+
124
+ def draw_method_badge(request, request_index)
125
+ method_color = method_color_for(request.method)
126
+
127
+ if request_index == state.selected
128
+ log_win.attron(color_pair(10) | A_DIM) do
129
+ log_win.addstr(" #{request.method.ljust(7)} ")
130
+ end
131
+ else
132
+ log_win.attron(color_pair(method_color) | A_BOLD) do
133
+ log_win.addstr(" #{request.method.ljust(7)} ")
134
+ end
135
+ end
136
+ end
137
+
138
+ def draw_path_column(request, request_index)
139
+ path_width = screen.panel_width - 27
140
+ path = request.path[0, path_width] || ""
141
+
142
+ if request_index == state.selected
143
+ log_win.attron(color_pair(10) | A_DIM) do
144
+ log_win.addstr(path.ljust(path_width))
145
+ end
146
+ else
147
+ log_win.addstr(path.ljust(path_width))
148
+ end
149
+ end
150
+
151
+ def draw_status_column(request, request_index)
152
+ status_col_start = screen.panel_width - 14
153
+
154
+ if request.status
155
+ status_color = status_color_for(request.status)
156
+ status_text = request.status.to_s.rjust(3)
157
+
158
+ log_win.setpos(log_win.cury, status_col_start)
159
+ if request_index == state.selected
160
+ log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(status_text + " ") }
161
+ else
162
+ log_win.attron(color_pair(status_color)) { log_win.addstr(status_text + " ") }
163
+ end
164
+ end
165
+ end
166
+
167
+ def draw_duration_column(request, request_index)
168
+ duration_col_start = screen.panel_width - 9
169
+
170
+ if request.duration
171
+ duration_text = "#{request.duration.to_i}ms".ljust(6)
172
+
173
+ log_win.setpos(log_win.cury, duration_col_start)
174
+ if request_index == state.selected
175
+ log_win.attron(color_pair(10) | A_DIM) { log_win.addstr(duration_text + " ") }
176
+ else
177
+ log_win.attron(A_DIM) { log_win.addstr(duration_text + " ") }
178
+ end
179
+ end
180
+ end
181
+
182
+ def method_color_for(method)
183
+ case method
184
+ when "GET" then 3
185
+ when "POST" then 4
186
+ when "PUT" then 5
187
+ when "DELETE" then 6
188
+ else 2
189
+ end
190
+ end
191
+
192
+ def status_color_for(status)
193
+ case status
194
+ when 200..299 then 3
195
+ when 300..399 then 4
196
+ when 400..599 then 6
197
+ else 2
198
+ end
199
+ end
200
+
201
+ def color_pair(n)
202
+ screen.color_pair(n)
203
+ end
204
+
205
+ def log_win
206
+ screen.log_win
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ module Renderer
6
+ class Scrollbar
7
+ include Curses
8
+
9
+ def initialize(screen)
10
+ self.screen = screen
11
+ end
12
+
13
+ def draw(win, height, offset, total)
14
+ return if total <= height
15
+
16
+ scrollbar_height = [(height * height / total), 1].max
17
+ scrollbar_pos = offset * height / total
18
+
19
+ x = win.maxx - 2 # Position scrollbar closer to the border
20
+ height.times do |i|
21
+ win.setpos(i + 1, x)
22
+ if i >= scrollbar_pos && i < scrollbar_pos + scrollbar_height
23
+ win.attron(color_pair(1)) { win.addstr("█") } # Solid block for scrollbar thumb
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ attr_accessor :screen
31
+
32
+ def color_pair(n)
33
+ screen.color_pair(n)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ class Screen
6
+ include Curses
7
+
8
+ # Layout constants
9
+ HEADER_HEIGHT = 5
10
+ PANEL_BORDER_WIDTH = 3
11
+ INPUT_TIMEOUT_MS = 200
12
+
13
+ # Color pairs
14
+ HEADER_CYAN = 1
15
+ DEFAULT_WHITE = 2
16
+ SUCCESS_GREEN = 3 # GET requests, 200 status
17
+ WARNING_YELLOW = 4 # POST requests, warnings
18
+ INFO_BLUE = 5 # PUT requests
19
+ ERROR_RED = 6 # DELETE requests, errors
20
+ BRIGHT_WHITE = 7
21
+ BLACK = 8
22
+ MAGENTA = 9
23
+ SELECTION_HIGHLIGHT = 10
24
+
25
+ attr_reader :header_win, :log_win, :panel_width, :detail_win
26
+
27
+ def setup
28
+ init_screen
29
+ setup_colors
30
+ clear_screen_immediately
31
+ setup_windows
32
+ end
33
+
34
+ def cleanup
35
+ close_screen
36
+ end
37
+
38
+ def refresh_all
39
+ header_win.refresh
40
+ log_win.refresh
41
+ detail_win.refresh
42
+ refresh
43
+ end
44
+
45
+ def height
46
+ lines
47
+ end
48
+
49
+ def width
50
+ cols
51
+ end
52
+
53
+ def color_pair(n)
54
+ Curses.color_pair(n)
55
+ end
56
+
57
+ private
58
+
59
+ attr_writer :header_win, :log_win, :panel_width, :detail_win
60
+
61
+ def clear_screen_immediately
62
+ clear
63
+ refresh
64
+ end
65
+
66
+ def setup_colors
67
+ start_color
68
+ cbreak
69
+ noecho
70
+ curs_set(0)
71
+ stdscr.keypad(true)
72
+ stdscr.timeout = INPUT_TIMEOUT_MS
73
+
74
+ # Define color pairs
75
+ init_pair(HEADER_CYAN, COLOR_CYAN, COLOR_BLACK) # Header/Cyan
76
+ init_pair(DEFAULT_WHITE, COLOR_WHITE, COLOR_BLACK) # Default/White
77
+ init_pair(SUCCESS_GREEN, COLOR_GREEN, COLOR_BLACK) # GET/Success/Green
78
+ init_pair(WARNING_YELLOW, COLOR_YELLOW, COLOR_BLACK) # POST/Warning/Yellow
79
+ init_pair(INFO_BLUE, COLOR_BLUE, COLOR_BLACK) # PUT/Blue
80
+ init_pair(ERROR_RED, COLOR_RED, COLOR_BLACK) # DELETE/Error/Red
81
+ init_pair(BRIGHT_WHITE, COLOR_WHITE, COLOR_BLACK) # Bold/Bright white
82
+ init_pair(BLACK, COLOR_BLACK, COLOR_BLACK) # Black
83
+ init_pair(MAGENTA, COLOR_MAGENTA, COLOR_BLACK) # Magenta
84
+ init_pair(SELECTION_HIGHLIGHT, COLOR_BLACK, COLOR_CYAN) # Selection highlighting
85
+ end
86
+
87
+ def setup_windows
88
+ self.panel_width = width / 2 - 2
89
+
90
+ self.header_win = Window.new(HEADER_HEIGHT, width, 0, 0)
91
+ self.log_win = Window.new(height - HEADER_HEIGHT, panel_width, HEADER_HEIGHT, 0)
92
+ self.detail_win = Window.new(height - HEADER_HEIGHT, width - panel_width - PANEL_BORDER_WIDTH, HEADER_HEIGHT, panel_width + PANEL_BORDER_WIDTH)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,61 @@
1
+ module LogBench
2
+ module App
3
+ class Sort
4
+ MODES = [:timestamp, :duration, :method, :status].freeze
5
+
6
+ def initialize
7
+ self.mode = :timestamp
8
+ end
9
+
10
+ def cycle
11
+ current_index = MODES.index(mode)
12
+ next_index = (current_index + 1) % MODES.length
13
+ self.mode = MODES[next_index]
14
+ end
15
+
16
+ def display_name
17
+ case mode
18
+ when :timestamp then "TIMESTAMP"
19
+ when :duration then "DURATION"
20
+ when :method then "METHOD"
21
+ when :status then "STATUS"
22
+ end
23
+ end
24
+
25
+ def sort_requests(requests)
26
+ case mode
27
+ when :timestamp
28
+ requests.sort_by { |req| req.timestamp || Time.at(0) }
29
+ when :duration
30
+ requests.sort_by { |req| -(req.duration || 0) } # Descending (slowest first)
31
+ when :method
32
+ requests.sort_by { |req| req.method || "" }
33
+ when :status
34
+ requests.sort_by { |req| -(req.status || 0) } # Descending (errors first)
35
+ else
36
+ requests
37
+ end
38
+ end
39
+
40
+ def timestamp?
41
+ mode == :timestamp
42
+ end
43
+
44
+ def duration?
45
+ mode == :duration
46
+ end
47
+
48
+ def method?
49
+ mode == :method
50
+ end
51
+
52
+ def status?
53
+ mode == :status
54
+ end
55
+
56
+ private
57
+
58
+ attr_accessor :mode
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module App
5
+ class State
6
+ attr_reader :main_filter, :sort, :detail_filter
7
+ attr_accessor :requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset
8
+
9
+ def initialize
10
+ self.requests = []
11
+ self.selected = 0
12
+ self.scroll_offset = 0
13
+ self.auto_scroll = true
14
+ self.running = true
15
+ self.focused_pane = :left
16
+ self.detail_scroll_offset = 0
17
+ self.main_filter = Filter.new
18
+ self.detail_filter = Filter.new
19
+ self.sort = Sort.new
20
+ end
21
+
22
+ def running?
23
+ running
24
+ end
25
+
26
+ def stop!
27
+ self.running = false
28
+ end
29
+
30
+ def toggle_auto_scroll
31
+ self.auto_scroll = !auto_scroll
32
+ end
33
+
34
+ def clear_filter
35
+ main_filter.clear
36
+ self.selected = 0
37
+ self.scroll_offset = 0
38
+ end
39
+
40
+ def clear_detail_filter
41
+ detail_filter.clear
42
+ self.detail_scroll_offset = 0
43
+ end
44
+
45
+ def cycle_sort_mode
46
+ sort.cycle
47
+ end
48
+
49
+ def switch_to_left_pane
50
+ self.focused_pane = :left
51
+ end
52
+
53
+ def switch_to_right_pane
54
+ self.focused_pane = :right
55
+ end
56
+
57
+ def left_pane_focused?
58
+ focused_pane == :left
59
+ end
60
+
61
+ def right_pane_focused?
62
+ focused_pane == :right
63
+ end
64
+
65
+ def enter_filter_mode
66
+ if left_pane_focused?
67
+ main_filter.enter_mode
68
+ else
69
+ detail_filter.enter_mode
70
+ end
71
+ end
72
+
73
+ def exit_filter_mode
74
+ main_filter.exit_mode
75
+ detail_filter.exit_mode
76
+ end
77
+
78
+ def add_to_filter(char)
79
+ if main_filter.active?
80
+ main_filter.add_character(char)
81
+ elsif detail_filter.active?
82
+ detail_filter.add_character(char)
83
+ end
84
+ end
85
+
86
+ def backspace_filter
87
+ if main_filter.active?
88
+ main_filter.remove_character
89
+ elsif detail_filter.active?
90
+ detail_filter.remove_character
91
+ end
92
+ end
93
+
94
+ def filter_mode
95
+ main_filter.active?
96
+ end
97
+
98
+ def detail_filter_mode
99
+ detail_filter.active?
100
+ end
101
+
102
+ def filtered_requests
103
+ # First apply filter - match exactly like original logtail
104
+ filtered = if main_filter.present?
105
+ requests.select do |req|
106
+ main_filter.matches?(req.path) ||
107
+ main_filter.matches?(req.method) ||
108
+ main_filter.matches?(req.controller) ||
109
+ main_filter.matches?(req.action) ||
110
+ main_filter.matches?(req.request_id)
111
+ end
112
+ else
113
+ requests
114
+ end
115
+
116
+ # Then apply sorting using Sort domain object
117
+ sort.sort_requests(filtered)
118
+ end
119
+
120
+ def current_request
121
+ filtered = filtered_requests
122
+ return nil if selected >= filtered.size || filtered.empty?
123
+
124
+ filtered[selected]
125
+ end
126
+
127
+ def navigate_up
128
+ if left_pane_focused?
129
+ self.selected = [selected - 1, 0].max
130
+ self.auto_scroll = false
131
+ else
132
+ self.detail_scroll_offset = [detail_scroll_offset - 1, 0].max
133
+ end
134
+ end
135
+
136
+ def navigate_down
137
+ if left_pane_focused?
138
+ max_index = filtered_requests.size - 1
139
+ self.selected = [selected + 1, max_index].min
140
+ self.auto_scroll = false
141
+ else
142
+ self.detail_scroll_offset += 1
143
+ end
144
+ end
145
+
146
+ def adjust_scroll_for_selection(visible_height)
147
+ return unless left_pane_focused?
148
+
149
+ if selected < scroll_offset
150
+ self.scroll_offset = selected
151
+ elsif selected >= scroll_offset + visible_height
152
+ self.scroll_offset = selected - visible_height + 1
153
+ end
154
+ end
155
+
156
+ def adjust_auto_scroll(visible_height)
157
+ return unless auto_scroll && !filtered_requests.empty?
158
+
159
+ self.selected = filtered_requests.size - 1
160
+ self.scroll_offset = [selected - visible_height + 1, 0].max
161
+ end
162
+
163
+ def adjust_scroll_bounds(visible_height)
164
+ filtered = filtered_requests
165
+ max_offset = [filtered.size - visible_height, 0].max
166
+ self.scroll_offset = scroll_offset.clamp(0, max_offset)
167
+ end
168
+
169
+ private
170
+
171
+ attr_accessor :focused_pane, :running
172
+ attr_writer :main_filter, :detail_filter, :sort
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LogBench
6
+ # A simple JSON formatter for Rails loggers that creates LogBench-compatible entries
7
+ # for non-request logging (background jobs, custom events, etc.)
8
+ #
9
+ # Usage:
10
+ # logger = Logger.new(Rails.root.join('log', 'custom.log'))
11
+ # logger.formatter = LogBench::JsonFormatter.new
12
+ # logger.info("Background job completed", job_id: 123)
13
+ #
14
+ class JsonFormatter < ::Logger::Formatter
15
+ def call(severity, timestamp, progname, message)
16
+ log_entry = build_log_entry(severity, timestamp, progname, message)
17
+ log_entry.to_json + "\n"
18
+ rescue
19
+ # Fallback to simple format if JSON generation fails
20
+ "#{timestamp} [#{severity}] #{progname}: #{message}\n"
21
+ end
22
+
23
+ private
24
+
25
+ def build_log_entry(severity, timestamp, progname, message)
26
+ entry = message_to_hash(message)
27
+ entry = parse_lograge_message(entry[:message]) if lograge_message?(entry)
28
+ request_id = current_request_id
29
+
30
+ entry.merge!(
31
+ level: severity,
32
+ timestamp: timestamp.utc.iso8601(3),
33
+ time: timestamp.to_f,
34
+ request_id: request_id,
35
+ progname: progname
36
+ ).compact
37
+ end
38
+
39
+ def message_to_hash(message)
40
+ case message
41
+ when String
42
+ {message: message}
43
+ when Hash
44
+ message.dup
45
+ when Exception
46
+ {
47
+ message: "#{message.class}: #{message.message}",
48
+ error_class: message.class.name,
49
+ error_message: message.message
50
+ }
51
+ else
52
+ {message: message.to_s}
53
+ end
54
+ end
55
+
56
+ def lograge_message?(entry)
57
+ # Check if the message field contains lograge JSON
58
+ return false unless entry[:message].is_a?(String) && entry[:message].start_with?("{")
59
+
60
+ # Try to parse and check for lograge fields
61
+ begin
62
+ parsed = JSON.parse(entry[:message])
63
+ parsed.is_a?(Hash) && parsed["method"] && parsed["path"] && parsed["status"]
64
+ rescue JSON::ParserError
65
+ false
66
+ end
67
+ end
68
+
69
+ def parse_lograge_message(message_string)
70
+ # Parse the lograge JSON string
71
+ JSON.parse(message_string)
72
+ rescue JSON::ParserError
73
+ nil
74
+ end
75
+
76
+ def current_request_id
77
+ # Try multiple ways to get the current request ID
78
+ request_id = nil
79
+
80
+ if defined?(Current) && Current.respond_to?(:request_id)
81
+ request_id = Current.request_id
82
+ elsif defined?(RequestStore) && RequestStore.exist?(:request_id)
83
+ request_id = RequestStore.read(:request_id)
84
+ elsif Thread.current[:request_id]
85
+ request_id = Thread.current[:request_id]
86
+ end
87
+
88
+ # Return nil if no request ID found - let caller decide whether to generate UUID
89
+ request_id
90
+ end
91
+ end
92
+ end