sumologic-query 1.1.0 → 1.1.1

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: 045b4c6496e38a665e48c98c37beb3fbf95477f8af17f507725ccf882dc63076
4
- data.tar.gz: fb5d931c84e347065ee258d0da508eaf3d2ab847dd978b33dd33151ee544939e
3
+ metadata.gz: 94a41b01c95f9975b2caa3fd6e35ed59f3a0a50ea1bef0a382984db8b28a2df1
4
+ data.tar.gz: 2a2af32a87f880dfbea771d08e1ba0aff16015a14da80742ee90f8bcaa8f653c
5
5
  SHA512:
6
- metadata.gz: 8f943fba2e5cb46fff13aaec60c1a898284ca745f2e357cafb88b68c6d55e8fae29fcb617761c18da7898ef27e1903185b50c076052b33b2b0a1249459caecda
7
- data.tar.gz: 77c6f9270a9874a442749834c7f89e3ced18769ec89735ceba3746e1a2d2bb37541d32a752f186b673b01e6da67ae82ed67eaa60804255494913d120d1ea19e2
6
+ metadata.gz: 42e019ecf001cf27fe9c4a6fb09ab3830c94e4d198b150ea9a34854a4cb77c72b0d769b61cce671dc3fb548b2d5bf6f7b8df97fcea08175bde3897f205ff8d24
7
+ data.tar.gz: 5f83a1f886d1b2d3e995be899b9b093bfb03738b9626ca5a5c7c2c06fecf88da037d017fe894b5c0ad014ec3a9a5bdabc0173eeb748b6e07bb013452e1cfd833
data/bin/sumo-query CHANGED
@@ -1,110 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Simple CLI wrapper for querying Sumo Logic logs
5
- # Usage: sumo-query --query "error" --from "2025-11-13T14:00:00" --to "2025-11-13T15:00:00"
6
-
7
4
  require_relative '../lib/sumologic'
8
- require 'optparse'
9
- require 'json'
10
-
11
- options = {
12
- time_zone: 'UTC'
13
- }
14
-
15
- OptionParser.new do |opts|
16
- opts.banner = <<~BANNER
17
- Sumo Logic Query Tool - Fast, read-only log access
18
-
19
- Usage: sumo-query [options]
20
-
21
- Examples:
22
- # Error timeline with 5-minute buckets
23
- sumo-query --query 'error | timeslice 5m | count' \\
24
- --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00'
25
-
26
- # Search for specific text
27
- sumo-query --query '"connection timeout"' \\
28
- --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00' \\
29
- --limit 100
30
-
31
- # Filter by source category
32
- sumo-query --query '_sourceCategory=prod/api error' \\
33
- --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00' \\
34
- --output results.json
35
-
36
- Options:
37
- BANNER
38
-
39
- opts.on('-q', '--query QUERY', 'Search query (required)') { |v| options[:query] = v }
40
- opts.on('-f', '--from TIME', 'Start time in ISO 8601 format (required)') { |v| options[:from] = v }
41
- opts.on('-t', '--to TIME', 'End time in ISO 8601 format (required)') { |v| options[:to] = v }
42
- opts.on('-z', '--time-zone TZ', 'Time zone (default: UTC)') { |v| options[:time_zone] = v }
43
- opts.on('-l', '--limit N', Integer, 'Limit number of messages') { |v| options[:limit] = v }
44
- opts.on('-o', '--output FILE', 'Output file (default: stdout)') { |v| options[:output] = v }
45
- opts.on('-d', '--debug', 'Enable debug output') { $DEBUG = true }
46
- opts.on('-h', '--help', 'Show this help') do
47
- puts opts
48
- exit
49
- end
50
- opts.on('-v', '--version', 'Show version') do
51
- puts "sumologic-query v#{Sumologic::VERSION}"
52
- exit
53
- end
54
- end.parse!
55
-
56
- # Validate required options
57
- unless options[:query] && options[:from] && options[:to]
58
- warn 'Error: --query, --from, and --to are required'
59
- warn 'Run with --help for usage information'
60
- exit 1
61
- end
62
5
 
63
6
  begin
