sumologic-query 1.3.2 → 1.3.4

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.
data/lib/sumologic/cli.rb CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  require 'thor'
4
4
  require 'json'
5
+ require_relative 'cli/commands/search_command'
6
+ require_relative 'cli/commands/list_collectors_command'
7
+ require_relative 'cli/commands/list_sources_command'
5
8
 
6
9
  module Sumologic
7
10
  # Thor-based CLI for Sumo Logic query tool
11
+ # Delegates commands to specialized command classes
8
12
  class CLI < Thor
9
13
  class_option :debug, type: :boolean, aliases: '-d', desc: 'Enable debug output'
10
14
  class_option :output, type: :string, aliases: '-o', desc: 'Output file (default: stdout)'
@@ -13,49 +17,51 @@ module Sumologic
13
17
  true
14
18
  end
15
19
 
20
+ def initialize(*args)
21
+ super
22
+ $DEBUG = true if options[:debug]
23
+ end
24
+
16
25
  desc 'search', 'Search Sumo Logic logs'
17
26
  long_desc <<~DESC
18
27
  Search Sumo Logic logs using a query string.
19
28
 
29
+ Time Formats:
30
+ --from and --to support multiple formats:
31
+ • 'now' - current time
32
+ • Relative: '-30s', '-5m', '-2h', '-7d', '-1w', '-1M' (sec/min/hour/day/week/month)
33
+ • Unix timestamp: '1700000000' (seconds since epoch)
34
+ • ISO 8601: '2025-11-13T14:00:00'
35
+
20
36
  Examples:
21
- # Error timeline with 5-minute buckets
37
+ # Last 30 minutes
38
+ sumo-query search --query 'error' --from '-30m' --to 'now'
39
+
40
+ # Last hour with ISO format
22
41
  sumo-query search --query 'error | timeslice 5m | count' \\
23
42
  --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00'
24
43
 
25
- # Search for specific text
44
+ # Last 7 days
26
45
  sumo-query search --query '"connection timeout"' \\
27
- --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00' \\
28
- --limit 100
46
+ --from '-7d' --to 'now' --limit 100
47
+
48
+ # Using Unix timestamps
49
+ sumo-query search --query 'error' \\
50
+ --from '1700000000' --to '1700003600'
29
51
 
30
52
  # Interactive mode with FZF
31
53
  sumo-query search --query 'error' \\
32
- --from '2025-11-13T14:00:00' --to '2025-11-13T15:00:00' \\
33
- --interactive
54
+ --from '-1h' --to 'now' --interactive
34
55
  DESC
35
56
  option :query, type: :string, required: true, aliases: '-q', desc: 'Search query'
36
- option :from, type: :string, required: true, aliases: '-f', desc: 'Start time (ISO 8601)'
37
- option :to, type: :string, required: true, aliases: '-t', desc: 'End time (ISO 8601)'
38
- option :time_zone, type: :string, default: 'UTC', aliases: '-z', desc: 'Time zone'
57
+ option :from, type: :string, required: true, aliases: '-f', desc: 'Start time (now, -30m, unix timestamp, ISO 8601)'
58
+ option :to, type: :string, required: true, aliases: '-t', desc: 'End time (now, -30m, unix timestamp, ISO 8601)'
59
+ option :time_zone, type: :string, default: 'UTC', aliases: '-z',
60
+ desc: 'Time zone (UTC, EST, AEST, +00:00, America/New_York, Australia/Sydney)'
39
61
  option :limit, type: :numeric, aliases: '-l', desc: 'Maximum messages to return'
40
62
  option :interactive, type: :boolean, aliases: '-i', desc: 'Launch interactive browser (requires fzf)'
41
63
  def search
