scout_apm_mcp 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e4f9717b9ae8caf19fa92e364acc8fd7183a27ca686b8c587748ec017bb1764
4
- data.tar.gz: 7c8261b3a2f17d320d69051c67e152d851e0fe701c93fca62cd68c92f069475b
3
+ metadata.gz: fe458cddc45b80969f47263d81bbe57f2050d3202b905a55afa60369aa9ed6d7
4
+ data.tar.gz: de2e96c9019de8727d7752134e84f6e19a2d66dc14247cd34b8d6ee7df125983
5
5
  SHA512:
6
- metadata.gz: ba2f1f24479d17fb119c08c1ca14b22a2059e9ee0f3ca5047fc47c2a57b8eece7e2fb6541036434a8e623fb8781c2eeb95a9b91ce3dfe576ae615a8a1ba6bf5e
7
- data.tar.gz: 94291c406ff5d7d5755349de80e03e6ca7001201c8d3642d613404ec2e1a0dcb0d96641439fca0ef5442a2738f5f20168fbee38219de6578ffe7551edf398c6e
6
+ metadata.gz: 34116b86965f6e2f15dd857a1e271e40966ccd92bb4a6d782c3d8a87bf58098b41129ef4caff286f91fe4857127b0a24b2b354988735e0faab5afaeebb0d2cda
7
+ data.tar.gz: 0b53a613b27315899f3f416fd589327b57969758ed5c0db5923c644b3f50788765b6b4264146e4668ee50901c79923f8c20295f130fb2e7b5dab2c46130e8e74
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.2.0 (2026-07-05)
6
+
7
+ - Add `list_anomaly_events` and `get_anomaly_event` client methods and MCP tools.
8
+ - Add `sort_by`, `limit`, and `offset` to `list_endpoints` for paginated endpoint listings.
9
+ - Return extracted `results` from `get_insights_history` and `get_insights_history_by_type`; validate `insight_type` on history-by-type requests.
10
+ - Retry transient ScoutAPM GET failures (5xx and network timeouts) with bounded exponential backoff.
11
+ - Fix `FetchTraceTool` `include_endpoint` so `trace_metric_name` is read from the extracted trace payload.
12
+ - Fix MCP JSON-RPC error responses for strict MCP clients.
13
+ - Reuse one ScoutAPM API client per MCP server process instead of creating a client per tool call.
14
+
3
15
  ## 0.1.6 (2026-03-29)
4
16
 
5
17
  - Background jobs API support: `list_jobs`, `list_job_metrics`, `get_job_metrics`, `list_job_traces` on the client, matching MCP tools, and URL parsing / `FetchScoutURLTool` handling for job and job-trace links.