64
- # Create client
65
- client = Sumologic::Client.new
66
-
67
- warn "Querying Sumo Logic: #{options[:from]} to #{options[:to]}"
68
- warn "Query: #{options[:query]}"
69
- warn 'This may take 1-3 minutes depending on data volume...'
70
- $stderr.puts
71
-
72
- # Execute search
73
- results = client.search(
74
- query: options[:query],
75
- from_time: options[:from],
76
- to_time: options[:to],
77
- time_zone: options[:time_zone],
78
- limit: options[:limit]
79
- )
80
-
81
- # Format output
82
- output = {
83
- query: options[:query],
84
- from: options[:from],
85
- to: options[:to],
86
- time_zone: options[:time_zone],
87
- message_count: results.size,
88
- messages: results
89
- }
90
-
91
- json_output = JSON.pretty_generate(output)
92
-
93
- # Write to file or stdout
94
- if options[:output]
95
- File.write(options[:output], json_output)
96
- warn "\nResults saved to: #{options[:output]}"
97
- warn "Message count: #{results.size}"
98
- else
99
- puts json_output
100
- end
101
- rescue Sumologic::AuthenticationError => e
102
- warn "\nAuthentication Error: #{e.message}"
103
- warn "\nPlease set environment variables:"
104
- warn " export SUMO_ACCESS_ID='your_access_id'"
105
- warn " export SUMO_ACCESS_KEY='your_access_key'"
106
- warn " export SUMO_DEPLOYMENT='us2' # Optional, defaults to us2"
107
- exit 1
7
+ Sumologic::CLI.start(ARGV)
108
8
  rescue Sumologic::TimeoutError => e
109
9
  warn "\nTimeout Error: #{e.message}"
110
10
  warn "\nTry:"
@@ -115,6 +15,10 @@ rescue Sumologic::TimeoutError => e
115
15
  rescue Sumologic::Error => e
116
16
  warn "\nError: #{e.message}"
117
17
  exit 1
18
+ rescue StandardError => e
19
+ warn "\nUnexpected Error: #{e.message}"
20
+ warn e.backtrace.first(5).join("\n") if $DEBUG
21
+ exit 1
118
22
  rescue Interrupt
119
23
  warn "\nInterrupted by user"
120
24
  exit 130
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'json'
5
+
6
+ module Sumologic
7
+ # Thor-based CLI for Sumo Logic query tool
8
+ class CLI < Thor
9
+ class_option :debug, type: :boolean, aliases: '-d', desc: 'Enable debug output'
10
+ class_option :output, type: :string, aliases: '-o', desc: 'Output file (default: stdout)'
11
+
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ desc 'search', 'Search Sumo Logic logs'
17
+ long_desc <<~DESC
18
+ Search Sumo Logic logs using a query string.
19
+
20
+ Examples:
21
+ # Error timeline with 5-minute buckets
22
+ sumo-query search --query 'error | timeslice 5m | count' \\
23
+ --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00'
24
+
25
+ # Search for specific text
26
+ sumo-query search --query '"connection timeout"' \\
27
+ --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00' \\
28
+ --limit 100
29
+ DESC
30
+ option :query, type: :string, required: true, aliases: '-q', desc: 'Search query'
31
+ option :from, type: :string, required: true, aliases: '-f', desc: 'Start time (ISO 8601)'
32
+ option :to, type: :string, required: true, aliases: '-t', desc: 'End time (ISO 8601)'
33
+ option :time_zone, type: :string, default: 'UTC', aliases: '-z', desc: 'Time zone'
34
+ option :limit, type: :numeric, aliases: '-l', desc: 'Limit number of messages'
35
+ def search
36
+ $DEBUG = true if options[:debug]
37
+
38
+ client = create_client
39
+
40
+ log_search_info
41
+ results = execute_search(client)
42
+ output_search_results(results)
43
+
44
+ warn "\nMessage count: #{results.size}"
45
+ end
46
+
47
+ desc 'list-collectors', 'List all Sumo Logic collectors'
48
+ long_desc <<~DESC
49
+ List all collectors in your Sumo Logic account.
50
+
51
+ Example:
52
+ sumo-query list-collectors --output collectors.json
53
+ DESC
54
+ def list_collectors
55
+ $DEBUG = true if options[:debug]
56
+
57
+ client = create_client
58
+
59
+ warn 'Fetching collectors...'
60
+ collectors = client.list_collectors
61
+
62
+ output_json(
63
+ total: collectors.size,
64
+ collectors: collectors.map { |c| format_collector(c) }
65
+ )
66
+ end
67
+
68
+ desc 'list-sources', 'List sources from collectors'
69
+ long_desc <<~DESC
70
+ List all sources from all collectors, or sources from a specific collector.
71
+
72
+ Examples:
73
+ # List all sources
74
+ sumo-query list-sources
75
+
76
+ # List sources for specific collector
77
+ sumo-query list-sources --collector-id 12345
78
+ DESC
79
+ option :collector_id, type: :string, desc: 'Collector ID to list sources for'
80
+ def list_sources
81
+ $DEBUG = true if options[:debug]
82
+
83
+ client = create_client
84
+
85
+ if options[:collector_id]
86
+ list_sources_for_collector(client, options[:collector_id])
87
+ else
88
+ list_all_sources(client)
89
+ end
90
+ end
91
+
92
+ default_task :search
93
+
94
+ private
95
+
96
+ def create_client
97
+ Client.new
98
+ rescue AuthenticationError => e
99
+ error "Authentication Error: #{e.message}"
100
+ error "\nPlease set environment variables:"
101
+ error " export SUMO_ACCESS_ID='your_access_id'"
102
+ error " export SUMO_ACCESS_KEY='your_access_key'"
103
+ error " export SUMO_DEPLOYMENT='us2' # Optional, defaults to us2"
104
+ exit 1
105
+ rescue Error => e
106
+ error "Error: #{e.message}"
107
+ exit 1
108
+ end
109
+
110
+ def list_sources_for_collector(client, collector_id)
111
+ warn "Fetching sources for collector: #{collector_id}"
112
+ sources = client.list_sources(collector_id: collector_id)
113
+
114
+ output_json(
115
+ collector_id: collector_id,
116
+ total: sources.size,
117
+ sources: sources.map { |s| format_source(s) }
118
+ )
119
+ end
120
+
121
+ def list_all_sources(client)
122
+ warn 'Fetching all sources from all collectors...'
123
+ warn 'This may take a minute...'
124
+
125
+ all_sources = client.list_all_sources
126
+
127
+ output_json(
128
+ total_collectors: all_sources.size,
129
+ total_sources: all_sources.sum { |c| c['sources'].size },
130
+ data: all_sources.map do |item|
131
+ {
132
+ collector: item['collector'],
133
+ sources: item['sources'].map { |s| format_source(s) }
134
+ }
135
+ end
136
+ )
137
+ end
138
+
139
+ def format_collector(collector)
140
+ {
141
+ id: collector['id'],
142
+ name: collector['name'],
143
+ collectorType: collector['collectorType'],
144
+ alive: collector['alive'],
145
+ category: collector['category']
146
+ }
147
+ end
148
+
149
+ def format_source(source)
150
+ {
151
+ id: source['id'],
152
+ name: source['name'],
153
+ category: source['category'],
154
+ sourceType: source['sourceType'],
155
+ alive: source['alive']
156
+ }
157
+ end
158
+
159
+ def output_json(data)
160
+ json_output = JSON.pretty_generate(data)
161
+
162
+ if options[:output]
163
+ File.write(options[:output], json_output)
164
+ warn "\nResults saved to: #{options[:output]}"
165
+ else
166
+ puts json_output
167
+ end
168
+ end
169
+
170
+ def error(message)
171
+ warn message
172
+ end
173
+
174
+ def log_search_info
175
+ warn "Querying Sumo Logic: #{options[:from]} to #{options[:to]}"
176
+ warn "Query: #{options[:query]}"
177
+ warn 'This may take 1-3 minutes depending on data volume...'
178
+ $stderr.puts
179
+ end
180
+
181
+ def execute_search(client)
182
+ client.search(
183
+ query: options[:query],
184
+ from_time: options[:from],
185
+ to_time: options[:to],
186
+ time_zone: options[:time_zone],
187
+ limit: options[:limit]
188
+ )
189
+ end
190
+
191
+ def output_search_results(results)
192
+ output_json(
193
+ query: options[:query],
194
+ from: options[:from],
195
+ to: options[:to],
196
+ time_zone: options[:time_zone],
197
+ message_count: results.size,
198
+ messages: results
199
+ )
200
+ end
201
+ end
202
+ end
@@ -1,215 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'json'
5
- require 'uri'
6
- require 'base64'
7
-
8
3
  module Sumologic