42
- $DEBUG = true if options[:debug]
43
-
44
- client = create_client
45
-
46
- log_search_info
47
- results = execute_search(client)
48
-
49
- warn '=' * 60
50
- warn "Results: #{results.size} messages"
51
- warn '=' * 60
52
- $stderr.puts
53
-
54
- if options[:interactive]
55
- launch_interactive_mode(results)
56
- else
57
- output_search_results(results)
58
- end
64
+ Commands::SearchCommand.new(options, create_client).execute
59
65
  end
60
66
 
61
67
  desc 'list-collectors', 'List all Sumo Logic collectors'
@@ -66,17 +72,7 @@ module Sumologic
66
72
  sumo-query list-collectors --output collectors.json
67
73
  DESC
68
74
  def list_collectors
69
- $DEBUG = true if options[:debug]
70
-
71
- client = create_client
72
-
73
- warn 'Fetching collectors...'
74
- collectors = client.list_collectors
75
-
76
- output_json(
77
- total: collectors.size,
78
- collectors: collectors.map { |c| format_collector(c) }
79
- )
75
+ Commands::ListCollectorsCommand.new(options, create_client).execute
80
76
  end
81
77
 
82
78
  desc 'list-sources', 'List sources from collectors'
@@ -92,15 +88,7 @@ module Sumologic
92
88
  DESC
93
89
  option :collector_id, type: :string, desc: 'Collector ID to list sources for'
94
90
  def list_sources
95
- $DEBUG = true if options[:debug]
96
-
97
- client = create_client
98
-
99
- if options[:collector_id]
100
- list_sources_for_collector(client, options[:collector_id])
101
- else
102
- list_all_sources(client)
103
- end
91
+ Commands::ListSourcesCommand.new(options, create_client).execute
104
92
  end
105
93
 
106
94
  desc 'version', 'Show version information'
@@ -127,120 +115,8 @@ module Sumologic
127
115
  exit 1
128
116
  end
129
117
 
130
- def list_sources_for_collector(client, collector_id)
131
- warn "Fetching sources for collector: #{collector_id}"
132
- sources = client.list_sources(collector_id: collector_id)
133
-
134
- output_json(
135
- collector_id: collector_id,
136
- total: sources.size,
137
- sources: sources.map { |s| format_source(s) }
138
- )
139
- end
140
-
141
- def list_all_sources(client)
142
- warn 'Fetching all sources from all collectors...'
143
- warn 'This may take a minute...'
144
-
145
- all_sources = client.list_all_sources
146
-
147
- output_json(
148
- total_collectors: all_sources.size,
149
- total_sources: all_sources.sum { |c| c['sources'].size },
150
- data: all_sources.map do |item|
151
- {
152
- collector: item['collector'],
153
- sources: item['sources'].map { |s| format_source(s) }
154
- }
155
- end
156
- )
157
- end
158
-
159
- def format_collector(collector)
160
- {
161
- id: collector['id'],
162
- name: collector['name'],
163
- collectorType: collector['collectorType'],
164
- alive: collector['alive'],
165
- category: collector['category']
166
- }
167
- end
168
-
169
- def format_source(source)
170
- {
171
- id: source['id'],
172
- name: source['name'],
173
- category: source['category'],
174
- sourceType: source['sourceType'],
175
- alive: source['alive']
176
- }
177
- end
178
-
179
- def output_json(data)
180
- json_output = JSON.pretty_generate(data)
181
-
182
- if options[:output]
183
- File.write(options[:output], json_output)
184
- warn "\nResults saved to: #{options[:output]}"
185
- else
186
- puts json_output
187
- end
188
- end
189
-
190
118
  def error(message)
191
119
  warn message
192
120
  end