data/README.md CHANGED
@@ -1,15 +1,9 @@
1
1
  # scout_apm_mcp
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/scout_apm_mcp.svg?v=0.1.3)](https://badge.fury.io/rb/scout_apm_mcp) [![Test Status](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/scout_apm_mcp.rb/graph/badge.svg?token=HVWDDNLEO5)](https://codecov.io/gh/amkisko/scout_apm_mcp.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/scout_apm_mcp.svg?v=0.2.0)](https://badge.fury.io/rb/scout_apm_mcp) [![Test Status](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/scout_apm_mcp.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/scout_apm_mcp.rb/graph/badge.svg?token=HVWDDNLEO5)](https://codecov.io/gh/amkisko/scout_apm_mcp.rb)
4
4
 
5
5
  Ruby gem providing ScoutAPM API client and MCP (Model Context Protocol) server tools for fetching traces, endpoints, metrics, errors, and insights. Integrates with MCP-compatible clients like Cursor IDE, Claude Desktop, and other MCP-enabled tools.
6
6
 
7
- Sponsored by [Kisko Labs](https://www.kiskolabs.com).
8
-
9
- <a href="https://www.kiskolabs.com">
10
- <img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
11
- </a>
12
-
13
7
  ## Requirements
14
8
 
15
9
  - **Ruby 3.1 or higher** (Ruby 3.0 and earlier are not supported)
@@ -378,3 +372,10 @@ If you discover a security vulnerability, please report it responsibly. See [SEC
378
372
 
379
373
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
380
374
 
375
+ ## Sponsors
376
+
377
+ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
378
+
379
+ <a href="https://www.kiskolabs.com">
380
+ <img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
381
+ </a>
@@ -0,0 +1,221 @@
1
+ module ScoutApmMcp
2
+ class Client
3
+ module Api
4
+ module Apps
5
+ def list_apps(active_since: nil)
6
+ uri = URI("#{@api_base}/apps")
7
+ response = make_request(uri)
8
+ apps = response.dig("results", "apps") || []
9
+ return apps unless active_since
10
+
11
+ active_time = Helpers.parse_time(active_since)
12
+ apps.select do |app|
13
+ reported_at = app["last_reported_at"]
14
+ reported_at && !reported_at.empty? && Helpers.parse_time(reported_at) >= active_time
15
+ end
16
+ end
17
+
18
+ def get_app(app_id)
19
+ uri = URI("#{@api_base}/apps/#{app_id}")
20
+ response = make_request(uri)
21
+ response.dig("results", "app") || {}
22
+ end
23
+ end
24
+
25
+ module Metrics
26
+ def list_metrics(app_id)
27
+ uri = URI("#{@api_base}/apps/#{app_id}/metrics")
28
+ response = make_request(uri)
29
+ response.dig("results", "availableMetrics") || []
30
+ end
31
+
32
+ def get_metric(app_id, metric_type, from: nil, to: nil, range: nil)
33
+ times = resolve_range_times(from: from, to: to, range: range)
34
+ validate_metric_params(metric_type, times[:from], times[:to])
35
+ uri = URI("#{@api_base}/apps/#{app_id}/metrics/#{metric_type}")
36
+ assign_query(uri, from: times[:from], to: times[:to])
37
+ response = make_request(uri)
38
+ response.dig("results", "series") || {}
39
+ end
40
+ end
41
+
42
+ module Endpoints
43
+ def list_endpoints(app_id, from: nil, to: nil, range: nil, sort_by: nil, limit: nil, offset: nil)
44
+ times = resolve_listing_time_range(from: from, to: to, range: range)
45
+ validate_sort_by!(sort_by)
46
+
47
+ uri = URI("#{@api_base}/apps/#{app_id}/endpoints")
48
+ assign_query(uri, from: times[:from], to: times[:to], sort_by: sort_by, limit: limit, offset: offset)
49
+ response = make_request(uri)
50
+ endpoint_listing_results(response, sort_by: sort_by, limit: limit, offset: offset)
51
+ end
52
+
53
+ def get_endpoint_metrics(app_id, endpoint_id, metric_type, from: nil, to: nil, range: nil)
54
+ times = resolve_range_times(from: from, to: to, range: range)
55
+ validate_metric_params(metric_type, times[:from], times[:to])
56
+ uri = api_uri("apps", app_id, "endpoints", endpoint_id, "metrics", metric_type)
57
+ assign_query(uri, from: times[:from], to: times[:to])
58
+ response = make_request(uri)
59
+ series = response.dig("results", "series") || {}
60
+ series[metric_type] || []
61
+ end
62
+
63
+ def list_endpoint_traces(app_id, endpoint_id, from: nil, to: nil, range: nil)
64
+ times = resolve_range_times(from: from, to: to, range: range)
65
+ validate_trace_time_range(times[:from], times[:to])
66
+ uri = api_uri("apps", app_id, "endpoints", endpoint_id, "traces")
67
+ assign_query(uri, from: times[:from], to: times[:to])
68
+ response = make_request(uri)
69
+ response.dig("results", "traces") || []
70
+ end
71
+
72
+ private
73
+
74
+ def endpoint_listing_results(response, sort_by:, limit:, offset:)
75
+ results = response["results"]
76
+ paginated = !sort_by.nil? || !limit.nil? || !offset.nil?
77
+ paginated ? (results || {}) : (results || [])
78
+ end
79
+ end
80
+
81
+ module Jobs
82
+ def list_jobs(app_id, from: nil, to: nil, range: nil)
83
+ times = resolve_listing_time_range(from: from, to: to, range: range)
84
+ uri = URI("#{@api_base}/apps/#{app_id}/jobs")
85
+ assign_query(uri, from: times[:from], to: times[:to])
86
+ response = make_request(uri)
87
+ response["results"] || []
88
+ end
89
+
90
+ def list_job_metrics(app_id, job_id)
91
+ uri = api_uri("apps", app_id, "jobs", job_id, "metrics")
92
+ response = make_request(uri)
93
+ response.dig("results", "availableMetrics") || []
94
+ end
95
+
96
+ def get_job_metrics(app_id, job_id, metric_type, from: nil, to: nil, range: nil)
97
+ times = resolve_range_times(from: from, to: to, range: range)
98
+ validate_job_metric_params(metric_type, times[:from], times[:to])
99
+ uri = api_uri("apps", app_id, "jobs", job_id, "metrics", metric_type)
100
+ assign_query(uri, from: times[:from], to: times[:to])
101
+ response = make_request(uri)
102
+ series = response.dig("results", "series") || {}
103
+ series[metric_type] || []
104
+ end
105
+
106
+ def list_job_traces(app_id, job_id, from: nil, to: nil, range: nil)
107
+ times = resolve_range_times(from: from, to: to, range: range)
108
+ validate_trace_time_range(times[:from], times[:to])
109
+ uri = api_uri("apps", app_id, "jobs", job_id, "traces")
110
+ assign_query(uri, from: times[:from], to: times[:to])
111
+ response = make_request(uri)
112
+ response.dig("results", "traces") || []
113
+ end
114
+ end
115
+
116
+ module Traces
117
+ def fetch_trace(app_id, trace_id)
118
+ uri = URI("#{@api_base}/apps/#{app_id}/traces/#{trace_id}")
119
+ response = make_request(uri)
120
+ response.dig("results", "trace") || {}
121
+ end
122
+ end
123
+
124
+ module ErrorGroups
125
+ def list_error_groups(app_id, from: nil, to: nil, endpoint: nil)
126
+ validate_time_range(from, to) if from && to
127
+ uri = URI("#{@api_base}/apps/#{app_id}/error_groups")
128
+ assign_query(uri, from: from, to: to, endpoint: endpoint)
129
+ response = make_request(uri)
130
+ response.dig("results", "error_groups") || []
131
+ end
132
+
133
+ def get_error_group(app_id, error_id)
134
+ uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}")
135
+ response = make_request(uri)
136
+ response.dig("results", "error_group") || {}
137
+ end
138
+
139
+ def get_error_group_errors(app_id, error_id)
140
+ uri = URI("#{@api_base}/apps/#{app_id}/error_groups/#{error_id}/errors")
141
+ response = make_request(uri)
142
+ response.dig("results", "errors") || []
143
+ end
144
+ end
145
+
146
+ module AnomalyEvents
147
+ def list_anomaly_events(app_id, from: nil, to: nil, range: nil, state: nil, metric: nil, endpoint: nil)
148
+ times = resolve_range_times(from: from, to: to, range: range)
149
+ validate_time_range(times[:from], times[:to]) if times[:from] && times[:to]
150
+ validate_anomaly_state!(state)
151
+
152
+ uri = URI("#{@api_base}/apps/#{app_id}/anomaly_events")
153
+ assign_query(uri, from: times[:from], to: times[:to], state: state, metric: metric, endpoint: endpoint)
154
+ response = make_request(uri)
155
+ response.dig("results", "anomaly_events") || []
156
+ end
157
+
158
+ def get_anomaly_event(app_id, anomaly_event_id)
159
+ uri = URI("#{@api_base}/apps/#{app_id}/anomaly_events/#{anomaly_event_id}")
160
+ response = make_request(uri)
161
+ response.dig("results", "anomaly_event") || {}
162
+ end
163
+ end
164
+
165
+ module Insights
166
+ def get_all_insights(app_id, limit: nil)
167
+ uri = URI("#{@api_base}/apps/#{app_id}/insights")
168
+ assign_query(uri, limit: limit)
169
+ response = make_request(uri)
170
+ response["results"] || {}
171
+ end
172
+
173
+ def get_insight_by_type(app_id, insight_type, limit: nil)
174
+ validate_insight_type(insight_type)
175
+ uri = URI("#{@api_base}/apps/#{app_id}/insights/#{insight_type}")
176
+ assign_query(uri, limit: limit)
177
+ response = make_request(uri)
178
+ response["results"] || {}
179
+ end
180
+
181
+ def get_insights_history(app_id, from: nil, to: nil, limit: nil, pagination_cursor: nil, pagination_direction: nil, pagination_page: nil)
182
+ uri = URI("#{@api_base}/apps/#{app_id}/insights/history")
183
+ assign_query(
184
+ uri,
185
+ from: from,
186
+ to: to,
187
+ limit: limit,
188
+ pagination_cursor: pagination_cursor,
189
+ pagination_direction: pagination_direction,
190
+ pagination_page: pagination_page
191
+ )
192
+ response = make_request(uri)
193
+ response["results"] || {}
194
+ end
195
+
196
+ def get_insights_history_by_type(app_id, insight_type, from: nil, to: nil, limit: nil, pagination_cursor: nil, pagination_direction: nil, pagination_page: nil)
197
+ validate_insight_type(insight_type)
198
+ uri = URI("#{@api_base}/apps/#{app_id}/insights/history/#{insight_type}")
199
+ assign_query(
200
+ uri,
201
+ from: from,
202
+ to: to,
203
+ limit: limit,
204
+ pagination_cursor: pagination_cursor,
205
+ pagination_direction: pagination_direction,
206
+ pagination_page: pagination_page
207
+ )
208
+ response = make_request(uri)
209
+ response["results"] || {}
210
+ end
211
+ end
212
+
213
+ module OpenApi
214
+ def fetch_openapi_schema
215
+ uri = URI("https://scoutapm.com/api/v0/openapi.yaml")
216
+ fetch_openapi_schema_response(uri)
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,174 @@
1
+ module ScoutApmMcp
2
+ class Client
3
+ module HttpTransport
4
+ private
5
+
6
+ def build_http_client(uri)
7
+ http = Net::HTTP.new(uri.host, uri.port)
8
+ http.read_timeout = 10
9
+ http.open_timeout = 10
10
+ configure_ssl(http, uri) if uri.scheme == "https"
11
+ http
12
+ end
13
+
14
+ def make_request(uri)
15
+ attempt = 0
16
+
17
+ loop do
18
+ attempt += 1
19
+ return perform_request(uri)
20
+ rescue APIError => error
21
+ retry_api_error!(error, attempt)
22
+ rescue OpenSSL::SSL::SSLError => error
23
+ raise_ssl_error(error)
24
+ rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT => error
25
+ retry_connection_error!(error, attempt)
26
+ rescue Error
27
+ raise
28
+ rescue => error
29
+ raise Error, "Request failed: #{error.class} - #{error.message}"
30
+ end
31
+ end
32
+
33
+ def perform_request(uri)
34
+ http = build_http_client(uri)
35
+ request = build_json_request(uri)
36
+ response = http.request(request)
37
+ response_data = handle_response_errors(response)
38
+ validate_api_status_header(response_data)
39
+ response_data
40
+ end
41
+
42
+ def handle_response_errors(response)
43
+ data = parse_response_body(response)
44
+
45
+ case response
46
+ when Net::HTTPSuccess
47
+ data
48
+ when Net::HTTPUnauthorized
49
+ raise AuthError, "Authentication failed - check your API key"
50
+ when Net::HTTPNotFound
51
+ raise APIError.new("Resource not found", status_code: 404, response_data: data)
52
+ else
53
+ raise_api_error(response, data)
54
+ end
55
+ end
56
+
57
+ def fetch_openapi_schema_response(uri)
58
+ http = build_http_client(uri)
59
+ request = build_openapi_request(uri)
60
+ response = http.request(request)
61
+ handle_openapi_response(response)
62
+ rescue OpenSSL::SSL::SSLError => error
63
+ raise_ssl_error(error)
64
+ rescue Error
65
+ raise
66
+ rescue => error
67
+ raise Error, "Request failed: #{error.class} - #{error.message}"
68
+ end
69
+
70
+ def configure_ssl(http, uri)
71
+ return unless uri.scheme == "https"
72
+
73
+ http.use_ssl = true
74
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
75
+ ca_file = ssl_certificate_file
76
+ http.ca_file = ca_file if ca_file
77
+ end
78
+
79
+ def ssl_certificate_file
80
+ if ENV["SSL_CERT_FILE"] && File.file?(ENV["SSL_CERT_FILE"])
81
+ ENV["SSL_CERT_FILE"]
82
+ elsif File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)
83
+ OpenSSL::X509::DEFAULT_CERT_FILE
84
+ end
85
+ end
86
+
87
+ def build_json_request(uri)
88
+ request = Net::HTTP::Get.new(uri)
89
+ request["X-SCOUT-API"] = @api_key
90
+ request["User-Agent"] = @user_agent
91
+ request["Accept"] = "application/json"
92
+ request
93
+ end
94
+
95
+ def build_openapi_request(uri)
96
+ request = Net::HTTP::Get.new(uri)
97
+ request["X-SCOUT-API"] = @api_key
98
+ request["User-Agent"] = @user_agent
99
+ request["Accept"] = "application/x-yaml, application/yaml, text/yaml, */*"
100
+ request
101
+ end
102
+
103
+ def parse_response_body(response)
104
+ JSON.parse(response.body)
105
+ rescue JSON::ParserError
106
+ raise APIError.new("Invalid JSON response: #{response.body}", status_code: response.code.to_i)
107
+ end
108
+
109
+ def validate_api_status_header(response_data)
110
+ return unless response_data.is_a?(Hash)
111
+
112
+ header = response_data["header"]
113
+ return unless header && header["status"]
114
+
115
+ status_code = header["status"]["code"]
116
+ return unless status_code && status_code >= 400
117
+
118
+ error_message = header["status"]["message"] || "Unknown API error"
119
+ raise APIError.new(error_message, status_code: status_code, response_data: response_data)
120
+ end
121
+
122
+ def raise_api_error(response, data)
123
+ error_message = "API request failed"
124
+ if data.is_a?(Hash) && data.dig("header", "status", "message")
125
+ error_message = data.dig("header", "status", "message")
126
+ end
127
+ raise APIError.new(error_message, status_code: response.code.to_i, response_data: data)
128
+ end
129
+
130
+ def handle_openapi_response(response)
131
+ case response
132
+ when Net::HTTPSuccess
133
+ {
134
+ content: response.body,
135
+ content_type: response.content_type,
136
+ status: response.code.to_i
137
+ }
138
+ when Net::HTTPUnauthorized
139
+ raise AuthError, "Authentication failed. Check your API key."
140
+ else
141
+ raise APIError.new("API request failed: #{response.code} #{response.message}", status_code: response.code.to_i)
142
+ end
143
+ end
144
+
145
+ def raise_ssl_error(error)
146
+ raise Error, "SSL verification failed: #{error.message}. This may be due to system certificate configuration issues."
147
+ end
148
+
149
+ def raise_connection_error(error)
150
+ raise Error, "Request failed: #{error.class} - #{error.message}"
151
+ end
152
+
153
+ def retryable_api_error?(error)
154
+ error.is_a?(APIError) && error.status_code && RETRYABLE_HTTP_STATUS_CODES.include?(error.status_code)
155
+ end
156
+
157
+ def retry_api_error!(error, attempt)
158
+ raise unless retryable_api_error?(error) && attempt < MAX_REQUEST_ATTEMPTS
159
+
160
+ sleep(retry_backoff_seconds(attempt))
161
+ end
162
+
163
+ def retry_connection_error!(error, attempt)
164
+ raise_connection_error(error) if attempt >= MAX_REQUEST_ATTEMPTS
165
+
166
+ sleep(retry_backoff_seconds(attempt))
167
+ end
168
+
169
+ def retry_backoff_seconds(attempt)
170
+ RETRY_BASE_DELAY_SECONDS * (2**(attempt - 1))
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,31 @@
1
+ module ScoutApmMcp
2
+ class Client
3
+ module QueryParams
4
+ private
5
+
6
+ def build_query_string(from: nil, to: nil)
7
+ encode_query_params("from" => from, "to" => to)
8
+ end
9
+
10
+ def encode_query_params(params)
11
+ filtered = params.each_with_object({}) do |(key, value), hash|
12
+ hash[key] = value unless value.nil?
13
+ end
14
+ return nil if filtered.empty?
15
+
16
+ URI.encode_www_form(filtered)
17
+ end
18
+
19
+ def assign_query(uri, **params)
20
+ query = encode_query_params(params.transform_keys(&:to_s))
21
+ uri.query = query if query
22
+ end
23
+
24
+ def api_uri(*path_segments)
25
+ uri = URI(@api_base)
26
+ uri.path = File.join(uri.path, *path_segments.map(&:to_s))
27
+ uri
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,90 @@
1
+ module ScoutApmMcp
2
+ class Client
3
+ module Validation
4
+ private
5
+
6
+ def validate_insight_type(insight_type)
7
+ return if VALID_INSIGHTS.include?(insight_type)
8
+
9
+ raise ArgumentError, "Invalid insight_type. Must be one of: #{VALID_INSIGHTS.join(", ")}"
10
+ end
11
+
12
+ def validate_metric_params(metric_type, from, to)
13
+ unless VALID_METRICS.include?(metric_type)
14
+ raise ArgumentError, "Invalid metric_type. Must be one of: #{VALID_METRICS.join(", ")}"
15
+ end
16
+ validate_time_range(from, to) if from && to
17
+ end
18
+
19
+ def validate_job_metric_params(metric_type, from, to)
20
+ unless VALID_JOB_METRICS.include?(metric_type)
21
+ raise ArgumentError, "Invalid metric_type. Must be one of: #{VALID_JOB_METRICS.join(", ")}"
22
+ end
23
+ validate_time_range(from, to) if from && to
24
+ end
25
+
26
+ def validate_time_range(from, to)
27
+ return unless from && to
28
+
29
+ from_time = Helpers.parse_time(from)
30
+ to_time = Helpers.parse_time(to)
31
+
32
+ if from_time >= to_time
33
+ raise ArgumentError, "from_time must be before to_time"
34
+ end
35
+
36
+ max_duration = 14 * 24 * 60 * 60
37
+ if (to_time - from_time) > max_duration
38
+ raise ArgumentError, "Time range cannot exceed 2 weeks"
39
+ end
40
+ end
41
+
42
+ def resolve_range_times(from:, to:, range:)
43
+ return {from: from, to: to} unless range
44
+
45
+ Helpers.calculate_range(range: range, to: to)
46
+ end
47
+
48
+ def resolve_listing_time_range(from:, to:, range:, default_range: "7days")
49
+ range = default_range if listing_range_unset?(from, to, range)
50
+ times = resolve_range_times(from: from, to: to, range: range)
51
+ from, to = normalize_listing_bounds(times[:from], times[:to])
52
+ validate_time_range(from, to) if from && to
53
+ {from: from, to: to}
54
+ end
55
+
56
+ def listing_range_unset?(from, to, range)
57
+ from.nil? && to.nil? && range.nil?
58
+ end
59
+
60
+ def normalize_listing_bounds(from, to)
61
+ from = Helpers.calculate_range(range: "7days", to: to)[:from] if from.nil? && to
62
+ to = Helpers.format_time(Time.now.utc) if from && to.nil?
63
+ [from, to]
64
+ end
65
+
66
+ def validate_trace_time_range(from, to)
67
+ validate_time_range(from, to) if from && to
68
+ return unless from && to
69
+
70
+ from_time = Helpers.parse_time(from)
71
+ seven_days_ago = Time.now.utc - (7 * 24 * 60 * 60)
72
+ if from_time < seven_days_ago
73
+ raise ArgumentError, "from_time cannot be older than 7 days"
74
+ end
75
+ end
76
+
77
+ def validate_sort_by!(sort_by)
78
+ return if sort_by.nil? || VALID_ENDPOINT_SORT_BY.include?(sort_by)
79
+
80
+ raise ArgumentError, "Invalid sort_by. Must be one of: #{VALID_ENDPOINT_SORT_BY.join(", ")}"
81
+ end
82
+
83
+ def validate_anomaly_state!(state)
84
+ return if state.nil? || VALID_ANOMALY_STATES.include?(state)
85
+
86
+ raise ArgumentError, "Invalid state. Must be one of: #{VALID_ANOMALY_STATES.join(", ")}"
87
+ end
88
+ end
89
+ end
90
+ end