9
- # Lightweight Sumo Logic Search Job API client
10
- # Handles historical log queries with automatic polling and pagination
4
+ # Facade for Sumo Logic API operations
5
+ # Coordinates HTTP, Search, and Metadata components
11
6
  class Client
12
- API_VERSION = 'v1'
13
- INITIAL_POLL_INTERVAL = 5 # seconds - start fast for small queries
14
- MAX_POLL_INTERVAL = 20 # seconds - slow down for large queries
15
- POLL_BACKOFF_FACTOR = 1.5 # increase interval by 50% each time
16
- DEFAULT_TIMEOUT = 300 # seconds (5 minutes)
17
- MAX_MESSAGES_PER_REQUEST = 10_000
18
-
19
- attr_reader :access_id, :access_key, :deployment, :base_url
20
-
21
- def initialize(access_id: nil, access_key: nil, deployment: nil)
22
- @access_id = access_id || ENV.fetch('SUMO_ACCESS_ID', nil)
23
- @access_key = access_key || ENV.fetch('SUMO_ACCESS_KEY', nil)
24
- @deployment = deployment || ENV['SUMO_DEPLOYMENT'] || 'us2'
25
- @base_url = deployment_url(@deployment)
26
-
27
- validate_credentials!
28
- end
29
-
30
- # Main search method
31
- # Returns array of messages/records as JSON
7
+ attr_reader :config
8
+
9
+ def initialize(config = nil)
10
+ @config = config || Configuration.new
11
+ @config.validate!
12
+
13
+ # Initialize HTTP layer
14
+ authenticator = Http::Authenticator.new(
15
+ access_id: @config.access_id,
16
+ access_key: @config.access_key
17
+ )
18
+ @http = Http::Client.new(
19
+ base_url: @config.base_url,
20
+ authenticator: authenticator
21
+ )
22
+
23
+ # Initialize domain components
24
+ @search = Search::Job.new(http_client: @http, config: @config)
25
+ @collector = Metadata::Collector.new(http_client: @http)
26
+ @source = Metadata::Source.new(http_client: @http, collector_client: @collector)
27
+ end
28
+
29
+ # Search logs with query
30
+ # Returns array of messages
32
31
  def search(query:, from_time:, to_time:, time_zone: 'UTC', limit: nil)
