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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 467d68c5e50ddf28a0be2b7a5e54879c4a2ff0083146a71b359d8d7d7537d485
4
+ data.tar.gz: 64475bd16a828fb65cd29f18449c81c4bec31e09c9257c35a1f8e635e0bd639b
5
+ SHA512:
6
+ metadata.gz: aaa0b07437366f8d047b214391e427eaeffd7b9de295c4f80dacd0e57c5462706e21a81beee0050c7bf9259808140be99cd89af1c0635a92329ee1a11afdd534
7
+ data.tar.gz: 23f498ab01846a21e60e821eb51415ffdf758f5720d7930e014689a4f058509bb1f3134d10ca4f0db4c82b98ba5458c8939ba90fabefdba83ff5f27479e5e066
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 cloudwatch_query contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,298 @@
1
+ # CloudwatchQuery
2
+
3
+ A Ruby gem for querying AWS CloudWatch Logs with a simple, chainable interface.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "cloudwatch_query"
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install cloudwatch_query
17
+ ```
18
+
19
+ ## Prerequisites
20
+
21
+ AWS credentials must be configured. This gem uses the standard AWS credential chain:
22
+
23
+ 1. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
24
+ 2. Shared credentials file (`~/.aws/credentials`)
25
+ 3. EC2 instance metadata
26
+
27
+ ## Configuration
28
+
29
+ ```ruby
30
+ CloudwatchQuery.configure do |config|
31
+ config.region = "us-west-1" # Default: ENV["AWS_REGION"] or "us-west-1"
32
+ config.default_limit = 100 # Default result limit
33
+ config.default_time_range = 3600 # Default lookback in seconds
34
+ end
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Basic Query
40
+
41
+ ```ruby
42
+ CloudwatchQuery
43
+ .logs("my-app-rails-log")
44
+ .where(level: "ERROR")
45
+ .past(30, :minutes)
46
+ .each { |event| puts event.message }
47
+ ```
48
+
49
+ ### Quick Search
50
+
51
+ ```ruby
52
+ CloudwatchQuery.search(
53
+ "OutOfMemoryError",
54
+ groups: "my-app-rails-log",
55
+ since: 2.hours.ago,
56
+ limit: 50
57
+ )
58
+ ```
59
+
60
+ ### Multiple Log Groups
61
+
62
+ ```ruby
63
+ CloudwatchQuery
64
+ .logs(
65
+ "my-app-rails-log",
66
+ "my-app-sidekiq-log"
67
+ )
68
+ .contains("database")
69
+ .where(level: "ERROR")
70
+ .past(1, :hours)
71
+ .execute
72
+ ```
73
+
74
+ ### Time Range Options
75
+
76
+ ```ruby
77
+ query = CloudwatchQuery.logs("my-app-rails-log")
78
+
79
+ # Relative time
80
+ query.past(30, :minutes)
81
+ query.past(2, :hours)
82
+ query.past(7, :days)
83
+
84
+ # Absolute time
85
+ query.since(Time.now - 3600)
86
+ query.since(1.hour.ago) # With ActiveSupport
87
+ query.between(start_time, end_time)
88
+ ```
89
+
90
+ Available time units (singular and plural forms accepted):
91
+
92
+ | Unit | Symbols |
93
+ |------|---------|
94
+ | Seconds | `:second`, `:seconds` |
95
+ | Minutes | `:minute`, `:minutes` |
96
+ | Hours | `:hour`, `:hours` |
97
+ | Days | `:day`, `:days` |
98
+ | Weeks | `:week`, `:weeks` |
99
+
100
+ ### Field Selection
101
+
102
+ ```ruby
103
+ CloudwatchQuery
104
+ .logs("my-app-rails-log")
105
+ .fields(:timestamp, :message, :logStream)
106
+ .past(1, :hours)
107
+ .execute
108
+ ```
109
+
110
+ ### Query Inspection
111
+
112
+ ```ruby
113
+ query = CloudwatchQuery
114
+ .logs("my-app-rails-log")
115
+ .where(level: "ERROR")
116
+ .contains("timeout")
117
+ .limit(50)
118
+
119
+ puts query.to_insights_query
120
+ # => fields @timestamp, @message, @logStream, @log | filter level = 'ERROR' | filter @message like /timeout/ | sort @timestamp desc | limit 50
121
+ ```
122
+
123
+ ### Working with Results
124
+
125
+ ```ruby
126
+ results = CloudwatchQuery
127
+ .logs("my-app-rails-log")
128
+ .past(1, :hours)
129
+ .execute
130
+
131
+ # Enumerable methods
132
+ results.each { |r| puts r.message }
133
+ results.count
134
+ results.empty?
135
+
136
+ # Access fields
137
+ results.first.timestamp
138
+ results.first.message
139
+ results.first[:logStream]
140
+ results.first.to_h
141
+ ```
142
+
143
+ ### Parsers
144
+
145
+ Results can be automatically parsed into structured objects. The gem ships with two built-in parsers: `RailsParser` and `SidekiqParser`. Parsers are registered globally and run automatically when results are returned.
146
+
147
+ #### How It Works
148
+
149
+ Each `Result` has a `parsed` attribute. When a query executes, every result's `message` is passed through the parser registry. The first parser whose `matches?` returns true parses the message into a structured log object (`RailsLog` or `SidekiqLog`).
150
+
151
+ ```ruby
152
+ results = CloudwatchQuery
153
+ .logs("my-app-rails-log")
154
+ .past(30, :minutes)
155
+ .execute
156
+
157
+ result = results.first
158
+ result.parsed? # => true
159
+ result.parser_name # => "rails"
160
+ result.log_type # => :rails
161
+ result.parsed # => RailsLog instance
162
+ ```
163
+
164
+ #### Rails Parser
165
+
166
+ Recognizes log lines containing a Rails request UUID (`[abc-123-def-456]`). Each line is further classified by sub-parsers:
167
+
168
+ | Sub-parser | Matches | Fields |
169
+ |------------|---------|--------|
170
+ | `:request` | `Started GET "/path" for 1.2.3.4` | `http_method`, `path`, `ip_address` |
171
+ | `:parameters` | `Parameters: {...}` | `params` |
172
+ | `:processing` | `Processing by Controller#action as HTML` | `controller`, `action`, `format` |
173
+ | `:completed` | `Completed 200 OK in 123ms` | `status_code`, `duration_ms` |
174
+ | `:redirect` | `Redirected to https://...` | `redirect_url` |
175
+ | `:active_job` | `[ActiveJob] Enqueued JobClass (Job ID: ...)` | `job_class`, `job_id`, `queue` |
176
+
177
+ ```ruby
178
+ result.parsed.line_type # => :request
179
+ result.parsed.http_method # => "GET"
180
+ result.parsed.path # => "/users/1"
181
+ result.parsed.request_id # => "abc-123-def-456"
182
+ ```
183
+
184
+ Use only specific sub-parsers:
185
+
186
+ ```ruby
187
+ CloudwatchQuery.parsers.clear
188
+ CloudwatchQuery.parsers.register(
189
+ CloudwatchQuery::Parsers::RailsParser.new(:request, :completed)
190
+ )
191
+ ```
192
+
193
+ #### Sidekiq Parser
194
+
195
+ Recognizes Sidekiq log lines (`2026-02-04T20:46:15.201Z pid=123 tid=abc class=Job jid=xyz`). Sub-parsers:
196
+
197
+ | Sub-parser | Matches | Fields |
198
+ |------------|---------|--------|
199
+ | `:start` | `INFO: start` | `status` |
200
+ | `:done` | `INFO: done` | `status`, `elapsed` |
201
+ | `:fail` | `INFO: fail` or `ERROR:` | `status` |
202
+
203
+ ```ruby
204
+ result.parsed.line_type # => :done
205
+ result.parsed.job_class # => "SendEmailJob"
206
+ result.parsed.elapsed # => 0.152
207
+ result.parsed.jid # => "abc123"
208
+ ```
209
+
210
+ #### Filtering Parsed Results
211
+
212
+ ```ruby
213
+ results.parsed # only successfully parsed results
214
+ results.unparsed # results that no parser matched
215
+ results.by_type(:rails) # filter by log type
216
+ results.group_by_request # group Rails logs by request_id
217
+ ```
218
+
219
+ #### Custom Parsers
220
+
221
+ Create a parser that responds to `matches?` and `parse`:
222
+
223
+ ```ruby
224
+ class MyParser
225
+ def matches?(message)
226
+ message.include?("[CUSTOM]")
227
+ end
228
+
229
+ def parse(message)
230
+ OpenStruct.new(type: :custom, body: message)
231
+ end
232
+
233
+ def parser_name
234
+ "custom"
235
+ end
236
+ end
237
+
238
+ CloudwatchQuery.parsers.register(MyParser.new)
239
+ ```
240
+
241
+ Registry methods: `register`, `prepend`, `insert`, `unregister`, `clear`, `list`.
242
+
243
+ ### List Log Groups
244
+
245
+ ```ruby
246
+ CloudwatchQuery.list_log_groups(prefix: "production-")
247
+ ```
248
+
249
+ ## API Reference
250
+
251
+ ### Query Builder Methods
252
+
253
+ | Method | Description |
254
+ |--------|-------------|
255
+ | `.logs(*groups)` | Select log groups to query |
256
+ | `.where(**conditions)` | Filter by field equality |
257
+ | `.contains(text)` | Filter messages containing text |
258
+ | `.matches(pattern)` | Filter messages matching regex |
259
+ | `.since(time)` | Set start time |
260
+ | `.before(time)` | Set end time |
261
+ | `.between(start, end)` | Set time range |
262
+ | `.past(amount, unit)` | Relative time (e.g., `past(30, :minutes)`) |
263
+ | `.fields(*fields)` | Select fields to return |
264
+ | `.limit(n)` | Limit number of results |
265
+ | `.execute` | Run query and return ResultSet |
266
+ | `.to_insights_query` | Return generated query string |
267
+
268
+ ### Result Methods
269
+
270
+ | Method | Description |
271
+ |--------|-------------|
272
+ | `.timestamp` | Log event timestamp |
273
+ | `.message` | Log message content |
274
+ | `.[](key)` | Access field by name |
275
+ | `.to_h` | Convert to hash |
276
+
277
+ ## Error Handling
278
+
279
+ ```ruby
280
+ begin
281
+ results = CloudwatchQuery
282
+ .logs("my-log-group")
283
+ .past(1, :hours)
284
+ .execute
285
+ rescue CloudwatchQuery::AuthError => e
286
+ puts "Authentication failed: #{e.message}"
287
+ rescue CloudwatchQuery::QueryError => e
288
+ puts "Query failed: #{e.message}"
289
+ rescue CloudwatchQuery::TimeoutError => e
290
+ puts "Query timed out: #{e.message}"
291
+ rescue CloudwatchQuery::Error => e
292
+ puts "Error: #{e.message}"
293
+ end
294
+ ```
295
+
296
+ ## License
297
+
298
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/cloudwatch_query/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "cloudwatch_query"
7
+ spec.version = CloudwatchQuery::VERSION
8
+ spec.authors = ["Igor Irianto"]
9
+
10
+ spec.summary = "A Ruby gem for querying AWS CloudWatch Logs with a simple, chainable interface"
11
+ spec.description = "Query AWS CloudWatch Logs using a fluent, ActiveRecord-style interface. " \
12
+ "Supports chainable queries, automatic Insights query generation, and enumerable results."
13
+ spec.homepage = "https://github.com/iggredible/cloudwatch_query"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ Dir["lib/**/*", "LICENSE.txt", "README.md", "Rakefile", "cloudwatch_query.gemspec"]
24
+ end
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "aws-sdk-cloudwatchlogs", "~> 1.0"
28
+
29
+ spec.add_development_dependency "rspec", "~> 3.0"
30
+ spec.add_development_dependency "webmock", "~> 3.0"
31
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-cloudwatchlogs"
4
+
5
+ module CloudwatchQuery
6
+ class Client
7
+ DEFAULT_POLL_INTERVAL = 1
8
+ DEFAULT_TIMEOUT = 60
9
+
10
+ attr_reader :aws_client
11
+
12
+ def initialize(region: nil, profile: nil)
13
+ config = CloudwatchQuery.configuration
14
+ client_options = {
15
+ region: region || config.region
16
+ }
17
+
18
+ # AWS SDK automatically picks up credentials from:
19
+ # 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
20
+ # 2. Shared credentials file (~/.aws/credentials) - set by saml2aws
21
+ # 3. EC2 instance metadata
22
+ @aws_client = Aws::CloudWatchLogs::Client.new(client_options)
23
+ end
24
+
25
+ def execute(query_string:, log_group_names:, start_time:, end_time:, limit:)
26
+ query_id = start_query(
27
+ query_string: query_string,
28
+ log_group_names: log_group_names,
29
+ start_time: start_time,
30
+ end_time: end_time,
31
+ limit: limit
32
+ )
33
+
34
+ poll_results(query_id)
35
+ rescue Aws::CloudWatchLogs::Errors::ServiceError => e
36
+ handle_aws_error(e)
37
+ end
38
+
39
+ def list_log_groups(prefix: nil)
40
+ options = {}
41
+ options[:log_group_name_prefix] = prefix if prefix
42
+
43
+ groups = []
44
+ aws_client.describe_log_groups(options).each_page do |page|
45
+ groups.concat(page.log_groups.map(&:log_group_name))
46
+ end
47
+ groups
48
+ rescue Aws::CloudWatchLogs::Errors::ServiceError => e
49
+ handle_aws_error(e)
50
+ end
51
+
52
+ private
53
+
54
+ def start_query(query_string:, log_group_names:, start_time:, end_time:, limit:)
55
+ response = aws_client.start_query(
56
+ log_group_names: Array(log_group_names),
57
+ start_time: start_time,
58
+ end_time: end_time,
59
+ query_string: query_string,
60
+ limit: limit
61
+ )
62
+ response.query_id
63
+ end
64
+
65
+ def poll_results(query_id, timeout: DEFAULT_TIMEOUT)
66
+ deadline = Time.now + timeout
67
+
68
+ loop do
69
+ raise TimeoutError, "Query timed out after #{timeout} seconds" if Time.now > deadline
70
+
71
+ response = aws_client.get_query_results(query_id: query_id)
72
+
73
+ case response.status
74
+ when "Complete"
75
+ return build_result_set(response)
76
+ when "Failed", "Cancelled"
77
+ raise QueryError, "Query #{response.status.downcase}"
78
+ when "Running", "Scheduled"
79
+ sleep(DEFAULT_POLL_INTERVAL)
80
+ else
81
+ raise QueryError, "Unknown query status: #{response.status}"
82
+ end
83
+ end
84
+ end
85
+
86
+ def build_result_set(response)
87
+ results = response.results.map do |row|
88
+ row.each_with_object({}) do |field, hash|
89
+ key = field.field.sub(/^@/, "")
90
+ hash[key] = field.value
91
+ end
92
+ end
93
+
94
+ ResultSet.new(
95
+ results: results,
96
+ statistics: response.statistics&.to_h || {},
97
+ registry: CloudwatchQuery.parsers
98
+ )
99
+ end
100
+
101
+ def handle_aws_error(error)
102
+ case error
103
+ when Aws::CloudWatchLogs::Errors::AccessDeniedException,
104
+ Aws::CloudWatchLogs::Errors::UnauthorizedException
105
+ raise AuthError, "AWS authentication failed: #{error.message}"
106
+ else
107
+ raise QueryError, "AWS error: #{error.message}"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloudwatchQuery
4
+ class Configuration
5
+ attr_accessor :region, :profile, :default_limit, :default_time_range
6
+
7
+ def initialize
8
+ @region = ENV["AWS_REGION"] || "us-west-1"
9
+ @profile = ENV["AWS_PROFILE"]
10
+ @default_limit = 100
11
+ @default_time_range = 3600
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloudwatchQuery
4
+ class Error < StandardError; end
5
+ class QueryError < Error; end
6
+ class ConfigError < Error; end
7
+ class AuthError < Error; end
8
+ class TimeoutError < Error; end
9
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloudwatchQuery
4
+ module Parsers
5
+ class Base
6
+ class << self
7
+ # Override in subclass - return true if this parser can handle the message
8
+ def matches?(message)
9
+ raise NotImplementedError, "#{name} must implement .matches?(message)"
10
+ end
11
+
12
+ # Override in subclass - parse the message and return a structured object
13
+ def parse(message)
14
+ raise NotImplementedError, "#{name} must implement .parse(message)"
15
+ end
16
+
17
+ # Human-readable name for this parser
18
+ def parser_name
19
+ name.split("::").last.sub(/Parser$/, "").gsub(/([a-z])([A-Z])/, '\1_\2').downcase
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloudwatchQuery
4
+ module Parsers
5
+ module Rails
6
+ class RailsLog
7
+ attr_reader :request_id, :line_type, :server, :process_id,
8
+ # Request fields
9
+ :http_method, :path, :ip_address, :request_timestamp,
10
+ # Parameters fields
11
+ :params,
12
+ # Redirect fields
13
+ :redirect_url,
14
+ # ActiveJob fields
15
+ :job_class, :job_id, :queue, :arguments,
16
+ # Processing fields
17
+ :controller, :action, :format,
18
+ # Completed fields
19
+ :status_code, :duration_ms,
20
+ # Raw message for unparsed line types
21
+ :raw_message
22
+
23
+ def initialize(attributes = {})
24
+ attributes.each do |key, value|
25
+ instance_variable_set("@#{key}", value) if respond_to?(key)
26
+ end
27
+ @line_type ||= :unknown
28
+ end
29
+
30
+ def type
31
+ :rails
32
+ end
33
+
34
+ def request?
35
+ line_type == :request
36
+ end
37
+
38
+ def parameters?
39
+ line_type == :parameters
40
+ end
41
+
42
+ def redirect?
43
+ line_type == :redirect
44
+ end
45
+
46
+ def active_job?
47
+ line_type == :active_job
48
+ end
49
+
50
+ def processing?
51
+ line_type == :processing
52
+ end
53
+
54
+ def completed?
55
+ line_type == :completed
56
+ end
57
+
58
+ def to_h
59
+ instance_variables.each_with_object({}) do |var, hash|
60
+ key = var.to_s.delete("@").to_sym
61
+ value = instance_variable_get(var)
62
+ hash[key] = value unless value.nil?
63
+ end
64
+ end
65
+
66
+ def [](key)
67
+ instance_variable_get("@#{key}")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end