log_bench 0.4.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 617bb4c585973c40149ffc5f58eba322446aa997a324515517269af591e88c02
4
- data.tar.gz: 613d94f2d455abd75a523ebbf0f3c8aeafd5993657f5f4281c8d841c9e8af097
3
+ metadata.gz: e09eee0378110cca913cb1611c4b299a874dc025c7be07fcde2e89a48be8c010
4
+ data.tar.gz: 92bb7b5373652fb947534380d996ce052099e301968c28d3e44b878a1cdce180
5
5
  SHA512:
6
- metadata.gz: 57aac276e8b98a7551b2f4dfa75df88c21723eb798aeedbd338d2323346902eaef3439a9a338dd4dfd6cd528361d3ebc950f404997a7a357f2643b7f0611a3cf
7
- data.tar.gz: e1ee5e4e719aff224c76a3485212df6a91aa1565ad192e834a92d51040a3b10a3055222fec79129c73af96d3f5692c1b8c1b7547b0cfffed54f02bffe1bc308b
6
+ metadata.gz: f75eb599a35ef1717eda389dbd2919134374922d14b6c4ff1b1c8b98c6f13138feec808b458aea78be882e557cb5a058142dbdf2717386347e3d6ad57e70e208
7
+ data.tar.gz: 9564662777ca62c550ec723240a25e5ff97f968734cac13845810ed8a207d5dec296d8ef1bbf2f2c193b7d8dc603ea14ab7fa2f6a21de6072275f9917a958991
data/README.md CHANGED
@@ -187,6 +187,82 @@ LogBench works with JSON-formatted logs. Each log entry should include:
187
187
  - `message`: SQL query with timing information
188
188
  - `request_id`: Links query to HTTP request
189
189
 
190
+ ## Job Logging
191
+
192
+ LogBench provides enhanced logging for background jobs with colored job prefixes in the TUI. Job logs are automatically prefixed with `[JobClass#job-id]` in orange/yellow color for easy identification.
193
+
194
+ ### ActiveJob
195
+
196
+ For **ActiveJob** classes, use the job's `logger` method (not `Rails.logger`) to ensure logs get proper job context:
197
+
198
+ ```ruby
199
+ class EmailDeliveryJob < ApplicationJob
200
+ def perform(user_id)
201
+ logger.info "Starting email delivery for user #{user_id}" # ✅ Will have job prefix
202
+
203
+ user = User.find(user_id)
204
+ logger.info "Found user: #{user.email}" # ✅ Will have job prefix
205
+
206
+ # SQL queries are automatically tagged
207
+ user.update!(last_email_sent_at: Time.current) # ✅ Will have job prefix
208
+
209
+ logger.info "Email delivery completed" # ✅ Will have job prefix
210
+ end
211
+ end
212
+ ```
213
+
214
+ **❌ Don't use `Rails.logger` in ActiveJob:**
215
+ ```ruby
216
+ class EmailDeliveryJob < ApplicationJob
217
+ def perform(user_id)
218
+ Rails.logger.info "This won't have job prefix" # ❌ No job context
219
+ end
220
+ end
221
+ ```
222
+
223
+ ### Plain Sidekiq Jobs
224
+
225
+ For **plain Sidekiq** jobs (not using ActiveJob), LogBench automatically captures job context:
226
+
227
+ ```ruby
228
+ class DataProcessingJob
229
+ include Sidekiq::Job
230
+
231
+ def perform(data_id)
232
+ Rails.logger.info "Processing data #{data_id}" # ✅ Will have job prefix
233
+
234
+ # SQL queries are automatically tagged
235
+ data = Data.find(data_id) # ✅ Will have job prefix
236
+ data.process!
237
+
238
+ Rails.logger.info "Data processing completed" # ✅ Will have job prefix
239
+ end
240
+ end
241
+ ```
242
+
243
+ ### Job Log Output
244
+
245
+ In the TUI, job-related logs appear with colored prefixes:
246
+
247
+ ```
248
+ 🟡[EmailDeliveryJob#email-job-123] Starting email delivery for user 456
249
+ 🟡[EmailDeliveryJob#email-job-123] Found user: user@example.com
250
+ 🟡[EmailDeliveryJob#email-job-123] User Load (1.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1
251
+ 🟡[EmailDeliveryJob#email-job-123] Email delivery completed
252
+ ```
253
+
254
+ *(🟡 represents yellow bold colored job name, consistent with Rails SQL query coloring)*
255
+
256
+ ### Job Context Detection
257
+
258
+ LogBench automatically detects job context through:
259
+
260
+ - **ActiveJob**: Uses Rails' tagged logging system with job class and job ID
261
+ - **Plain Sidekiq**: Uses LogBench::Current attributes set by Sidekiq middleware
262
+ - **SQL Queries**: Inherit job context from the current request/job execution
263
+
264
+ No additional configuration is required - job logging works automatically once LogBench is installed.
265
+
190
266
  ## Testing
