cloudwatch_query 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/LICENSE.txt +21 -0
- data/README.md +298 -0
- data/Rakefile +8 -0
- data/cloudwatch_query.gemspec +31 -0
- data/lib/cloudwatch_query/client.rb +111 -0
- data/lib/cloudwatch_query/configuration.rb +14 -0
- data/lib/cloudwatch_query/errors.rb +9 -0
- data/lib/cloudwatch_query/parsers/base.rb +24 -0
- data/lib/cloudwatch_query/parsers/rails/rails_log.rb +72 -0
- data/lib/cloudwatch_query/parsers/rails/sub_parsers.rb +175 -0
- data/lib/cloudwatch_query/parsers/rails_parser.rb +144 -0
- data/lib/cloudwatch_query/parsers/registry.rb +93 -0
- data/lib/cloudwatch_query/parsers/sidekiq/sidekiq_log.rb +53 -0
- data/lib/cloudwatch_query/parsers/sidekiq/sub_parsers.rb +78 -0
- data/lib/cloudwatch_query/parsers/sidekiq_parser.rb +130 -0
- data/lib/cloudwatch_query/parsers.rb +19 -0
- data/lib/cloudwatch_query/query.rb +147 -0
- data/lib/cloudwatch_query/result.rb +68 -0
- data/lib/cloudwatch_query/result_set.rb +62 -0
- data/lib/cloudwatch_query/time_helpers.rb +56 -0
- data/lib/cloudwatch_query/version.rb +5 -0
- data/lib/cloudwatch_query.rb +61 -0
- metadata +112 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CloudwatchQuery
|
|
4
|
+
module Parsers
|
|
5
|
+
module Rails
|
|
6
|
+
# Base module for Rails sub-parsers
|
|
7
|
+
module SubParser
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def matches?(_message)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(_message, _base_data)
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parses: "Started GET "/path" for 1.2.3.4 at 2026-02-04 18:37:21 +0000"
|
|
24
|
+
class RequestSubParser
|
|
25
|
+
include SubParser
|
|
26
|
+
|
|
27
|
+
REQUEST_REGEX = /Started\s+(?<http_method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+"(?<path>[^"]+)"\s+for\s+(?<ip_address>[\d.]+)\s+at\s+(?<timestamp>.+)$/
|
|
28
|
+
|
|
29
|
+
def self.matches?(message)
|
|
30
|
+
message.include?("Started ") &&
|
|
31
|
+
(message.include?(" GET ") || message.include?(" POST ") ||
|
|
32
|
+
message.include?(" PUT ") || message.include?(" PATCH ") ||
|
|
33
|
+
message.include?(" DELETE "))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.parse(message, _base_data)
|
|
37
|
+
match = message.match(REQUEST_REGEX)
|
|
38
|
+
return {} unless match
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
line_type: :request,
|
|
42
|
+
http_method: match[:http_method],
|
|
43
|
+
path: match[:path],
|
|
44
|
+
ip_address: match[:ip_address],
|
|
45
|
+
request_timestamp: match[:timestamp]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parses: "Parameters: {...}"
|
|
51
|
+
class ParametersSubParser
|
|
52
|
+
include SubParser
|
|
53
|
+
|
|
54
|
+
PARAMS_REGEX = /Parameters:\s+(?<params>\{.+\})$/
|
|
55
|
+
|
|
56
|
+
def self.matches?(message)
|
|
57
|
+
message.include?("Parameters: {")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.parse(message, _base_data)
|
|
61
|
+
match = message.match(PARAMS_REGEX)
|
|
62
|
+
return {} unless match
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
line_type: :parameters,
|
|
66
|
+
params: safe_parse_params(match[:params])
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.safe_parse_params(params_string)
|
|
71
|
+
JSON.parse(
|
|
72
|
+
params_string
|
|
73
|
+
.gsub(/=>/, ":")
|
|
74
|
+
.gsub(/:(\w+)/, '"\1"')
|
|
75
|
+
.gsub(/nil/, "null")
|
|
76
|
+
)
|
|
77
|
+
rescue JSON::ParserError, StandardError
|
|
78
|
+
{ raw: params_string }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Parses: "Redirected to https://..."
|
|
83
|
+
class RedirectSubParser
|
|
84
|
+
include SubParser
|
|
85
|
+
|
|
86
|
+
REDIRECT_REGEX = /Redirected to\s+(?<redirect_url>.+)$/
|
|
87
|
+
|
|
88
|
+
def self.matches?(message)
|
|
89
|
+
message.include?("Redirected to ")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.parse(message, _base_data)
|
|
93
|
+
match = message.match(REDIRECT_REGEX)
|
|
94
|
+
return {} unless match
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
line_type: :redirect,
|
|
98
|
+
redirect_url: match[:redirect_url].strip
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Parses: "[ActiveJob] Enqueued JobClass (Job ID: uuid) to Sidekiq(queue)"
|
|
104
|
+
class ActiveJobSubParser
|
|
105
|
+
include SubParser
|
|
106
|
+
|
|
107
|
+
ACTIVE_JOB_REGEX = /\[ActiveJob\]\s+Enqueued\s+(?<job_class>[\w:]+)\s+\(Job ID:\s+(?<job_id>[^)]+)\)\s+to\s+\w+\((?<queue>\w+)\)(?:\s+with arguments:\s+(?<arguments>.+))?$/
|
|
108
|
+
|
|
109
|
+
def self.matches?(message)
|
|
110
|
+
message.include?("[ActiveJob] Enqueued")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.parse(message, _base_data)
|
|
114
|
+
match = message.match(ACTIVE_JOB_REGEX)
|
|
115
|
+
return {} unless match
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
line_type: :active_job,
|
|
119
|
+
job_class: match[:job_class],
|
|
120
|
+
job_id: match[:job_id],
|
|
121
|
+
queue: match[:queue],
|
|
122
|
+
arguments: match[:arguments]&.strip
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Parses: "Processing by Controller#action as FORMAT"
|
|
128
|
+
class ProcessingSubParser
|
|
129
|
+
include SubParser
|
|
130
|
+
|
|
131
|
+
PROCESSING_REGEX = /Processing by\s+(?<controller>\w+)#(?<action>\w+)\s+as\s+(?<format>\w+)/
|
|
132
|
+
|
|
133
|
+
def self.matches?(message)
|
|
134
|
+
message.include?("Processing by ")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.parse(message, _base_data)
|
|
138
|
+
match = message.match(PROCESSING_REGEX)
|
|
139
|
+
return {} unless match
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
line_type: :processing,
|
|
143
|
+
controller: match[:controller],
|
|
144
|
+
action: match[:action],
|
|
145
|
+
format: match[:format]
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Parses: "Completed 200 OK in 123ms"
|
|
151
|
+
class CompletedSubParser
|
|
152
|
+
include SubParser
|
|
153
|
+
|
|
154
|
+
COMPLETED_REGEX = /Completed\s+(?<status>\d+)\s+\w+\s+in\s+(?<duration>[\d.]+)(?<unit>ms|s)/
|
|
155
|
+
|
|
156
|
+
def self.matches?(message)
|
|
157
|
+
message.include?("Completed ")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.parse(message, _base_data)
|
|
161
|
+
match = message.match(COMPLETED_REGEX)
|
|
162
|
+
return {} unless match
|
|
163
|
+
|
|
164
|
+
duration_ms = match[:unit] == "s" ? match[:duration].to_f * 1000 : match[:duration].to_f
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
line_type: :completed,
|
|
168
|
+
status_code: match[:status].to_i,
|
|
169
|
+
duration_ms: duration_ms
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rails/sub_parsers"
|
|
4
|
+
require_relative "rails/rails_log"
|
|
5
|
+
|
|
6
|
+
module CloudwatchQuery
|
|
7
|
+
module Parsers
|
|
8
|
+
class RailsParser < Base
|
|
9
|
+
# Built-in sub-parsers mapped to symbols
|
|
10
|
+
BUILT_IN_SUB_PARSERS = {
|
|
11
|
+
request: Rails::RequestSubParser,
|
|
12
|
+
parameters: Rails::ParametersSubParser,
|
|
13
|
+
redirect: Rails::RedirectSubParser,
|
|
14
|
+
active_job: Rails::ActiveJobSubParser,
|
|
15
|
+
processing: Rails::ProcessingSubParser,
|
|
16
|
+
completed: Rails::CompletedSubParser
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Matches request UUID pattern: [abc-123-def-456]
|
|
20
|
+
REQUEST_ID_REGEX = /\[(?<request_id>[a-f0-9-]{36})\]/
|
|
21
|
+
|
|
22
|
+
# Matches syslog prefix: "Feb 4 22:37:47 ip-10-15-1-216 cryo[1030829]:"
|
|
23
|
+
SYSLOG_PREFIX_REGEX = /^(?<syslog_timestamp>\w+\s+\d+\s+[\d:]+)\s+(?<server>[\w.-]+)\s+\w+\[(?<process_id>\d+)\]:\s*/
|
|
24
|
+
|
|
25
|
+
attr_reader :sub_parsers
|
|
26
|
+
|
|
27
|
+
# Initialize with specific sub-parsers or use all defaults
|
|
28
|
+
#
|
|
29
|
+
# @example Use all defaults
|
|
30
|
+
# RailsParser.new
|
|
31
|
+
#
|
|
32
|
+
# @example Use only specific built-in sub-parsers
|
|
33
|
+
# RailsParser.new(:request, :parameters)
|
|
34
|
+
#
|
|
35
|
+
# @example Mix built-in and custom sub-parsers
|
|
36
|
+
# RailsParser.new(:request, :parameters, MyCustomSubParser)
|
|
37
|
+
#
|
|
38
|
+
# @example All defaults plus custom
|
|
39
|
+
# RailsParser.new(:all, MyCustomSubParser)
|
|
40
|
+
#
|
|
41
|
+
def initialize(*args)
|
|
42
|
+
@sub_parsers = resolve_sub_parsers(args)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def matches?(message)
|
|
46
|
+
# Must have a Rails request UUID pattern
|
|
47
|
+
message.match?(REQUEST_ID_REGEX)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse(message)
|
|
51
|
+
return nil unless matches?(message)
|
|
52
|
+
|
|
53
|
+
base_data = extract_base_data(message)
|
|
54
|
+
clean_message = strip_syslog_prefix(message)
|
|
55
|
+
|
|
56
|
+
# Try each sub-parser until one matches
|
|
57
|
+
sub_parser_data = {}
|
|
58
|
+
@sub_parsers.each do |parser|
|
|
59
|
+
if parser.matches?(clean_message)
|
|
60
|
+
sub_parser_data = parser.parse(clean_message, base_data)
|
|
61
|
+
break if sub_parser_data[:line_type]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# If no sub-parser matched, mark as unknown
|
|
66
|
+
sub_parser_data[:line_type] ||= :unknown
|
|
67
|
+
sub_parser_data[:raw_message] = message if sub_parser_data[:line_type] == :unknown
|
|
68
|
+
|
|
69
|
+
Rails::RailsLog.new(base_data.merge(sub_parser_data))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Class method for simple usage (uses all defaults)
|
|
73
|
+
class << self
|
|
74
|
+
def matches?(message)
|
|
75
|
+
new.matches?(message)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse(message)
|
|
79
|
+
new.parse(message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parser_name
|
|
83
|
+
"rails"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# List available built-in sub-parser names
|
|
87
|
+
def available_sub_parsers
|
|
88
|
+
BUILT_IN_SUB_PARSERS.keys
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def resolve_sub_parsers(args)
|
|
95
|
+
return BUILT_IN_SUB_PARSERS.values if args.empty?
|
|
96
|
+
|
|
97
|
+
parsers = []
|
|
98
|
+
args.each do |arg|
|
|
99
|
+
case arg
|
|
100
|
+
when :all
|
|
101
|
+
parsers.concat(BUILT_IN_SUB_PARSERS.values)
|
|
102
|
+
when Symbol
|
|
103
|
+
parser = BUILT_IN_SUB_PARSERS[arg]
|
|
104
|
+
raise ArgumentError, "Unknown sub-parser: #{arg}. Available: #{BUILT_IN_SUB_PARSERS.keys.join(', ')}" unless parser
|
|
105
|
+
|
|
106
|
+
parsers << parser
|
|
107
|
+
else
|
|
108
|
+
# Assume it's a custom sub-parser class/object
|
|
109
|
+
validate_sub_parser!(arg)
|
|
110
|
+
parsers << arg
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
parsers.uniq
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_sub_parser!(parser)
|
|
117
|
+
unless parser.respond_to?(:matches?) && parser.respond_to?(:parse)
|
|
118
|
+
raise ArgumentError, "Sub-parser must respond to .matches?(message) and .parse(message, base_data)"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def extract_base_data(message)
|
|
123
|
+
data = {}
|
|
124
|
+
|
|
125
|
+
# Extract request ID
|
|
126
|
+
if (match = message.match(REQUEST_ID_REGEX))
|
|
127
|
+
data[:request_id] = match[:request_id]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Extract syslog prefix data
|
|
131
|
+
if (match = message.match(SYSLOG_PREFIX_REGEX))
|
|
132
|
+
data[:server] = match[:server]
|
|
133
|
+
data[:process_id] = match[:process_id]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
data
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def strip_syslog_prefix(message)
|
|
140
|
+
message.sub(SYSLOG_PREFIX_REGEX, "")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CloudwatchQuery
|
|
4
|
+
module Parsers
|
|
5
|
+
class Registry
|
|
6
|
+
def initialize
|
|
7
|
+
@parsers = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Register one or more parsers (appends to end, lower priority)
|
|
11
|
+
def register(*parsers)
|
|
12
|
+
parsers.flatten.each do |parser|
|
|
13
|
+
validate_parser!(parser)
|
|
14
|
+
@parsers << parser unless @parsers.include?(parser)
|
|
15
|
+
end
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Add parser(s) at the beginning (highest priority)
|
|
20
|
+
def prepend(*parsers)
|
|
21
|
+
parsers.flatten.reverse.each do |parser|
|
|
22
|
+
validate_parser!(parser)
|
|
23
|
+
@parsers.delete(parser)
|
|
24
|
+
@parsers.unshift(parser)
|
|
25
|
+
end
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Insert parser at specific index
|
|
30
|
+
def insert(index, parser)
|
|
31
|
+
validate_parser!(parser)
|
|
32
|
+
@parsers.delete(parser)
|
|
33
|
+
@parsers.insert(index, parser)
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Remove a parser
|
|
38
|
+
def unregister(parser)
|
|
39
|
+
@parsers.delete(parser)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Clear all parsers
|
|
44
|
+
def clear
|
|
45
|
+
@parsers.clear
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# List all registered parsers
|
|
50
|
+
def list
|
|
51
|
+
@parsers.dup
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Parse a message using the first matching parser
|
|
55
|
+
# Returns [parsed_object, parser_name] or [nil, nil] if no match
|
|
56
|
+
def parse(message)
|
|
57
|
+
return [nil, nil] if message.nil? || message.empty?
|
|
58
|
+
|
|
59
|
+
@parsers.each do |parser|
|
|
60
|
+
if parser.matches?(message)
|
|
61
|
+
begin
|
|
62
|
+
parsed = parser.parse(message)
|
|
63
|
+
return [parsed, get_parser_name(parser)] if parsed
|
|
64
|
+
rescue StandardError
|
|
65
|
+
# Parser failed, try next one
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
[nil, nil]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def get_parser_name(parser)
|
|
77
|
+
if parser.respond_to?(:parser_name)
|
|
78
|
+
parser.parser_name
|
|
79
|
+
elsif parser.class.respond_to?(:parser_name)
|
|
80
|
+
parser.class.parser_name
|
|
81
|
+
else
|
|
82
|
+
parser.class.name.split("::").last.sub(/Parser$/, "").downcase
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_parser!(parser)
|
|
87
|
+
unless parser.respond_to?(:matches?) && parser.respond_to?(:parse)
|
|
88
|
+
raise ArgumentError, "Parser must respond to .matches?(message) and .parse(message)"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CloudwatchQuery
|
|
4
|
+
module Parsers
|
|
5
|
+
module Sidekiq
|
|
6
|
+
class SidekiqLog
|
|
7
|
+
attr_reader :timestamp, :pid, :tid, :job_class, :jid,
|
|
8
|
+
:line_type, :status, :elapsed,
|
|
9
|
+
:raw_message
|
|
10
|
+
|
|
11
|
+
def initialize(attributes = {})
|
|
12
|
+
attributes.each do |key, value|
|
|
13
|
+
instance_variable_set("@#{key}", value) if respond_to?(key)
|
|
14
|
+
end
|
|
15
|
+
@line_type ||= :unknown
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def type
|
|
19
|
+
:sidekiq
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start?
|
|
23
|
+
line_type == :start
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def done?
|
|
27
|
+
line_type == :done
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fail?
|
|
31
|
+
line_type == :fail
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Alias for elapsed
|
|
35
|
+
def duration
|
|
36
|
+
elapsed
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
instance_variables.each_with_object({}) do |var, hash|
|
|
41
|
+
key = var.to_s.delete("@").to_sym
|
|
42
|
+
value = instance_variable_get(var)
|
|
43
|
+
hash[key] = value unless value.nil?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def [](key)
|
|
48
|
+
instance_variable_get("@#{key}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CloudwatchQuery
|
|
4
|
+
module Parsers
|
|
5
|
+
module Sidekiq
|
|
6
|
+
# Base module for Sidekiq sub-parsers
|
|
7
|
+
module SubParser
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def matches?(_message)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(_message, _base_data)
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parses job start: "INFO: start"
|
|
24
|
+
class StartSubParser
|
|
25
|
+
include SubParser
|
|
26
|
+
|
|
27
|
+
def self.matches?(message)
|
|
28
|
+
message.end_with?("INFO: start")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.parse(_message, _base_data)
|
|
32
|
+
{
|
|
33
|
+
line_type: :start,
|
|
34
|
+
status: "start"
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parses job done with elapsed: "elapsed=0.152 INFO: done"
|
|
40
|
+
class DoneSubParser
|
|
41
|
+
include SubParser
|
|
42
|
+
|
|
43
|
+
ELAPSED_REGEX = /elapsed=(?<elapsed>[\d.]+)\s+INFO:\s+done$/
|
|
44
|
+
|
|
45
|
+
def self.matches?(message)
|
|
46
|
+
message.include?("INFO: done")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse(message, _base_data)
|
|
50
|
+
match = message.match(ELAPSED_REGEX)
|
|
51
|
+
elapsed = match ? match[:elapsed].to_f : nil
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
line_type: :done,
|
|
55
|
+
status: "done",
|
|
56
|
+
elapsed: elapsed
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Parses job failure: "INFO: fail" or error messages
|
|
62
|
+
class FailSubParser
|
|
63
|
+
include SubParser
|
|
64
|
+
|
|
65
|
+
def self.matches?(message)
|
|
66
|
+
message.include?("INFO: fail") || message.include?("ERROR:")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.parse(message, _base_data)
|
|
70
|
+
{
|
|
71
|
+
line_type: :fail,
|
|
72
|
+
status: "fail"
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sidekiq/sub_parsers"
|
|
4
|
+
require_relative "sidekiq/sidekiq_log"
|
|
5
|
+
|
|
6
|
+
module CloudwatchQuery
|
|
7
|
+
module Parsers
|
|
8
|
+
class SidekiqParser < Base
|
|
9
|
+
# Built-in sub-parsers mapped to symbols
|
|
10
|
+
BUILT_IN_SUB_PARSERS = {
|
|
11
|
+
start: Sidekiq::StartSubParser,
|
|
12
|
+
done: Sidekiq::DoneSubParser,
|
|
13
|
+
fail: Sidekiq::FailSubParser
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Matches Sidekiq log format: "2026-02-04T20:46:15.201Z pid=123 tid=abc class=Job jid=xyz"
|
|
17
|
+
SIDEKIQ_PATTERN = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+pid=\d+\s+tid=\w+\s+class=/
|
|
18
|
+
BASE_REGEX = /^(?<timestamp>\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s+pid=(?<pid>\d+)\s+tid=(?<tid>\w+)\s+class=(?<job_class>[\w:]+)\s+jid=(?<jid>\w+)/
|
|
19
|
+
|
|
20
|
+
attr_reader :sub_parsers
|
|
21
|
+
|
|
22
|
+
# Initialize with specific sub-parsers or use all defaults
|
|
23
|
+
#
|
|
24
|
+
# @example Use all defaults
|
|
25
|
+
# SidekiqParser.new
|
|
26
|
+
#
|
|
27
|
+
# @example Use only specific built-in sub-parsers
|
|
28
|
+
# SidekiqParser.new(:start, :done)
|
|
29
|
+
#
|
|
30
|
+
# @example Mix built-in and custom sub-parsers
|
|
31
|
+
# SidekiqParser.new(:start, :done, MyCustomSubParser)
|
|
32
|
+
#
|
|
33
|
+
# @example All defaults plus custom
|
|
34
|
+
# SidekiqParser.new(:all, MyCustomSubParser)
|
|
35
|
+
#
|
|
36
|
+
def initialize(*args)
|
|
37
|
+
@sub_parsers = resolve_sub_parsers(args)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def matches?(message)
|
|
41
|
+
message.match?(SIDEKIQ_PATTERN)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(message)
|
|
45
|
+
return nil unless matches?(message)
|
|
46
|
+
|
|
47
|
+
base_data = extract_base_data(message)
|
|
48
|
+
return nil if base_data.empty?
|
|
49
|
+
|
|
50
|
+
# Try each sub-parser until one matches
|
|
51
|
+
sub_parser_data = {}
|
|
52
|
+
@sub_parsers.each do |parser|
|
|
53
|
+
if parser.matches?(message)
|
|
54
|
+
sub_parser_data = parser.parse(message, base_data)
|
|
55
|
+
break if sub_parser_data[:line_type]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# If no sub-parser matched, mark as unknown
|
|
60
|
+
sub_parser_data[:line_type] ||= :unknown
|
|
61
|
+
sub_parser_data[:raw_message] = message if sub_parser_data[:line_type] == :unknown
|
|
62
|
+
|
|
63
|
+
Sidekiq::SidekiqLog.new(base_data.merge(sub_parser_data))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Class method for simple usage (uses all defaults)
|
|
67
|
+
class << self
|
|
68
|
+
def matches?(message)
|
|
69
|
+
new.matches?(message)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse(message)
|
|
73
|
+
new.parse(message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parser_name
|
|
77
|
+
"sidekiq"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# List available built-in sub-parser names
|
|
81
|
+
def available_sub_parsers
|
|
82
|
+
BUILT_IN_SUB_PARSERS.keys
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def resolve_sub_parsers(args)
|
|
89
|
+
return BUILT_IN_SUB_PARSERS.values if args.empty?
|
|
90
|
+
|
|
91
|
+
parsers = []
|
|
92
|
+
args.each do |arg|
|
|
93
|
+
case arg
|
|
94
|
+
when :all
|
|
95
|
+
parsers.concat(BUILT_IN_SUB_PARSERS.values)
|
|
96
|
+
when Symbol
|
|
97
|
+
parser = BUILT_IN_SUB_PARSERS[arg]
|
|
98
|
+
raise ArgumentError, "Unknown sub-parser: #{arg}. Available: #{BUILT_IN_SUB_PARSERS.keys.join(', ')}" unless parser
|
|
99
|
+
|
|
100
|
+
parsers << parser
|
|
101
|
+
else
|
|
102
|
+
# Assume it's a custom sub-parser class/object
|
|
103
|
+
validate_sub_parser!(arg)
|
|
104
|
+
parsers << arg
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
parsers.uniq
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_sub_parser!(parser)
|
|
111
|
+
unless parser.respond_to?(:matches?) && parser.respond_to?(:parse)
|
|
112
|
+
raise ArgumentError, "Sub-parser must respond to .matches?(message) and .parse(message, base_data)"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def extract_base_data(message)
|
|
117
|
+
match = message.match(BASE_REGEX)
|
|
118
|
+
return {} unless match
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
timestamp: match[:timestamp],
|
|
122
|
+
pid: match[:pid],
|
|
123
|
+
tid: match[:tid],
|
|
124
|
+
job_class: match[:job_class],
|
|
125
|
+
jid: match[:jid]
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parsers/base"
|
|
4
|
+
require_relative "parsers/registry"
|
|
5
|
+
require_relative "parsers/rails_parser"
|
|
6
|
+
require_relative "parsers/sidekiq_parser"
|
|
7
|
+
|
|
8
|
+
module CloudwatchQuery
|
|
9
|
+
module Parsers
|
|
10
|
+
class << self
|
|
11
|
+
def default_parsers
|
|
12
|
+
[
|
|
13
|
+
RailsParser.new,
|
|
14
|
+
SidekiqParser.new
|
|
15
|
+
]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|