33
- job_id = create_job(query, from_time, to_time, time_zone)
34
- poll_until_complete(job_id)
35
- messages = fetch_all_messages(job_id, limit)
36
- delete_job(job_id)
37
- messages
38
- rescue StandardError => e
39
- delete_job(job_id) if job_id
40
- raise Error, "Search failed: #{e.message}"
41
- end
42
-
43
- private
44
-
45
- def validate_credentials!
46
- raise AuthenticationError, 'SUMO_ACCESS_ID not set' unless @access_id
47
- raise AuthenticationError, 'SUMO_ACCESS_KEY not set' unless @access_key
48
- end
49
-
50
- def deployment_url(deployment)
51
- case deployment
52
- when /^http/
53
- deployment # Full URL provided
54
- when 'us1'
55
- 'https://api.sumologic.com/api/v1'
56
- when 'us2'
57
- 'https://api.us2.sumologic.com/api/v1'
58
- when 'eu'
59
- 'https://api.eu.sumologic.com/api/v1'
60
- when 'au'
61
- 'https://api.au.sumologic.com/api/v1'
62
- else
63
- "https://api.#{deployment}.sumologic.com/api/v1"
64
- end
65
- end
66
-
67
- def auth_header
68
- encoded = Base64.strict_encode64("#{@access_id}:#{@access_key}")
69
- "Basic #{encoded}"
70
- end
71
-
72
- def create_job(query, from_time, to_time, time_zone)
73
- uri = URI("#{@base_url}/search/jobs")
74
- request = Net::HTTP::Post.new(uri)
75
- request['Authorization'] = auth_header
76
- request['Content-Type'] = 'application/json'
77
- request['Accept'] = 'application/json'
78
-
79
- body = {
32
+ @search.execute(
80
33
  query: query,
81
- from: from_time,
82
- to: to_time,
83
- timeZone: time_zone
84
- }
85
- request.body = body.to_json
86
-
87
- response = http_request(uri, request)
88
- data = JSON.parse(response.body)
89
-
90
- raise Error, "Failed to create job: #{data['message']}" unless data['id']
91
-
92
- log_info "Created search job: #{data['id']}"
93
- data['id']
94
- end
95
-
96
- def poll_until_complete(job_id, timeout: DEFAULT_TIMEOUT)
97
- uri = URI("#{@base_url}/search/jobs/#{job_id}")
98
- start_time = Time.now
99
- interval = INITIAL_POLL_INTERVAL
100
- poll_count = 0
101
-
102
- loop do
103
- raise TimeoutError, "Search job timed out after #{timeout} seconds" if Time.now - start_time > timeout
104
-
105
- request = Net::HTTP::Get.new(uri)
106
- request['Authorization'] = auth_header
107
- request['Accept'] = 'application/json'
108
-
109
- response = http_request(uri, request)
110
- data = JSON.parse(response.body)
111
-
112
- state = data['state']
113
- msg_count = data['messageCount']
114
- rec_count = data['recordCount']
115
- log_info "Job state: #{state} (#{msg_count} messages, #{rec_count} records) [interval: #{interval}s]"
116
-
117
- case state
118
- when 'DONE GATHERING RESULTS'
119
- elapsed = Time.now - start_time
120
- log_info "Job completed in #{elapsed.round(1)} seconds after #{poll_count + 1} polls"
121
- return data
122
- when 'CANCELLED', 'FORCE PAUSED'
123
- raise Error, "Search job #{state.downcase}"
124
- end
125
-
126
- sleep interval
127
- poll_count += 1
128
-
129
- # Adaptive backoff: gradually increase interval for long-running jobs
130
- # This reduces API calls while maintaining responsiveness for quick jobs
131
- interval = [interval * POLL_BACKOFF_FACTOR, MAX_POLL_INTERVAL].min
132
- end
34
+ from_time: from_time,
35
+ to_time: to_time,
36
+ time_zone: time_zone,
37
+ limit: limit
38
+ )
133
39
  end
134
40
 