193
-
194
- def log_search_info
195
- warn '=' * 60
196
- warn 'Sumo Logic Search Query'
197
- warn '=' * 60
198
- warn "Time Range: #{options[:from]} to #{options[:to]}"
199
- warn "Query: #{options[:query]}"
200
- warn "Limit: #{options[:limit] || 'unlimited'}"
201
- warn '-' * 60
202
- warn 'Creating search job...'
203
- $stderr.puts
204
- end
205
-
206
- def execute_search(client)
207
- client.search(
208
- query: options[:query],
209
- from_time: options[:from],
210
- to_time: options[:to],
211
- time_zone: options[:time_zone],
212
- limit: options[:limit]
213
- )
214
- end
215
-
216
- def output_search_results(results)
217
- output_json(
218
- query: options[:query],
219
- from: options[:from],
220
- to: options[:to],
221
- time_zone: options[:time_zone],
222
- message_count: results.size,
223
- messages: results
224
- )
225
- end
226
-
227
- def launch_interactive_mode(results)
228
- require_relative 'interactive'
229
-
230
- # Format results for interactive mode
231
- formatted_results = {
232
- 'query' => options[:query],
233
- 'from' => options[:from],
234
- 'to' => options[:to],
235
- 'time_zone' => options[:time_zone],
236
- 'message_count' => results.size,
237
- 'messages' => results
238
- }
239
-
240
- Sumologic::Interactive.launch(formatted_results)
241
- rescue Sumologic::Interactive::Error => e
242
- error e.message
243
- exit 1
244
- end
245
121
  end
246
122
  end
@@ -1,30 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'json'
5
- require 'uri'
6
3
  require_relative 'connection_pool'
4
+ require_relative 'debug_logger'
5
+ require_relative 'cookie_jar'
6
+ require_relative 'request_builder'
7
+ require_relative 'response_handler'
7
8
 
8
9
  module Sumologic
9
10
  module Http
10
- # Handles HTTP communication with Sumo Logic API
11
- # Responsibilities: request execution, error handling, SSL configuration
12
- # Uses connection pooling for thread-safe parallel requests
11
+ # Orchestrates HTTP communication with Sumo Logic API
12
+ # Delegates to specialized components for request building,
13
+ # response handling, connection pooling, and cookie management
13
14
  class Client
14
15
  def initialize(base_url:, authenticator:)
15
- @base_url = base_url
16
- @authenticator = authenticator
16
+ @cookie_jar = CookieJar.new
17
+ @request_builder = RequestBuilder.new(
18
+ base_url: base_url,
19
+ authenticator: authenticator,
20
+ cookie_jar: @cookie_jar
21
+ )
22
+ @response_handler = ResponseHandler.new
17
23
  @connection_pool = ConnectionPool.new(base_url: base_url, max_connections: 10)
18
24
  end
19
25
 
20
26
  # Execute HTTP request with error handling
21
27
  # Uses connection pool for thread-safe parallel execution
22
28
  def request(method:, path:, body: nil, query_params: nil)
23
- uri = build_uri(path, query_params)
24
- request = build_request(method, uri, body)
29
+ uri = @request_builder.build_uri(path, query_params)
30
+ request = @request_builder.build_request(method, uri, body)
31
+
32
+ DebugLogger.log_request(method, uri, body, request.to_hash)
25
33
 
26
34
  response = execute_request(uri, request)
27
- handle_response(response)
35
+
36
+ DebugLogger.log_response(response)
37
+
38
+ @response_handler.handle(response)
28
39
  rescue Errno::ECONNRESET, Errno::EPIPE, EOFError, Net::HTTPBadResponse => e
29
40
  # Connection error - raise for retry at higher level
30
41
  raise Error, "Connection error: #{e.message}"
@@ -37,49 +48,15 @@ module Sumologic
37
48
 
38
49
  private
39
50
 
40
- def build_uri(path, query_params)
41
- uri = URI("#{@base_url}#{path}")
42
- uri.query = URI.encode_www_form(query_params) if query_params
43
- uri
44
- end
45
-
46
- def build_request(method, uri, body)
47
- request_class = case method
48
- when :get then Net::HTTP::Get
49
- when :post then Net::HTTP::Post
50
- when :delete then Net::HTTP::Delete
51
- else raise ArgumentError, "Unsupported HTTP method: #{method}"
52
- end
53
-
54
- request = request_class.new(uri)
55
- request['Authorization'] = @authenticator.auth_header
56
- request['Accept'] = 'application/json'
57
-
58
- if body
59
- request['Content-Type'] = 'application/json'
60
- request.body = body.to_json
61
- end
62
-
63
- request
64
- end
65
-
66
51
  def execute_request(uri, request)
