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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class CacheEntry < Entry
6
+ SQL_OPERATIONS = %w[SELECT INSERT UPDATE DELETE].freeze
7
+
8
+ attr_reader :content, :timing
9
+
10
+ def initialize(raw_line)
11
+ super
12
+ self.type = :cache
13
+ end
14
+
15
+ def self.build(raw_line)
16
+ return unless parseable?(raw_line)
17
+
18
+ entry = Entry.new(raw_line)
19
+ return unless entry.type == :cache
20
+
21
+ new(raw_line)
22
+ end
23
+
24
+ def duration_ms
25
+ return 0.0 unless timing
26
+
27
+ timing.gsub(/[()ms]/, "").to_f
28
+ end
29
+
30
+ def hit?
31
+ content.include?("CACHE")
32
+ end
33
+
34
+ def miss?
35
+ !hit?
36
+ end
37
+
38
+ def to_h
39
+ super.merge(
40
+ content: content,
41
+ timing: timing,
42
+ operation: operation,
43
+ duration_ms: duration_ms,
44
+ hit: hit?
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :operation
51
+ attr_writer :content, :timing, :operation
52
+
53
+ def extract_from_json(data)
54
+ super
55
+ message = data["message"] || ""
56
+ return unless cache_message?(data)
57
+
58
+ self.content = message.strip
59
+ extract_timing_and_operation
60
+ end
61
+
62
+ def extract_timing_and_operation
63
+ clean_content = remove_ansi_codes(content)
64
+ self.timing = extract_timing(clean_content)
65
+ self.operation = extract_operation(clean_content)
66
+ end
67
+
68
+ def extract_timing(text)
69
+ match = text.match(/\(([0-9.]+ms)\)/)
70
+ match ? match[1] : nil
71
+ end
72
+
73
+ def extract_operation(text)
74
+ SQL_OPERATIONS.find { |op| text.include?(op) }
75
+ end
76
+
77
+ def remove_ansi_codes(text)
78
+ text.gsub(/\e\[[0-9;]*m/, "")
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class CallLineEntry < Entry
6
+ attr_reader :content
7
+
8
+ def initialize(raw_line)
9
+ super
10
+ self.type = :sql_call_line
11
+ end
12
+
13
+ def self.build(raw_line)
14
+ return unless parseable?(raw_line)
15
+
16
+ entry = Entry.new(raw_line)
17
+ return unless entry.type == :sql_call_line
18
+
19
+ new(raw_line)
20
+ end
21
+
22
+ def to_h
23
+ super.merge(
24
+ content: content,
25
+ file_path: file_path,
26
+ line_number: line_number,
27
+ method_name: method_name
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ attr_writer :content
34
+ attr_accessor :file_path, :line_number, :method_name
35
+
36
+ def extract_from_json(data)
37
+ super
38
+ message = data["message"] || ""
39
+ return unless call_line_message?(data)
40
+
41
+ self.content = message.strip
42
+ extract_call_info
43
+ end
44
+
45
+ def extract_call_info
46
+ # Parse call line like " ↳ app/controllers/users_controller.rb:10:in 'UsersController#show'"
47
+ if content =~ /↳\s+(.+):(\d+):in\s+'(.+)'/
48
+ self.file_path = $1
49
+ self.line_number = $2.to_i
50
+ self.method_name = $3
51
+ end
52
+ end
53
+
54
+ def call_line_message?(data)
55
+ message = data["message"] || ""
56
+ message.include?("↳")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class Collection
6
+ include Enumerable
7
+
8
+ attr_accessor :entries
9
+
10
+ def initialize(input)
11
+ self.entries = parse_input(input)
12
+ end
13
+
14
+ def each(&block)
15
+ entries.each(&block)
16
+ end
17
+
18
+ def size
19
+ entries.size
20
+ end
21
+
22
+ def empty?
23
+ entries.empty?
24
+ end
25
+
26
+ def requests
27
+ entries.select { |entry| entry.is_a?(Request) }
28
+ end
29
+
30
+ def queries
31
+ entries.flat_map(&:queries)
32
+ end
33
+
34
+ def cache_operations
35
+ entries.flat_map(&:cache_operations)
36
+ end
37
+
38
+ def filter_by_method(method)
39
+ filtered_requests = requests.select { |req| req.method == method.upcase }
40
+ create_collection_from_requests(filtered_requests)
41
+ end
42
+
43
+ def filter_by_path(path_pattern)
44
+ filtered_requests = requests.select { |req| req.path.include?(path_pattern) }
45
+ create_collection_from_requests(filtered_requests)
46
+ end
47
+
48
+ def filter_by_status(status_range)
49
+ filtered_requests = requests.select { |req| status_range.include?(req.status) }
50
+ create_collection_from_requests(filtered_requests)
51
+ end
52
+
53
+ def slow_requests(threshold_ms = 1000)
54
+ filtered_requests = requests.select { |req| req.duration && req.duration > threshold_ms }
55
+ create_collection_from_requests(filtered_requests)
56
+ end
57
+
58
+ def sort_by_duration
59
+ sorted_requests = requests.sort_by { |req| -(req.duration || 0) }
60
+ create_collection_from_requests(sorted_requests)
61
+ end
62
+
63
+ def sort_by_timestamp
64
+ sorted_requests = requests.sort_by(&:timestamp)
65
+ create_collection_from_requests(sorted_requests)
66
+ end
67
+
68
+ def to_a
69
+ entries
70
+ end
71
+
72
+ private
73
+
74
+ def create_collection_from_requests(requests)
75
+ new_collection = self.class.new([])
76
+ new_collection.entries = requests
77
+ new_collection
78
+ end
79
+
80
+ def parse_input(input)
81
+ lines = normalize_input(input)
82
+ parsed_entries = Parser.parse_lines(lines)
83
+ Parser.group_by_request(parsed_entries)
84
+ end
85
+
86
+ def normalize_input(input)
87
+ case input
88
+ when String
89
+ [input]
90
+ when Array
91
+ input.flat_map { |item| normalize_input(item) }
92
+ else
93
+ Array(input)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class Entry
6
+ attr_reader :type, :raw_line, :request_id, :timestamp
7
+
8
+ def initialize(raw_line)
9
+ self.raw_line = raw_line.strip
10
+ self.timestamp = Time.now
11
+ self.type = :unknown
12
+ parse!
13
+ end
14
+
15
+ def self.build(raw_line)
16
+ new(raw_line) if parseable?(raw_line)
17
+ end
18
+
19
+ def self.parseable?(line)
20
+ data = JSON.parse(line.strip)
21
+ data.is_a?(Hash)
22
+ rescue JSON::ParserError
23
+ false
24
+ end
25
+
26
+ def http_request?
27
+ type == :http_request
28
+ end
29
+
30
+ def related_log?
31
+ !http_request?
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ raw: raw_line,
37
+ timestamp: timestamp,
38
+ request_id: request_id,
39
+ type: type
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ attr_writer :type, :raw_line, :timestamp, :request_id
46
+
47
+ def parse!
48
+ parse_json
49
+ end
50
+
51
+ def parse_json
52
+ data = JSON.parse(raw_line)
53
+ return false unless data.is_a?(Hash)
54
+
55
+ # extract_from_json returns false if log should be discarded
56
+ extract_from_json(data)
57
+ rescue JSON::ParserError
58
+ false
59
+ end
60
+
61
+ def extract_from_json(data)
62
+ # Discard logs without request_id - they can't be correlated
63
+ return false unless data["request_id"]
64
+
65
+ self.timestamp = parse_timestamp(data["timestamp"])
66
+ self.request_id = data["request_id"]
67
+ self.type = determine_json_type(data)
68
+ true
69
+ end
70
+
71
+ def determine_json_type(data)
72
+ return :http_request if lograge_request?(data)
73
+ return :cache if cache_message?(data)
74
+ return :sql if sql_message?(data)
75
+ return :sql_call_line if call_stack_message?(data)
76
+
77
+ :other
78
+ end
79
+
80
+ def lograge_request?(data)
81
+ data["method"] && data["path"] && data["status"]
82
+ end
83
+
84
+ def sql_message?(data)
85
+ message = data["message"] || ""
86
+ %w[SELECT INSERT UPDATE DELETE TRANSACTION BEGIN COMMIT ROLLBACK SAVEPOINT].any? { |op| message.include?(op) }
87
+ end
88
+
89
+ def cache_message?(data)
90
+ message = data["message"] || ""
91
+ message.include?("CACHE")
92
+ end
93
+
94
+ def call_stack_message?(data)
95
+ message = data["message"] || ""
96
+ message.include?("↳")
97
+ end
98
+
99
+ def parse_timestamp(timestamp_str)
100
+ return Time.now unless timestamp_str
101
+
102
+ Time.parse(timestamp_str)
103
+ rescue ArgumentError
104
+ Time.now
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class File
6
+ attr_reader :path, :last_position
7
+
8
+ def initialize(path)
9
+ self.path = find_log_file(path)
10
+ self.last_position = 0
11
+ validate!
12
+ end
13
+
14
+ def requests
15
+ collection.requests
16
+ end
17
+
18
+ def entries
19
+ collection.entries
20
+ end
21
+
22
+ def collection
23
+ @collection ||= Collection.new(lines)
24
+ end
25
+
26
+ def lines
27
+ @lines ||= read_lines
28
+ end
29
+
30
+ def reload!
31
+ self.lines = nil
32
+ self.collection = nil
33
+ self.last_position = 0
34
+ end
35
+
36
+ def tail(max_lines = 1000)
37
+ all_lines = read_lines
38
+ recent_lines = all_lines.last(max_lines)
39
+ Collection.new(recent_lines)
40
+ end
41
+
42
+ def watch(&block)
43
+ return enum_for(:watch) unless block_given?
44
+
45
+ loop do
46
+ new_lines = read_new_lines
47
+ next if new_lines.empty?
48
+
49
+ new_collection = Collection.new(new_lines)
50
+ yield new_collection unless new_collection.empty?
51
+
52
+ sleep 0.5
53
+ end
54
+ end
55
+
56
+ def size
57
+ ::File.size(path)
58
+ end
59
+
60
+ def exist?
61
+ ::File.exist?(path)
62
+ end
63
+
64
+ def mtime
65
+ ::File.mtime(path)
66
+ end
67
+
68
+ private
69
+
70
+ attr_writer :path, :last_position
71
+
72
+ def read_lines
73
+ return [] unless exist?
74
+
75
+ ::File.readlines(path, chomp: true)
76
+ end
77
+
78
+ def read_new_lines
79
+ return [] unless exist?
80
+ return [] unless size > last_position
81
+
82
+ new_lines = []
83
+ ::File.open(path, "r") do |file|
84
+ file.seek(last_position)
85
+ new_lines = file.readlines(chomp: true)
86
+ self.last_position = file.tell
87
+ end
88
+
89
+ new_lines
90
+ end
91
+
92
+ def find_log_file(path)
93
+ candidates = [path, "log/development.log"]
94
+
95
+ candidates.find { |candidate| ::File.exist?(candidate) } || path
96
+ end
97
+
98
+ def validate!
99
+ raise Error, "File not found: #{path}" unless exist?
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class Parser
6
+ def self.parse_line(raw_line)
7
+ return unless Entry.parseable?(raw_line)
8
+
9
+ entry = Entry.new(raw_line)
10
+ build_specific_entry(entry)
11
+ end
12
+
13
+ def self.parse_lines(lines)
14
+ lines.map { |line| parse_line(line) }.compact
15
+ end
16
+
17
+ def self.group_by_request(entries)
18
+ grouped = entries.group_by(&:request_id)
19
+ build_requests_from_groups(grouped)
20
+ end
21
+
22
+ def self.build_specific_entry(entry)
23
+ case entry.type
24
+ when :http_request
25
+ Request.build(entry.raw_line)
26
+ when :sql
27
+ QueryEntry.build(entry.raw_line)
28
+ when :cache
29
+ CacheEntry.build(entry.raw_line)
30
+ when :sql_call_line
31
+ CallLineEntry.build(entry.raw_line)
32
+ else
33
+ entry
34
+ end
35
+ end
36
+
37
+ def self.build_requests_from_groups(grouped)
38
+ requests = []
39
+
40
+ grouped.each do |request_id, entries|
41
+ next unless request_id
42
+
43
+ request = find_request_entry(entries)
44
+ next unless request
45
+
46
+ related_logs = find_related_logs(entries)
47
+ related_logs.each { |log| request.add_related_log(log) }
48
+
49
+ requests << request
50
+ end
51
+
52
+ requests.sort_by(&:timestamp)
53
+ end
54
+
55
+ def self.find_request_entry(entries)
56
+ entries.find { |entry| entry.is_a?(Request) }
57
+ end
58
+
59
+ def self.find_related_logs(entries)
60
+ entries.reject { |entry| entry.is_a?(Request) }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBench
4
+ module Log
5
+ class QueryEntry < Entry
6
+ SELECT = "SELECT"
7
+ INSERT = "INSERT"
8
+ UPDATE = "UPDATE"
9
+ DELETE = "DELETE"
10
+ TRANSACTION = "TRANSACTION"
11
+ BEGIN_TRANSACTION = "BEGIN"
12
+ COMMIT = "COMMIT"
13
+ ROLLBACK = "ROLLBACK"
14
+ SAVEPOINT = "SAVEPOINT"
15
+ SQL_OPERATIONS = [SELECT, INSERT, UPDATE, DELETE, TRANSACTION, BEGIN_TRANSACTION, COMMIT, ROLLBACK, SAVEPOINT].freeze
16
+
17
+ attr_reader :content, :timing
18
+
19
+ def initialize(raw_line)
20
+ super
21
+ self.type = :sql
22
+ end
23
+
24
+ def self.build(raw_line)
25
+ return unless parseable?(raw_line)
26
+
27
+ entry = Entry.new(raw_line)
28
+ return unless entry.type == :sql
29
+
30
+ new(raw_line)
31
+ end
32
+
33
+ def duration_ms
34
+ return 0.0 unless timing
35
+
36
+ timing.gsub(/[()ms]/, "").to_f
37
+ end
38
+
39
+ def select?
40
+ operation == SELECT
41
+ end
42
+
43
+ def insert?
44
+ operation == INSERT
45
+ end
46
+
47
+ def update?
48
+ operation == UPDATE
49
+ end
50
+
51
+ def delete?
52
+ operation == DELETE
53
+ end
54
+
55
+ def transaction?
56
+ operation == TRANSACTION
57
+ end
58
+
59
+ def begin?
60
+ operation == BEGIN_TRANSACTION
61
+ end
62
+
63
+ def commit?
64
+ operation == COMMIT
65
+ end
66
+
67
+ def rollback?
68
+ operation == ROLLBACK
69
+ end
70
+
71
+ def savepoint?
72
+ operation == SAVEPOINT
73
+ end
74
+
75
+ def slow?(threshold_ms = 100)
76
+ duration_ms > threshold_ms
77
+ end
78
+
79
+ def to_h
80
+ super.merge(
81
+ content: content,
82
+ timing: timing,
83
+ operation: operation,
84
+ duration_ms: duration_ms,
85
+ has_ansi: has_ansi_codes?(content)
86
+ )
87
+ end
88
+
89
+ private
90
+
91
+ def extract_from_json(data)
92
+ # Call parent method which checks for request_id
93
+ return false unless super
94
+
95
+ message = data["message"] || ""
96
+ return false unless sql_message?(data)
97
+
98
+ self.content = message.strip
99
+ extract_timing_and_operation
100
+ true
101
+ end
102
+
103
+ def extract_timing_and_operation
104
+ clean_content = remove_ansi_codes(content)
105
+ self.timing = extract_timing(clean_content)
106
+ self.operation = extract_operation(clean_content)
107
+ end
108
+
109
+ def extract_timing(text)
110
+ match = text.match(/\(([0-9.]+ms)\)/)
111
+ match ? match[1] : nil
112
+ end
113
+
114
+ def extract_operation(text)
115
+ SQL_OPERATIONS.find { |op| text.include?(op) }
116
+ end
117
+
118
+ def remove_ansi_codes(text)
119
+ text.gsub(/\e\[[0-9;]*m/, "")
120
+ end
121
+
122
+ def has_ansi_codes?(text)
123
+ text.match?(/\e\[[0-9;]*m/)
124
+ end
125
+
126
+ private
127
+
128
+ attr_reader :operation
129
+ attr_writer :content, :timing, :operation
130
+ end
131
+ end
132
+ end