191
267
 
192
268
  LogBench includes a comprehensive test suite to ensure reliability and correctness.
@@ -19,7 +19,7 @@ module LogBench
19
19
 
20
20
  def initialize(log_file_path = "log/development.log")
21
21
  self.log_file_path = find_log_file(log_file_path)
22
- self.state = State.new
22
+ self.state = State.instance
23
23
  validate_log_file!
24
24
  end
25
25
 
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "singleton"
4
+
3
5
  module LogBench
4
6
  module App
5
7
  class State
8
+ include Singleton
9
+
6
10
  attr_reader :main_filter, :sort, :detail_filter, :cleared_requests
7
11
  attr_accessor :requests, :orphan_requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset, :detail_selected_entry, :text_selection_mode, :update_available, :update_version
8
12
 
9
13
  def initialize
14
+ reset!
15
+ end
16
+
17
+ def reset!
10
18
  self.requests = []
11
19
  self.orphan_requests = []
12
20
  self.selected = 0
@@ -23,6 +31,7 @@ module LogBench
23
31
  self.update_available = false
24
32
  self.update_version = nil
25
33
  self.cleared_requests = nil
34
+ self.job_ids_map = {}
26
35
  end
27
36
 
28
37
  def running?
@@ -280,9 +289,17 @@ module LogBench
280
289
  end
281
290
  end
282
291
 
292
+ def register_job_enqueue(job_id, request_id)
293
+ job_ids_map[job_id] = request_id
294
+ end
295
+
296
+ def request_id_for_job(job_id)
297
+ job_ids_map[job_id]
298
+ end
299
+
283
300
  private
284
301
 
285
- attr_accessor :focused_pane, :running
302
+ attr_accessor :focused_pane, :running, :job_ids_map
286
303
  attr_writer :main_filter, :detail_filter, :sort, :cleared_requests
287
304
  end
288
305
  end
@@ -3,7 +3,7 @@
3
3
  if defined?(ActiveSupport::CurrentAttributes)
4
4
  module LogBench
5
5
  class Current < ActiveSupport::CurrentAttributes
6
- attribute :request_id
6
+ attribute :request_id, :jid, :job_class
7
7
  end
8
8
  end
9
9
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ # Shared module for formatting job prefixes with ANSI colors
5
+ # Used by both JsonFormatter (when writing logs) and Parser (when reading old logs)
6
+ module JobPrefixFormatter
7
+ # Job color palette - using standard colors only (no bright colors)
8
+ JOB_COLORS = [
9
+ 31, # Red
10
+ 32, # Green
11
+ 33, # Yellow
12
+ 34, # Blue
13
+ 35, # Magenta
14
+ 36 # Cyan
15
+ ].freeze
16
+
17
+ # Extract job info from ActiveJob tags
18
+ # Returns [job_id, job_class] or nil if not a valid ActiveJob tag
19
+ def extract_job_info_from_tags(tags)
20
+ return nil unless tags.is_a?(Array) && tags.size >= 3
21
+ return nil unless tags[0] == "ActiveJob"
22
+
23
+ # ActiveJob tags format: ["ActiveJob", "JobClassName", "job-id"]
24
+ job_class = tags[1]
25
+ job_id = tags[2]
26
+ return nil unless job_class && job_id
27
+
28
+ [job_id, job_class]
29
+ end
30
+
31
+ # Build colored job prefix using ANSI color codes
32
+ def build_colored_job_prefix(job_class, job_id)
33
+ # Pick a color based on the job ID for visual differentiation
34
+ color_code = pick_job_color(job_id)
35
+ "\u001b[1m\u001b[#{color_code}m[#{job_class}##{job_id}]\u001b[0m"
36
+ end
37
+
38
+ # Pick a consistent color for a job based on its ID
39
+ def pick_job_color(job_id)
40
+ # Use a simple hash of the job ID to pick a consistent color
41
+ # This ensures the same job ID always gets the same color
42
+ hash = job_id.to_s.bytes.sum
43
+ JOB_COLORS[hash % JOB_COLORS.length]
44
+ end
45
+ end
46
+ end
@@ -8,6 +8,7 @@ module LogBench
8
8
  # JSON logs. Extends TaggedLogging::Formatter for full Rails compatibility.