67
- @connection_pool.with_connection(uri) do |http|
52
+ response = @connection_pool.with_connection(uri) do |http|
68
53
  http.request(request)
69
54
  end
70
- end
71
55
 
72
- def handle_response(response)
73
- case response.code.to_i
74
- when 200..299
75
- JSON.parse(response.body)
76
- when 401, 403
77
- raise AuthenticationError, "Authentication failed: #{response.body}"
78
- when 429
79
- raise Error, "Rate limit exceeded: #{response.body}"
80
- else
81
- raise Error, "HTTP #{response.code}: #{response.body}"
82
- end
56
+ # Store cookies from response for subsequent requests
57
+ @cookie_jar.store_from_response(response)
58
+
59
+ response
83
60
  end
84
61
  end
85
62
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+
3
5
  module Sumologic
4
6
  module Http
5
7
  # Thread-safe connection pool for HTTP clients
@@ -84,10 +86,22 @@ module Sumologic
84
86
  http.read_timeout = READ_TIMEOUT
85
87
  http.open_timeout = OPEN_TIMEOUT
86
88
  http.keep_alive_timeout = 30
89
+
90
+ # SSL configuration
91
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
92
+ http.cert_store = ssl_cert_store
93
+
87
94
  http.start
88
95
  http
89
96
  end
90
97
 
98
+ def ssl_cert_store
99
+ # Use system's default certificate store
100
+ store = OpenSSL::X509::Store.new
101
+ store.set_default_paths
102
+ store
103
+ end
104
+
91
105
  def create_temporary_connection(uri)
92
106
  # Fallback: create a temporary connection if pool is exhausted
93
107
  create_connection(uri)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Http
