sumologic-query 1.0.1 → 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 +4 -4
- data/CHANGELOG.md +8 -27
- data/bin/sumo-query +5 -101
- data/lib/sumologic/cli.rb +202 -0
- data/lib/sumologic/client.rb +44 -188
- data/lib/sumologic/configuration.rb +55 -0
- data/lib/sumologic/http/authenticator.rb +20 -0
- data/lib/sumologic/http/client.rb +80 -0
- data/lib/sumologic/metadata/collector.rb +33 -0
- data/lib/sumologic/metadata/source.rb +72 -0
- data/lib/sumologic/search/job.rb +68 -0
- data/lib/sumologic/search/paginator.rb +67 -0
- data/lib/sumologic/search/poller.rb +80 -0
- data/lib/sumologic/version.rb +1 -1
- data/lib/sumologic.rb +33 -2
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94a41b01c95f9975b2caa3fd6e35ed59f3a0a50ea1bef0a382984db8b28a2df1
|
|
4
|
+
data.tar.gz: 2a2af32a87f880dfbea771d08e1ba0aff16015a14da80742ee90f8bcaa8f653c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 42e019ecf001cf27fe9c4a6fb09ab3830c94e4d198b150ea9a34854a4cb77c72b0d769b61cce671dc3fb548b2d5bf6f7b8df97fcea08175bde3897f205ff8d24
|
|
7
|
+
data.tar.gz: 5f83a1f886d1b2d3e995be899b9b093bfb03738b9626ca5a5c7c2c06fecf88da037d017fe894b5c0ad014ec3a9a5bdabc0173eeb748b6e07bb013452e1cfd833
|
data/CHANGELOG.md
CHANGED
|
@@ -1,34 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to this project
|
|
3
|
+
All notable changes to this project are documented in [GitHub Releases](https://github.com/patrick204nqh/sumologic-query/releases).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
6
|
|
|
8
|
-
##
|
|
7
|
+
## Releases
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
- Core `Sumologic::Client` class for Search Job API
|
|
13
|
-
- Command-line interface with query, time range, and output options
|
|
14
|
-
- Automatic job polling with 20-second intervals
|
|
15
|
-
- Automatic pagination for large result sets (10K messages per request)
|
|
16
|
-
- Support for multiple Sumo Logic deployments (us1, us2, eu, au)
|
|
17
|
-
- Environment variable configuration (SUMO_ACCESS_ID, SUMO_ACCESS_KEY, SUMO_DEPLOYMENT)
|
|
18
|
-
- Debug mode for troubleshooting (SUMO_DEBUG)
|
|
19
|
-
- JSON output format with metadata
|
|
20
|
-
- Zero external dependencies (stdlib only)
|
|
21
|
-
- Comprehensive error handling and user-friendly messages
|
|
22
|
-
- MIT license
|
|
23
|
-
- Complete documentation and examples
|
|
9
|
+
- [v1.1.0](https://github.com/patrick204nqh/sumologic-query/releases/tag/v1.1.0) - Latest
|
|
10
|
+
- [v1.0.0](https://github.com/patrick204nqh/sumologic-query/releases/tag/v1.0.0) - Initial release
|
|
24
11
|
|
|
25
|
-
|
|
26
|
-
- Query historical logs via Search Job API
|
|
27
|
-
- Time range filtering (ISO 8601 format)
|
|
28
|
-
- Message limiting
|
|
29
|
-
- Timezone support
|
|
30
|
-
- File or stdout output
|
|
31
|
-
- 5-minute default timeout
|
|
32
|
-
- Graceful cleanup of search jobs
|
|
12
|
+
---
|
|
33
13
|
|
|
34
|
-
|
|
14
|
+
**Note:** Release notes are automatically generated from commit messages and pull requests.
|
|
15
|
+
See [GitHub Releases](https://github.com/patrick204nqh/sumologic-query/releases) for detailed changelogs.
|
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
|
-
|
|
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
|
data/lib/sumologic/client.rb
CHANGED
|
@@ -1,203 +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
|
-
#
|
|
10
|
-
#
|
|
4
|
+
# Facade for Sumo Logic API operations
|
|
5
|
+
# Coordinates HTTP, Search, and Metadata components
|
|
11
6
|
class Client
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
31
|
def search(query:, from_time:, to_time:, time_zone: 'UTC', limit: nil)
|
|
31
|
-
|
|
32
|
-
poll_until_complete(job_id)
|
|
33
|
-
messages = fetch_all_messages(job_id, limit)
|
|
34
|
-
delete_job(job_id)
|
|
35
|
-
messages
|
|
36
|
-
rescue StandardError => e
|
|
37
|
-
delete_job(job_id) if job_id
|
|
38
|
-
raise Error, "Search failed: #{e.message}"
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def validate_credentials!
|
|
44
|
-
raise AuthenticationError, 'SUMO_ACCESS_ID not set' unless @access_id
|
|
45
|
-
raise AuthenticationError, 'SUMO_ACCESS_KEY not set' unless @access_key
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def deployment_url(deployment)
|
|
49
|
-
case deployment
|
|
50
|
-
when /^http/
|
|
51
|
-
deployment # Full URL provided
|
|
52
|
-
when 'us1'
|
|
53
|
-
'https://api.sumologic.com/api/v1'
|
|
54
|
-
when 'us2'
|
|
55
|
-
'https://api.us2.sumologic.com/api/v1'
|
|
56
|
-
when 'eu'
|
|
57
|
-
'https://api.eu.sumologic.com/api/v1'
|
|
58
|
-
when 'au'
|
|
59
|
-
'https://api.au.sumologic.com/api/v1'
|
|
60
|
-
else
|
|
61
|
-
"https://api.#{deployment}.sumologic.com/api/v1"
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def auth_header
|
|
66
|
-
encoded = Base64.strict_encode64("#{@access_id}:#{@access_key}")
|
|
67
|
-
"Basic #{encoded}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def create_job(query, from_time, to_time, time_zone)
|
|
71
|
-
uri = URI("#{@base_url}/search/jobs")
|
|
72
|
-
request = Net::HTTP::Post.new(uri)
|
|
73
|
-
request['Authorization'] = auth_header
|
|
74
|
-
request['Content-Type'] = 'application/json'
|
|
75
|
-
request['Accept'] = 'application/json'
|
|
76
|
-
|
|
77
|
-
body = {
|
|
32
|
+
@search.execute(
|
|
78
33
|
query: query,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
response = http_request(uri, request)
|
|
86
|
-
data = JSON.parse(response.body)
|
|
87
|
-
|
|
88
|
-
raise Error, "Failed to create job: #{data['message']}" unless data['id']
|
|
89
|
-
|
|
90
|
-
log_info "Created search job: #{data['id']}"
|
|
91
|
-
data['id']
|
|
34
|
+
from_time: from_time,
|
|
35
|
+
to_time: to_time,
|
|
36
|
+
time_zone: time_zone,
|
|
37
|
+
limit: limit
|
|
38
|
+
)
|
|
92
39
|
end
|
|
93
40
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
loop do
|
|
100
|
-
raise TimeoutError, "Search job timed out after #{timeout} seconds" if Time.now - start_time > timeout
|
|
101
|
-
|
|
102
|
-
request = Net::HTTP::Get.new(uri)
|
|
103
|
-
request['Authorization'] = auth_header
|
|
104
|
-
request['Accept'] = 'application/json'
|
|
105
|
-
|
|
106
|
-
response = http_request(uri, request)
|
|
107
|
-
data = JSON.parse(response.body)
|
|
108
|
-
|
|
109
|
-
state = data['state']
|
|
110
|
-
log_info "Job state: #{state} (#{data['messageCount']} messages, #{data['recordCount']} records)"
|
|
111
|
-
|
|
112
|
-
case state
|
|
113
|
-
when 'DONE GATHERING RESULTS'
|
|
114
|
-
return data
|
|
115
|
-
when 'CANCELLED', 'FORCE PAUSED'
|
|
116
|
-
raise Error, "Search job #{state.downcase}"
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
sleep interval
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def fetch_all_messages(job_id, limit = nil)
|
|
124
|
-
messages = []
|
|
125
|
-
offset = 0
|
|
126
|
-
total_fetched = 0
|
|
127
|
-
|
|
128
|
-
loop do
|
|
129
|
-
batch_limit = if limit
|
|
130
|
-
[MAX_MESSAGES_PER_REQUEST, limit - total_fetched].min
|
|
131
|
-
else
|
|
132
|
-
MAX_MESSAGES_PER_REQUEST
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
break if batch_limit <= 0
|
|
136
|
-
|
|
137
|
-
uri = URI("#{@base_url}/search/jobs/#{job_id}/messages")
|
|
138
|
-
uri.query = URI.encode_www_form(offset: offset, limit: batch_limit)
|
|
139
|
-
|
|
140
|
-
request = Net::HTTP::Get.new(uri)
|
|
141
|
-
request['Authorization'] = auth_header
|
|
142
|
-
request['Accept'] = 'application/json'
|
|
143
|
-
|
|
144
|
-
response = http_request(uri, request)
|
|
145
|
-
data = JSON.parse(response.body)
|
|
146
|
-
|
|
147
|
-
batch = data['messages'] || []
|
|
148
|
-
messages.concat(batch)
|
|
149
|
-
total_fetched += batch.size
|
|
150
|
-
|
|
151
|
-
log_info "Fetched #{batch.size} messages (total: #{total_fetched})"
|
|
152
|
-
|
|
153
|
-
break if batch.size < batch_limit # No more messages
|
|
154
|
-
break if limit && total_fetched >= limit
|
|
155
|
-
|
|
156
|
-
offset += batch.size
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
messages
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def delete_job(job_id)
|
|
163
|
-
return unless job_id
|
|
164
|
-
|
|
165
|
-
uri = URI("#{@base_url}/search/jobs/#{job_id}")
|
|
166
|
-
request = Net::HTTP::Delete.new(uri)
|
|
167
|
-
request['Authorization'] = auth_header
|
|
168
|
-
|
|
169
|
-
http_request(uri, request)
|
|
170
|
-
log_info "Deleted search job: #{job_id}"
|
|
171
|
-
rescue StandardError => e
|
|
172
|
-
log_error "Failed to delete job #{job_id}: #{e.message}"
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def http_request(uri, request)
|
|
176
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
177
|
-
http.use_ssl = true
|
|
178
|
-
http.read_timeout = 60
|
|
179
|
-
http.open_timeout = 10
|
|
180
|
-
|
|
181
|
-
response = http.request(request)
|
|
182
|
-
|
|
183
|
-
case response.code.to_i
|
|
184
|
-
when 200..299
|
|
185
|
-
response
|
|
186
|
-
when 401, 403
|
|
187
|
-
raise AuthenticationError, "Authentication failed: #{response.body}"
|
|
188
|
-
when 429
|
|
189
|
-
raise Error, "Rate limit exceeded: #{response.body}"
|
|
190
|
-
else
|
|
191
|
-
raise Error, "HTTP #{response.code}: #{response.body}"
|
|
192
|
-
end
|
|
41
|
+
# List all collectors
|
|
42
|
+
# Returns array of collector objects
|
|
43
|
+
def list_collectors
|
|
44
|
+
@collector.list
|
|
193
45
|
end
|
|
194
46
|
|
|
195
|
-
|
|
196
|
-
|
|
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)
|
|
197
51
|
end
|
|
198
52
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
201
57
|
end
|
|
202
58
|
end
|
|
203
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
|
data/lib/sumologic/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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-
|
|
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:
|