9
9
  class JsonFormatter < ::Logger::Formatter
10
10
  include ActiveSupport::TaggedLogging::Formatter
11
+ include JobPrefixFormatter
11
12
 
12
13
  def call(severity, timestamp, progname, message)
13
14
  log_entry = build_log_entry(severity, timestamp, progname, message)
@@ -25,6 +26,15 @@ module LogBench
25
26
  entry = parse_lograge_message(entry[:message]) if lograge_message?(entry)
26
27
  request_id = current_request_id
27
28
 
29
+ # Get job info from Current attributes (direct Sidekiq jobs) or tags (ActiveJob)
30
+ job_id, job_class = get_job_info(tags)
31
+
32
+ # Add colored job prefix to message if we're in a job context
33
+ if job_id && job_class && entry[:message]
34
+ job_prefix = build_colored_job_prefix(job_class, job_id)
35
+ entry[:message] = "#{job_prefix} #{entry[:message]}"
36
+ end
37
+
28
38
  base_entry = {
29
39
  level: severity,
30
40
  timestamp: timestamp.utc.iso8601(3),
@@ -74,19 +84,50 @@ module LogBench
74
84
  end
75
85
 
76
86
  def current_request_id
77
- request_id = nil
78
-
79
- if defined?(LogBench::Current) && LogBench::Current.respond_to?(:request_id)
80
- request_id = LogBench::Current.request_id
81
- elsif defined?(Current) && Current.respond_to?(:request_id)
82
- request_id = Current.request_id
83
- elsif defined?(RequestStore) && RequestStore.exist?(:request_id)
84
- request_id = RequestStore.read(:request_id)
85
- elsif Thread.current[:request_id]
86
- request_id = Thread.current[:request_id]
87
+ get_current_attribute(:request_id)
88
+ end
89
+
90
+ def current_jid
91
+ get_current_attribute(:jid)
92
+ end
93
+
94
+ def current_job_class
95
+ get_current_attribute(:job_class)
96
+ end
97
+
98
+ # Generic method to get current attributes from various storage mechanisms
99
+ def get_current_attribute(attribute_name)
100
+ # Try LogBench::Current first (preferred)
101
+ if defined?(LogBench::Current) && LogBench::Current.respond_to?(attribute_name)
102
+ return LogBench::Current.public_send(attribute_name)
103
+ end
104
+
105
+ # Try Current (fallback for apps that define their own Current)
106
+ if defined?(Current) && Current.respond_to?(attribute_name)
107
+ return Current.public_send(attribute_name)
108
+ end
109
+
110
+ # Try RequestStore (for apps using request_store gem)
111
+ if defined?(RequestStore) && RequestStore.exist?(attribute_name)
112
+ return RequestStore.read(attribute_name)
113
+ end
114
+
115
+ # Try Thread local storage (last resort)
116
+ Thread.current[attribute_name]
117
+ end
118
+
119
+ # Get job info from Current attributes (direct Sidekiq jobs) or tags (ActiveJob)
120
+ def get_job_info(tags)
121
+ # First try Current attributes (for direct Sidekiq jobs)
122
+ current_jid = get_current_attribute(:jid)
123
+ current_job_class = get_current_attribute(:job_class)
124
+
125
+ if current_jid && current_job_class
126
+ return [current_jid, current_job_class]
87
127
  end
88
128
 
89
- request_id
129
+ # Fallback to tags (for ActiveJob)
130
+ extract_job_info_from_tags(tags)
90
131
  end
91
132
  end
92
133
  end
@@ -8,6 +8,7 @@ module LogBench
8
8
  attr_accessor :entries
9
9
 
10
10
  def initialize(input)
11
+ self.parsed_entries = nil
11
12
  self.entries = parse_input(input)
12
13
  end
13
14
 
@@ -59,6 +60,8 @@ module LogBench
59
60
 
60
61
  private
61
62
 
63
+ attr_accessor :parsed_entries
64
+
62
65
  def create_collection_from_requests(requests)
63
66
  new_collection = self.class.new([])
64
67
  new_collection.entries = requests
@@ -67,7 +70,7 @@ module LogBench
67
70
 
68
71
  def parse_input(input)
69
72
  lines = normalize_input(input)
70
- parsed_entries = Parser.parse_lines(lines)
73
+ self.parsed_entries = Parser.parse_lines(lines)
71
74
  Parser.group_by_request(parsed_entries)
72
75
  end
73
76
 
@@ -3,7 +3,7 @@
3
3
  module LogBench
4
4
  module Log
5
5
  class Entry
6
- attr_reader :type, :raw_line, :request_id, :timestamp, :content, :timing
6
+ attr_reader :type, :raw_line, :request_id, :timestamp, :content, :timing, :json_data
7
7
 
8
8
  def initialize(json_data)
9
9
  self.json_data = json_data
@@ -23,8 +23,7 @@ module LogBench
23
23
 
24
24
  private
25
25
 
26
- attr_writer :type, :timestamp, :request_id, :content, :timing
27
- attr_accessor :json_data
26
+ attr_writer :type, :timestamp, :request_id, :content, :timing, :json_data
28
27
 
29
28
  def parse_timestamp(timestamp_str)
30
29
  return Time.now unless timestamp_str
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class JobEnqueueEntry < Entry
6
+ attr_reader :job_id
7
+
8
+ def initialize(json_data)
9
+ super
10
+ self.type = :job_enqueue
11
+ self.job_id = extract_job_id
12
+ end
13
+
14
+ private
15
+
16
+ attr_writer :job_id
17
+
18
+ def extract_job_id
19
+ Parser.extract_job_id_from_enqueue(content)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -3,12 +3,17 @@
3
3
  module LogBench
4
4
  module Log
5
5
  class Parser
6
+ extend JobPrefixFormatter
7
+
6
8
  def self.parse_line(raw_line)
7
9
  clean_line = raw_line.encode("UTF-8", invalid: :replace, undef: :replace, replace: "").strip
8
10
  data = JSON.parse(clean_line)
9
11
  return unless data.is_a?(Hash)
10
12
 
11
- build_specific_entry(data)
13
+ entry = build_specific_entry(data)
14
+ register_job_enqueue(entry)
15
+ enrich_job_entry(entry)
16
+ entry
12
17
  rescue JSON::ParserError
13
18
  nil
14
19
  end
@@ -32,6 +37,8 @@ module LogBench
32
37
  QueryEntry.new(data, cached: true)
33
38
  when :sql_call_line
34
39
  CallLineEntry.new(data)
40
+ when :job_enqueue
41
+ JobEnqueueEntry.new(data)
35
42
  else
36
43
  Entry.new(data)
37
44
  end
@@ -67,6 +74,7 @@ module LogBench
67
74
  return :cache if cache_message?(data)
68
75
  return :sql if sql_message?(data)
69
76
  return :sql_call_line if call_stack_message?(data)
77
+ return :job_enqueue if job_enqueue_message?(data)
70
78
 
71
79
  :other
72
80
  end
@@ -89,6 +97,54 @@ module LogBench
89
97
  message = data["message"] || ""
90
98
  message.include?("↳")
91
99
  end
100
+
101
+ def self.job_enqueue_message?(data)
102
+ message = data["message"] || ""
103
+ message.match?(/Enqueued .+ \(Job ID: .+\)/)
104
+ end
105
+
106
+ def self.extract_job_id_from_enqueue(message)
107
+ match = message.match(/Job ID: ([^\)]+)/)
108
+ match[1] if match
109
+ end
110
+
111
+ # Register job enqueue in State
112
+ def self.register_job_enqueue(entry)
113
+ return unless entry.is_a?(JobEnqueueEntry)
114
+ return unless defined?(App::State)
115
+
116
+ App::State.instance.register_job_enqueue(entry.job_id, entry.request_id)
117
+ end
118
+
119
+ # Enrich job execution logs with request_id and colored prefix
120
+ def self.enrich_job_entry(entry)
121
+ return unless entry.respond_to?(:json_data)
122
+
123
+ tags = entry.json_data["tags"]
124
+ job_id, job_class = extract_job_info_from_tags(tags)
125
+ return unless job_id
126
+
127
+ add_job_prefix_to_entry(entry, job_id, job_class)
128
+ add_request_id_to_entry(entry, job_id)
129
+ end
130
+
131
+ # Add colored job prefix to entry content
132
+ def self.add_job_prefix_to_entry(entry, job_id, job_class)
133
+ return if entry.content.match?(/\[[\w:]+#[^\]]+\]/)
134
+
135
+ job_prefix = build_colored_job_prefix(job_class, job_id)
136
+ new_content = "#{job_prefix} #{entry.content}"
137
+ entry.instance_variable_set(:@content, new_content)
138
+ end
139
+
140
+ # Add request_id to entry from State
141
+ def self.add_request_id_to_entry(entry, job_id)
142
+ return if entry.request_id
143
+ return unless defined?(App::State)
144
+
145
+ request_id = App::State.instance.request_id_for_job(job_id)
146
+ entry.instance_variable_set(:@request_id, request_id) if request_id
147
+ end
92
148
  end
93
149
  end
94
150
  end
@@ -102,6 +102,13 @@ module LogBench
102
102
 
103
103
  require "sidekiq/middleware/current_attributes"
104
104
  Sidekiq::CurrentAttributes.persist("LogBench::Current")
105
+
106
+ # Add our custom middleware to capture job ID
107
+ Sidekiq.configure_server do |config|
108
+ config.server_middleware do |chain|
109
+ chain.add LogBench::SidekiqMiddleware
110
+ end
111
+ end
105
112
  end
106
113
 
107
114
  # Validate that LogBench setup worked correctly
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ # Sidekiq middleware to capture job ID (jid) and job class name and set them in Current attributes
5
+ # for inclusion in JSON logs
6
+ class SidekiqMiddleware
7
+ def call(worker, job, _queue)
8
+ if defined?(LogBench::Current)
9
+ # Only set Current attributes for direct Sidekiq jobs
10
+ # ActiveJob jobs will use tags instead
11
+ unless activejob_wrapper?(job["class"])
12
+ LogBench::Current.jid = job["jid"]
13
+ LogBench::Current.job_class = job["class"] || worker.class.name
14
+ end
15
+ end
16
+
17
+ yield
18
+ ensure
19
+ if defined?(LogBench::Current)
20
+ # Clean up the job attributes after the job completes
21
+ LogBench::Current.jid = nil
22
+ LogBench::Current.job_class = nil
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def activejob_wrapper?(job_class)
29
+ job_class == "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LogBench
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: log_bench
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamín Silva
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-10-07 00:00:00.000000000 Z
10
+ date: 2025-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -145,15 +145,18 @@ files:
145
145
  - lib/log_bench/configuration.rb
146
146
  - lib/log_bench/configuration_validator.rb
147
147
  - lib/log_bench/current.rb
148
+ - lib/log_bench/job_prefix_formatter.rb
148
149
  - lib/log_bench/json_formatter.rb
149
150
  - lib/log_bench/log/call_line_entry.rb
150
151
  - lib/log_bench/log/collection.rb
151
152
  - lib/log_bench/log/entry.rb
152
153
  - lib/log_bench/log/file.rb
154
+ - lib/log_bench/log/job_enqueue_entry.rb
153
155
  - lib/log_bench/log/parser.rb
154
156
  - lib/log_bench/log/query_entry.rb
155
157
  - lib/log_bench/log/request.rb
156
158
  - lib/log_bench/railtie.rb
159
+ - lib/log_bench/sidekiq_middleware.rb
157
160
  - lib/log_bench/version.rb
158
161
  - lib/log_bench/version_checker.rb
159
162
  - lib/tasks/log_bench.rake