135
- def fetch_all_messages(job_id, limit = nil)
136
- messages = []
137
- offset = 0
138
- total_fetched = 0
139
-
140
- loop do
141
- batch_limit = if limit
142
- [MAX_MESSAGES_PER_REQUEST, limit - total_fetched].min
143
- else
144
- MAX_MESSAGES_PER_REQUEST
145
- end
146
-
147
- break if batch_limit <= 0
148
-
149
- uri = URI("#{@base_url}/search/jobs/#{job_id}/messages")
150
- uri.query = URI.encode_www_form(offset: offset, limit: batch_limit)
151
-
152
- request = Net::HTTP::Get.new(uri)
153
- request['Authorization'] = auth_header
154
- request['Accept'] = 'application/json'
155
-
156
- response = http_request(uri, request)
157
- data = JSON.parse(response.body)
158
-
159
- batch = data['messages'] || []
160
- messages.concat(batch)
161
- total_fetched += batch.size
162
-
163
- log_info "Fetched #{batch.size} messages (total: #{total_fetched})"
164
-
165
- break if batch.size < batch_limit # No more messages
166
- break if limit && total_fetched >= limit
167
-
168
- offset += batch.size
169
- end
170
-
171
- messages
172
- end
173
-
174
- def delete_job(job_id)
175
- return unless job_id
176
-
177
- uri = URI("#{@base_url}/search/jobs/#{job_id}")
178
- request = Net::HTTP::Delete.new(uri)
179
- request['Authorization'] = auth_header
180
-
181
- http_request(uri, request)
182
- log_info "Deleted search job: #{job_id}"
183
- rescue StandardError => e
184
- log_error "Failed to delete job #{job_id}: #{e.message}"
185
- end
186
-
187
- def http_request(uri, request)
188
- http = Net::HTTP.new(uri.host, uri.port)
189
- http.use_ssl = true
190
- http.read_timeout = 60
191
- http.open_timeout = 10
192
-
193
- response = http.request(request)
194
-
195
- case response.code.to_i
196
- when 200..299
197
- response
198
- when 401, 403
199
- raise AuthenticationError, "Authentication failed: #{response.body}"
200
- when 429
201
- raise Error, "Rate limit exceeded: #{response.body}"
202
- else
203
- raise Error, "HTTP #{response.code}: #{response.body}"
204
- end
41
+ # List all collectors
42
+ # Returns array of collector objects
43
+ def list_collectors
44
+ @collector.list
205
45
  end
206
46
 
207
- def log_info(message)
208
- warn "[Sumologic::Client] #{message}" if ENV['SUMO_DEBUG'] || $DEBUG
47
+ # List sources for a specific collector
48
+ # Returns array of source objects
49
+ def list_sources(collector_id:)
50
+ @source.list(collector_id: collector_id)
209
51
  end
210
52
 
211
- def log_error(message)
212
- warn "[Sumologic::Client ERROR] #{message}"
53
+ # List all sources from all collectors
54
+ # Returns array of hashes with collector and sources
55
+ def list_all_sources
56
+ @source.list_all
213
57
  end
214
58
  end