5
+ # Simple cookie jar for storing and managing HTTP cookies
6
+ # Handles Set-Cookie response headers and Cookie request headers
7
+ class CookieJar
8
+ def initialize
9
+ @cookies = {}
10
+ end
11
+
12
+ # Store cookies from response Set-Cookie headers
13
+ def store_from_response(response)
14
+ return unless response['Set-Cookie']
15
+
16
+ Array(response['Set-Cookie']).each do |cookie_header|
17
+ parse_and_store(cookie_header)
18
+ end
19
+ end
20
+
21
+ # Format cookies for Cookie request header
22
+ # Returns nil if no cookies stored
23
+ def to_header
24
+ return nil if @cookies.empty?
25
+
26
+ @cookies.map { |name, value| "#{name}=#{value}" }.join('; ')
27
+ end
28
+
29
+ # Check if any cookies are stored
30
+ def any?
31
+ @cookies.any?
32
+ end
33
+
34
+ # Clear all stored cookies
35
+ def clear
36
+ @cookies.clear
37
+ end
38
+
39
+ private
40
+
41
+ def parse_and_store(cookie_header)
42
+ # Parse cookie name=value (ignore path, domain, expires, etc.)
43
+ # Example: "session_id=abc123; Path=/; HttpOnly"
44
+ return unless cookie_header =~ /^([^=]+)=([^;]+)/
45
+
46
+ name = Regexp.last_match(1).strip
47
+ value = Regexp.last_match(2).strip
48
+ @cookies[name] = value
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Sumologic
6
+ module Http
7
+ # Handles debug logging for HTTP requests and responses
8
+ # Only logs when $DEBUG is enabled
9
+ module DebugLogger
10
+ module_function
11
+
12
+ def log_request(method, uri, body, headers = {})
13
+ return unless $DEBUG
14
+
15
+ warn "\n[DEBUG] API Request:"
16
+ warn " Method: #{method.to_s.upcase}"
17
+ warn " URL: #{uri}"
18
+ warn " Cookie: #{headers['Cookie']}" if headers['Cookie']
19
+ log_request_body(body) if body
20
+ warn ''
21
+ end
22
+
23
+ def log_response(response)
24
+ return unless $DEBUG
25
+
26
+ warn '[DEBUG] API Response:'
27
+ warn " Status: #{response.code} #{response.message}"
28
+ log_response_body(response.body)
29
+ warn ''
30
+ end
31
+
32
+ def log_request_body(body)
33
+ warn " Body: #{JSON.pretty_generate(body)}"
34
+ rescue JSON::GeneratorError
35
+ warn " Body: #{body.inspect}"
36
+ end
37
+
38
+ def log_response_body(body)
39
+ truncated = body.length > 500
40
+ display_body = truncated ? "#{body[0..500]}..." : body
41
+ warn " Body: #{display_body}"
42
+ warn " (truncated, full length: #{body.length} characters)" if truncated
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Sumologic
8
+ module Http
9
+ # Builds HTTP requests with proper headers, authentication, and cookies
10
+ class RequestBuilder
11
+ def initialize(base_url:, authenticator:, cookie_jar:)
12
+ @base_url = base_url
13
+ @authenticator = authenticator
14
+ @cookie_jar = cookie_jar
15
+ end
16
+
17
+ # Build complete URI from path and query parameters
18
+ def build_uri(path, query_params = nil)
19
+ uri = URI("#{@base_url}#{path}")
20
+ uri.query = URI.encode_www_form(query_params) if query_params
21
+ uri
22
+ end
23
+
24
+ # Build HTTP request with all necessary headers
25
+ def build_request(method, uri, body = nil)
26
+ request = create_request_object(method, uri)
27
+ add_headers(request)
28
+ add_body(request, body) if body
29
+ request
30
+ end
31
+
32
+ private
33
+
34
+ def create_request_object(method, uri)
35
+ request_class = request_class_for(method)
36
+ request_class.new(uri)
37
+ end
38
+
39
+ def request_class_for(method)
40
+ case method
41
+ when :get then Net::HTTP::Get
42
+ when :post then Net::HTTP::Post
43
+ when :delete then Net::HTTP::Delete
44
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
45
+ end
46
+ end
47
+
48
+ def add_headers(request)
49
+ request['Authorization'] = @authenticator.auth_header
50
+ request['Accept'] = 'application/json'
51
+
52
+ # Add cookies if available
53
+ cookie_header = @cookie_jar.to_header
54
+ request['Cookie'] = cookie_header if cookie_header
55
+ end
56
+
57
+ def add_body(request, body)
58
+ request['Content-Type'] = 'application/json'
59
+ request.body = body.to_json
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Sumologic
6
+ module Http
7
+ # Handles HTTP response parsing and error handling
8
+ class ResponseHandler
9
+ # Parse response and handle errors
10
+ def handle(response)
11
+ case response.code.to_i
12
+ when 200..299
13
+ parse_success(response)
14
+ when 401, 403
15
+ handle_authentication_error(response)
16
+ when 429
17
+ handle_rate_limit_error(response)
18
+ else
19
+ handle_generic_error(response)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def parse_success(response)
26
+ JSON.parse(response.body)
27
+ end
28
+
29
+ def handle_authentication_error(response)
30
+ raise AuthenticationError, "Authentication failed: #{response.body}"
31
+ end
32
+
33
+ def handle_rate_limit_error(response)
34
+ raise Error, "Rate limit exceeded: #{response.body}"
35
+ end
36
+
37
+ def handle_generic_error(response)
38
+ raise Error, "HTTP #{response.code}: #{response.body}"
39
+ end
40
+ end
41
+ end
42
+ end