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.
@@ -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