215
59
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ # Centralized configuration for Sumo Logic client
5
+ class Configuration
6
+ attr_accessor :access_id, :access_key, :deployment, :timeout, :initial_poll_interval, :max_poll_interval,
7
+ :poll_backoff_factor, :max_messages_per_request
8
+
9
+ API_VERSION = 'v1'
10
+
11
+ def initialize
12
+ # Authentication
13
+ @access_id = ENV.fetch('SUMO_ACCESS_ID', nil)
14
+ @access_key = ENV.fetch('SUMO_ACCESS_KEY', nil)
15
+ @deployment = ENV['SUMO_DEPLOYMENT'] || 'us2'
16
+
17
+ # Search job polling
18
+ @initial_poll_interval = 5 # seconds - start fast for small queries
19
+ @max_poll_interval = 20 # seconds - slow down for large queries
20
+ @poll_backoff_factor = 1.5 # increase interval by 50% each time
21
+
22
+ # Timeouts and limits
23
+ @timeout = 300 # seconds (5 minutes)
24
+ @max_messages_per_request = 10_000
25
+ end
26
+
27
+ def base_url
28
+ @base_url ||= build_base_url
29
+ end
30
+
31
+ def validate!
32
+ raise AuthenticationError, 'SUMO_ACCESS_ID not set' unless @access_id
33
+ raise AuthenticationError, 'SUMO_ACCESS_KEY not set' unless @access_key
34
+ end
35
+
36
+ private
37
+
38
+ def build_base_url
39
+ case @deployment
40
+ when /^http/
41
+ @deployment # Full URL provided
42
+ when 'us1'
43
+ "https://api.sumologic.com/api/#{API_VERSION}"
44
+ when 'us2'
45
+ "https://api.us2.sumologic.com/api/#{API_VERSION}"
46
+ when 'eu'
47
+ "https://api.eu.sumologic.com/api/#{API_VERSION}"
48
+ when 'au'
49
+ "https://api.au.sumologic.com/api/#{API_VERSION}"
50
+ else
51
+ "https://api.#{@deployment}.sumologic.com/api/#{API_VERSION}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Sumologic
6
+ module Http
7
+ # Handles authentication header generation for Sumo Logic API
8
+ class Authenticator
9
+ def initialize(access_id:, access_key:)
10
+ @access_id = access_id
11
+ @access_key = access_key
12
+ end
13
+
14
+ def auth_header
15
+ encoded = Base64.strict_encode64("#{@access_id}:#{@access_key}")
16
+ "Basic #{encoded}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Sumologic
8
+ module Http
9
+ # Handles HTTP communication with Sumo Logic API
10
+ # Responsibilities: request execution, error handling, SSL configuration
11
+ class Client
12
+ READ_TIMEOUT = 60
13
+ OPEN_TIMEOUT = 10
14
+
15
+ def initialize(base_url:, authenticator:)
16
+ @base_url = base_url
17
+ @authenticator = authenticator
18
+ end
19
+
20
+ # Execute HTTP request with error handling
21
+ def request(method:, path:, body: nil, query_params: nil)
22
+ uri = build_uri(path, query_params)
23
+ request = build_request(method, uri, body)
24
+
25
+ response = execute_request(uri, request)
26
+ handle_response(response)
27
+ end
28
+
29
+ private
30
+
31
+ def build_uri(path, query_params)
32
+ uri = URI("#{@base_url}#{path}")
33
+ uri.query = URI.encode_www_form(query_params) if query_params
34
+ uri
35
+ end
36
+
37
+ def build_request(method, uri, body)
38
+ request_class = case method
39
+ when :get then Net::HTTP::Get
40
+ when :post then Net::HTTP::Post
41
+ when :delete then Net::HTTP::Delete
42
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
43
+ end
44
+
45
+ request = request_class.new(uri)
46
+ request['Authorization'] = @authenticator.auth_header
47
+ request['Accept'] = 'application/json'
48
+
49
+ if body
50
+ request['Content-Type'] = 'application/json'
51
+ request.body = body.to_json
52
+ end
53
+
54
+ request
55
+ end
56
+
57
+ def execute_request(uri, request)
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.use_ssl = true
60
+ http.read_timeout = READ_TIMEOUT
61
+ http.open_timeout = OPEN_TIMEOUT
62
+
63
+ http.request(request)
64
+ end
65
+
66
+ def handle_response(response)
67
+ case response.code.to_i
68
+ when 200..299
69
+ JSON.parse(response.body)
70
+ when 401, 403
71
+ raise AuthenticationError, "Authentication failed: #{response.body}"
72
+ when 429
73
+ raise Error, "Rate limit exceeded: #{response.body}"
74
+ else
75
+ raise Error, "HTTP #{response.code}: #{response.body}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Metadata
5
+ # Handles collector metadata operations
6
+ class Collector
7
+ def initialize(http_client:)
8
+ @http = http_client
9
+ end
10
+
11
+ # List all collectors
12
+ # Returns array of collector objects
13
+ def list
14
+ data = @http.request(
15
+ method: :get,
16
+ path: '/collectors'
17
+ )
18
+
19
+ collectors = data['collectors'] || []
20
+ log_info "Found #{collectors.size} collectors"
21
+ collectors
22
+ rescue StandardError => e
23
+ raise Error, "Failed to list collectors: #{e.message}"
24
+ end
25
+
26
+ private
27
+
28
+ def log_info(message)
29
+ warn "[Sumologic::Metadata::Collector] #{message}" if ENV['SUMO_DEBUG'] || $DEBUG
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Metadata
5
+ # Handles source metadata operations
6
+ class Source
7
+ def initialize(http_client:, collector_client:)
8
+ @http = http_client
9
+ @collector_client = collector_client
10
+ end
11
+
12
+ # List sources for a specific collector
13
+ # Returns array of source objects with metadata
14
+ def list(collector_id:)
15
+ data = @http.request(
16
+ method: :get,
17
+ path: "/collectors/#{collector_id}/sources"
18
+ )
19
+
20
+ sources = data['sources'] || []
21
+ log_info "Found #{sources.size} sources for collector #{collector_id}"
22
+ sources
23
+ rescue StandardError => e
24
+ raise Error, "Failed to list sources for collector #{collector_id}: #{e.message}"
25
+ end
26
+
27
+ # List all sources from all collectors
28
+ # Returns array of hashes with collector info and their sources
29
+ def list_all
30
+ collectors = @collector_client.list
31
+ result = []
32
+
33
+ collectors.each do |collector|
34
+ next unless collector['alive'] # Skip offline collectors
35
+
36
+ collector_id = collector['id']
37
+ collector_name = collector['name']
38
+
39
+ log_info "Fetching sources for collector: #{collector_name} (#{collector_id})"
40
+
41
+ sources = list(collector_id: collector_id)
42
+
43
+ result << {
44
+ 'collector' => {
45
+ 'id' => collector_id,
46
+ 'name' => collector_name,
47
+ 'collectorType' => collector['collectorType']
48
+ },
49
+ 'sources' => sources
50
+ }
51
+ rescue StandardError => e
52
+ log_error "Failed to fetch sources for collector #{collector_name}: #{e.message}"
53
+ end
54
+
55
+ log_info "Total: #{result.size} collectors with sources"
56
+ result
57
+ rescue StandardError => e
58
+ raise Error, "Failed to list all sources: #{e.message}"
59
+ end
60
+
61
+ private
62
+
63
+ def log_info(message)
64
+ warn "[Sumologic::Metadata::Source] #{message}" if ENV['SUMO_DEBUG'] || $DEBUG
65
+ end
66
+
67
+ def log_error(message)
68
+ warn "[Sumologic::Metadata::Source ERROR] #{message}"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Search
5
+ # Manages search job lifecycle: create, poll, fetch, delete
6
+ class Job
7
+ def initialize(http_client:, config:)
8
+ @http = http_client
9
+ @config = config
10
+ @poller = Poller.new(http_client: http_client, config: config)
11
+ @paginator = Paginator.new(http_client: http_client, config: config)
12
+ end
13
+
14
+ # Execute a complete search workflow
15
+ # Returns array of messages
16
+ def execute(query:, from_time:, to_time:, time_zone: 'UTC', limit: nil)
17
+ job_id = create(query, from_time, to_time, time_zone)
18
+ @poller.poll(job_id)
19
+ messages = @paginator.fetch_all(job_id, limit: limit)
20
+ delete(job_id)
21
+ messages
22
+ rescue StandardError => e
23
+ delete(job_id) if job_id
24
+ raise Error, "Search failed: #{e.message}"
25
+ end
26
+
27
+ private
28
+
29
+ def create(query, from_time, to_time, time_zone)
30
+ data = @http.request(
31
+ method: :post,
32
+ path: '/search/jobs',
33
+ body: {
34
+ query: query,
35
+ from: from_time,
36
+ to: to_time,
37
+ timeZone: time_zone
38
+ }
39
+ )
40
+
41
+ raise Error, "Failed to create job: #{data['message']}" unless data['id']
42
+
43
+ log_info "Created search job: #{data['id']}"
44
+ data['id']
45
+ end
46
+
47
+ def delete(job_id)
48
+ return unless job_id
49
+
50
+ @http.request(
51
+ method: :delete,
52
+ path: "/search/jobs/#{job_id}"
53
+ )
54
+ log_info "Deleted search job: #{job_id}"
55
+ rescue StandardError => e
56
+ log_error "Failed to delete job #{job_id}: #{e.message}"
57
+ end
58
+
59
+ def log_info(message)
60
+ warn "[Sumologic::Search::Job] #{message}" if ENV['SUMO_DEBUG'] || $DEBUG
61
+ end
62
+
63
+ def log_error(message)
64
+ warn "[Sumologic::Search::Job ERROR] #{message}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Search
5
+ # Handles paginated fetching of search job messages
6
+ class Paginator
7
+ def initialize(http_client:, config:)
8
+ @http = http_client
9
+ @config = config
10
+ end
11
+
12
+ # Fetch all messages for a job with automatic pagination
13
+ # Returns array of message objects
14
+ def fetch_all(job_id, limit: nil)
15
+ messages = []
16
+ offset = 0
17
+ total_fetched = 0
18
+
19
+ loop do
20
+ batch_limit = calculate_batch_limit(limit, total_fetched)
21
+ break if batch_limit <= 0
22
+
23
+ batch = fetch_batch(job_id, offset, batch_limit)
24
+ messages.concat(batch)
25
+ total_fetched += batch.size
26
+
27
+ log_progress(batch.size, total_fetched)
28
+
29
+ break if batch.size < batch_limit # No more messages
30
+ break if limit && total_fetched >= limit
31
+
32
+ offset += batch.size
33
+ end
34
+
35
+ messages
36
+ end
37
+
38
+ private
39
+
40
+ def calculate_batch_limit(user_limit, total_fetched)
41
+ if user_limit
42
+ [@config.max_messages_per_request, user_limit - total_fetched].min
43
+ else
44
+ @config.max_messages_per_request
45
+ end
46
+ end
47
+
48
+ def fetch_batch(job_id, offset, limit)
49
+ data = @http.request(
50
+ method: :get,
51
+ path: "/search/jobs/#{job_id}/messages",
52
+ query_params: { offset: offset, limit: limit }
53
+ )
54
+
55
+ data['messages'] || []
56
+ end
57
+
58
+ def log_progress(batch_size, total)
59
+ log_info "Fetched #{batch_size} messages (total: #{total})"
60
+ end
61
+
62
+ def log_info(message)
63
+ warn "[Sumologic::Search::Paginator] #{message}" if ENV['SUMO_DEBUG'] || $DEBUG
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Search
5
+ # Handles adaptive polling of search jobs with exponential backoff
6
+ class Poller
7
+ def initialize(http_client:, config:)
8
+ @http = http_client
9
+ @config = config
10
+ end
11
+
12
+ # Poll until job completes or times out
13
+ # Returns final job status data
14
+ def poll(job_id)
15
+ start_time = Time.now
16
+ interval = @config.initial_poll_interval
17
+ poll_count = 0
18
+
19
+ loop do
20
+ check_timeout!(start_time)
21
+
22
+ data = fetch_job_status(job_id)
23
+ state = data['state']
24
+
25
+ log_poll_status(state, data, interval, poll_count)
26
+
27
+ case state
28
+ when 'DONE GATHERING RESULTS'
29
+ log_completion(start_time, poll_count)
30
+ return data
31
+ when 'CANCELLED', 'FORCE PAUSED'
32
+ raise Error, "Search job #{state.downcase}"
33
+ end
34
+
35
+ sleep interval
36
+ poll_count += 1
37
+ interval = calculate_next_interval(interval)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def check_timeout!(start_time)
44
+ elapsed = Time.now - start_time
45
+ return unless elapsed > @config.timeout
46
+
47
+ raise TimeoutError, "Search job timed out after #{@config.timeout} seconds"
48
+ end
49
+
50
+ def fetch_job_status(job_id)
51
+ @http.request(
52
+ method: :get,
53
+ path: "/search/jobs/#{job_id}"
54
+ )
55
+ end
56
+
57
+ def calculate_next_interval(current_interval)
58
+ # Adaptive backoff: gradually increase interval for long-running jobs
59
+ new_interval = current_interval * @config.poll_backoff_factor
60
+ [new_interval, @config.max_poll_interval].min
61
+ end
62
+
63
+ def log_poll_status(state, data, interval, count)
64
+ msg_count = data['messageCount']
65
+ rec_count = data['recordCount']
66
+ log_info "Job state: #{state} (#{msg_count} messages, #{rec_count} records) " \
67
+ "[interval: #{interval}s, poll: #{count}]"
68
+ end
69
+
70
+ def log_completion(start_time, poll_count)
71
+ elapsed = Time.now - start_time
72
+ log_info "Job completed in #{elapsed.round(1)} seconds after #{poll_count + 1} polls"
73
+ end
74
+
75
+ def log_info(message)
76
+ warn "[Sumologic::Search::Poller] #{message}" if ENV['SUMO_DEBUG'] || $DEBUG
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sumologic
4
- VERSION = '1.1.0'
4
+ VERSION = '1.1.1'
5
5
  end
