sumologic-query 1.1.0 → 1.1.2

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,208 @@
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
+ desc 'version', 'Show version information'
93
+ def version
94
+ puts "sumo-query version #{Sumologic::VERSION}"
95
+ end
96
+ map %w[-v --version] => :version
97
+
98
+ default_task :search
99
+
100
+ private
101
+
102
+ def create_client
103
+ Client.new
104
+ rescue AuthenticationError => e
105
+ error "Authentication Error: #{e.message}"
106
+ error "\nPlease set environment variables:"
107
+ error " export SUMO_ACCESS_ID='your_access_id'"
108
+ error " export SUMO_ACCESS_KEY='your_access_key'"
109
+ error " export SUMO_DEPLOYMENT='us2' # Optional, defaults to us2"
110
+ exit 1
111
+ rescue Error => e
112
+ error "Error: #{e.message}"
113
+ exit 1
114
+ end
115
+
116
+ def list_sources_for_collector(client, collector_id)
117
+ warn "Fetching sources for collector: #{collector_id}"
118
+ sources = client.list_sources(collector_id: collector_id)
119
+
120
+ output_json(
121
+ collector_id: collector_id,
122
+ total: sources.size,
123
+ sources: sources.map { |s| format_source(s) }
124
+ )
125
+ end
126
+
127
+ def list_all_sources(client)
128
+ warn 'Fetching all sources from all collectors...'
129
+ warn 'This may take a minute...'
130
+
131
+ all_sources = client.list_all_sources
132
+
133
+ output_json(
134
+ total_collectors: all_sources.size,
135
+ total_sources: all_sources.sum { |c| c['sources'].size },
136
+ data: all_sources.map do |item|
137
+ {
138
+ collector: item['collector'],
139
+ sources: item['sources'].map { |s| format_source(s) }
140
+ }
141
+ end
142
+ )
143
+ end
144
+
145
+ def format_collector(collector)
146
+ {
147
+ id: collector['id'],
148
+ name: collector['name'],
149
+ collectorType: collector['collectorType'],
150
+ alive: collector['alive'],
151
+ category: collector['category']
152
+ }
153
+ end
154
+
155
+ def format_source(source)
156
+ {
157
+ id: source['id'],
158
+ name: source['name'],
159
+ category: source['category'],
160
+ sourceType: source['sourceType'],
161
+ alive: source['alive']
162
+ }
163
+ end
164
+
165
+ def output_json(data)
166
+ json_output = JSON.pretty_generate(data)
167
+
168
+ if options[:output]
169
+ File.write(options[:output], json_output)
170
+ warn "\nResults saved to: #{options[:output]}"
171
+ else
172
+ puts json_output
173
+ end
174
+ end
175
+
176
+ def error(message)
177
+ warn message
178
+ end
179
+
180
+ def log_search_info
181
+ warn "Querying Sumo Logic: #{options[:from]} to #{options[:to]}"
182
+ warn "Query: #{options[:query]}"
183
+ warn 'This may take 1-3 minutes depending on data volume...'
184
+ $stderr.puts
185
+ end
186
+
187
+ def execute_search(client)
188
+ client.search(
189
+ query: options[:query],
190
+ from_time: options[:from],
191
+ to_time: options[:to],
192
+ time_zone: options[:time_zone],
193
+ limit: options[:limit]
194
+ )
195
+ end
196
+
197
+ def output_search_results(results)
198
+ output_json(
199
+ query: options[:query],
200
+ from: options[:from],
201
+ to: options[:to],
202
+ time_zone: options[:time_zone],
203
+ message_count: results.size,
204
+ messages: results
205
+ )
206
+ end
207
+ end
208
+ 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