data/lib/sumologic.rb CHANGED
@@ -1,10 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'sumologic/version'
4
- require_relative 'sumologic/client'
5
4
 
6
5
  module Sumologic
6
+ # Base error class for all Sumologic errors
7
7
  class Error < StandardError; end
8
- class TimeoutError < Error; end
8
+
9
+ # Authentication-related errors
9
10
  class AuthenticationError < Error; end
11
+
12
+ # Timeout errors during search job execution
13
+ class TimeoutError < Error; end
14
+ end
15
+
16
+ # Load configuration first
17
+ require_relative 'sumologic/configuration'
18
+
19
+ # Load HTTP layer
20
+ require_relative 'sumologic/http/authenticator'
21
+ require_relative 'sumologic/http/client'
22
+
23
+ # Load search domain
24
+ require_relative 'sumologic/search/poller'
25
+ require_relative 'sumologic/search/paginator'
26
+ require_relative 'sumologic/search/job'
27
+
28
+ # Load metadata domain
29
+ require_relative 'sumologic/metadata/collector'
30
+ require_relative 'sumologic/metadata/source'
31
+
32
+ # Load main client (facade)
33
+ require_relative 'sumologic/client'
34
+
35
+ # Load CLI (requires thor gem)
36
+ begin
37
+ require 'thor'
38
+ require_relative 'sumologic/cli'
39
+ rescue LoadError
40
+ # Thor not available - CLI won't work but library will
10
41
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sumologic-query
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - patrick204nqh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-13 00:00:00.000000000 Z
11
+ date: 2025-11-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: thor
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rake
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -82,7 +96,16 @@ files:
82
96
  - README.md
83
97
  - bin/sumo-query
84
98
  - lib/sumologic.rb
99
+ - lib/sumologic/cli.rb
85
100
  - lib/sumologic/client.rb
101
+ - lib/sumologic/configuration.rb
102
+ - lib/sumologic/http/authenticator.rb
103
+ - lib/sumologic/http/client.rb
104
+ - lib/sumologic/metadata/collector.rb
105
+ - lib/sumologic/metadata/source.rb
106
+ - lib/sumologic/search/job.rb
107
+ - lib/sumologic/search/paginator.rb
108
+ - lib/sumologic/search/poller.rb
86
109
  - lib/sumologic/version.rb
87
110
  homepage: https://github.com/patrick204nqh/sumologic-query
88
111
  licenses: