pingops 0.0.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 +7 -0
- data/CHANGELOG.md +26 -0
- data/README.md +377 -0
- data/lib/pingops/core/body_capture.rb +102 -0
- data/lib/pingops/core/configuration.rb +181 -0
- data/lib/pingops/core/constants.rb +126 -0
- data/lib/pingops/core/context_keys.rb +96 -0
- data/lib/pingops/core/domain_filter.rb +123 -0
- data/lib/pingops/core/header_filter.rb +123 -0
- data/lib/pingops/core/id_generator.rb +78 -0
- data/lib/pingops/core/span_eligibility.rb +56 -0
- data/lib/pingops/core/types.rb +190 -0
- data/lib/pingops/errors.rb +15 -0
- data/lib/pingops/instrumentation/manager.rb +87 -0
- data/lib/pingops/instrumentation/net_http.rb +146 -0
- data/lib/pingops/otel/config_store.rb +101 -0
- data/lib/pingops/otel/span_processor.rb +293 -0
- data/lib/pingops/otel/tracer_provider.rb +93 -0
- data/lib/pingops/register.rb +48 -0
- data/lib/pingops/sdk.rb +291 -0
- data/lib/pingops/version.rb +5 -0
- data/lib/pingops.rb +160 -0
- data/pingops.gemspec +54 -0
- metadata +295 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f2dd00a33589bfeaca93f9755d53075f18e54c02796e1af084584d4400291445
|
|
4
|
+
data.tar.gz: 54f27e3fc4a35cc93b017f1e226a7a57fe82f61fad8bb79f19f739efd2daca04
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 198fc7dfec12b0edbee02e08d21657321198783328b2ea96fc90d9818bc4506969117e9944b72f90f01c3c17d8f91152326fe516de5ef7e4ecce5e9eb8f3bf97
|
|
7
|
+
data.tar.gz: ae880a1d85dbb023f70a21bccd140b17377042ea67d4370b751ed975420c2f6634e697d6eef8da579e174d3af1acbb18d9c00a12ef879f1b3906d110ecad3af5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.0.1] - 2026-02-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of the PingOps Ruby SDK
|
|
13
|
+
- Automatic HTTP instrumentation for Net::HTTP
|
|
14
|
+
- Manual tracing with `Pingops.start_trace`
|
|
15
|
+
- Configuration via hash, file (JSON/YAML), or environment variables
|
|
16
|
+
- Domain allow/deny list filtering with path matching
|
|
17
|
+
- Header filtering and redaction with multiple strategies
|
|
18
|
+
- Request/response body capture with size limits
|
|
19
|
+
- Batched and immediate export modes
|
|
20
|
+
- OpenTelemetry SDK integration
|
|
21
|
+
- Deterministic trace ID generation from seed
|
|
22
|
+
- Custom trace attributes (userId, sessionId, tags, metadata)
|
|
23
|
+
- Per-trace body capture override
|
|
24
|
+
- Rails integration support
|
|
25
|
+
- Auto-initialization via register pattern
|
|
26
|
+
- Comprehensive test suite
|
data/README.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# PingOps Ruby SDK
|
|
2
|
+
|
|
3
|
+
A production-grade Ruby SDK for PingOps that provides automatic HTTP tracing with OpenTelemetry integration.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Automatic HTTP Instrumentation** - Captures outgoing HTTP requests from `Net::HTTP`, Faraday, and HTTPClient
|
|
8
|
+
- **OpenTelemetry Compatible** - Built on OpenTelemetry API and SDK for seamless integration
|
|
9
|
+
- **Manual Tracing** - Start traces with custom attributes (userId, sessionId, tags, metadata)
|
|
10
|
+
- **Domain Filtering** - Allow/deny lists with path matching
|
|
11
|
+
- **Header Redaction** - Automatic redaction of sensitive headers
|
|
12
|
+
- **Body Capture** - Configurable request/response body capture with size limits
|
|
13
|
+
- **Flexible Configuration** - File-based (JSON/YAML) or programmatic configuration with environment variable support
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add this line to your application's Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'pingops'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
And then execute:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install it yourself:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
gem install pingops
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require 'pingops'
|
|
39
|
+
|
|
40
|
+
# Initialize the SDK
|
|
41
|
+
Pingops.initialize(
|
|
42
|
+
api_key: 'your-api-key',
|
|
43
|
+
base_url: 'https://api.pingops.com',
|
|
44
|
+
service_name: 'my-service'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Trace HTTP requests
|
|
48
|
+
Pingops.start_trace(attributes: { user_id: 'user-123' }) do
|
|
49
|
+
# All HTTP requests in this block are automatically traced
|
|
50
|
+
response = Net::HTTP.get(URI('https://api.example.com/users'))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Shutdown when done
|
|
54
|
+
Pingops.shutdown
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
### Programmatic Configuration
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Pingops.initialize(
|
|
63
|
+
api_key: 'your-api-key',
|
|
64
|
+
base_url: 'https://api.pingops.com',
|
|
65
|
+
service_name: 'my-service',
|
|
66
|
+
debug: false,
|
|
67
|
+
|
|
68
|
+
# Body capture settings
|
|
69
|
+
capture_request_body: true,
|
|
70
|
+
capture_response_body: true,
|
|
71
|
+
max_request_body_size: 4096,
|
|
72
|
+
max_response_body_size: 4096,
|
|
73
|
+
|
|
74
|
+
# Domain filtering
|
|
75
|
+
domain_allow_list: [
|
|
76
|
+
{ domain: 'api.example.com' },
|
|
77
|
+
{ domain: '.mycompany.com', paths: ['/api/', '/v1/'] }
|
|
78
|
+
],
|
|
79
|
+
domain_deny_list: [
|
|
80
|
+
{ domain: 'internal.mycompany.com' }
|
|
81
|
+
],
|
|
82
|
+
|
|
83
|
+
# Header filtering
|
|
84
|
+
headers_allow_list: ['content-type', 'accept'],
|
|
85
|
+
headers_deny_list: ['x-internal-secret'],
|
|
86
|
+
|
|
87
|
+
# Header redaction
|
|
88
|
+
header_redaction: {
|
|
89
|
+
sensitive_patterns: ['authorization', 'cookie', 'api-key'],
|
|
90
|
+
strategy: 'replace', # replace, partial, partial_end, remove
|
|
91
|
+
redaction_string: '[REDACTED]',
|
|
92
|
+
visible_chars: 4,
|
|
93
|
+
enabled: true
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
# Export settings
|
|
97
|
+
export_mode: 'batched', # or 'immediate'
|
|
98
|
+
batch_size: 50,
|
|
99
|
+
batch_timeout: 5000
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### File-Based Configuration
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# Load from JSON file
|
|
107
|
+
Pingops.initialize('/path/to/pingops.json')
|
|
108
|
+
|
|
109
|
+
# Or with config_file key
|
|
110
|
+
Pingops.initialize(config_file: '/path/to/pingops.yaml')
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Example `pingops.json`:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"apiKey": "your-api-key",
|
|
118
|
+
"baseUrl": "https://api.pingops.com",
|
|
119
|
+
"serviceName": "my-service",
|
|
120
|
+
"debug": false,
|
|
121
|
+
"captureRequestBody": true,
|
|
122
|
+
"captureResponseBody": true,
|
|
123
|
+
"domainAllowList": [
|
|
124
|
+
{ "domain": "api.example.com" }
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Environment Variables
|
|
130
|
+
|
|
131
|
+
Environment variables override file and programmatic configuration:
|
|
132
|
+
|
|
133
|
+
| Variable | Description |
|
|
134
|
+
|----------|-------------|
|
|
135
|
+
| `PINGOPS_API_KEY` | API key for authentication |
|
|
136
|
+
| `PINGOPS_BASE_URL` | PingOps backend URL |
|
|
137
|
+
| `PINGOPS_SERVICE_NAME` | Service name for traces |
|
|
138
|
+
| `PINGOPS_DEBUG` | Enable debug logging ("true") |
|
|
139
|
+
| `PINGOPS_BATCH_SIZE` | Batch size for export |
|
|
140
|
+
| `PINGOPS_BATCH_TIMEOUT` | Batch timeout in milliseconds |
|
|
141
|
+
| `PINGOPS_EXPORT_MODE` | Export mode (batched/immediate) |
|
|
142
|
+
| `PINGOPS_CONFIG_FILE` | Config file path (for auto-init) |
|
|
143
|
+
|
|
144
|
+
## API Reference
|
|
145
|
+
|
|
146
|
+
### `Pingops.initialize(config)`
|
|
147
|
+
|
|
148
|
+
Initialize the SDK. Idempotent - subsequent calls are no-ops.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# With hash
|
|
152
|
+
Pingops.initialize(api_key: 'key', base_url: 'url', service_name: 'name')
|
|
153
|
+
|
|
154
|
+
# With file path
|
|
155
|
+
Pingops.initialize('/path/to/config.json')
|
|
156
|
+
|
|
157
|
+
# With config_file key
|
|
158
|
+
Pingops.initialize(config_file: '/path/to/config.yaml')
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### `Pingops.shutdown`
|
|
162
|
+
|
|
163
|
+
Shutdown the SDK, flushing any pending spans.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
Pingops.shutdown
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `Pingops.start_trace(options, &block)`
|
|
170
|
+
|
|
171
|
+
Start a trace and execute the block within the trace context.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
result = Pingops.start_trace(
|
|
175
|
+
attributes: {
|
|
176
|
+
user_id: 'user-123',
|
|
177
|
+
session_id: 'session-456',
|
|
178
|
+
tags: ['production', 'web'],
|
|
179
|
+
metadata: { 'request_id' => 'abc123' },
|
|
180
|
+
capture_request_body: true,
|
|
181
|
+
capture_response_body: true
|
|
182
|
+
},
|
|
183
|
+
seed: 'optional-seed-for-deterministic-trace-id'
|
|
184
|
+
) do
|
|
185
|
+
# Your code here
|
|
186
|
+
Net::HTTP.get(URI('https://api.example.com'))
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### `Pingops.active_trace_id`
|
|
191
|
+
|
|
192
|
+
Get the current trace ID (within a trace block).
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
Pingops.start_trace do
|
|
196
|
+
trace_id = Pingops.active_trace_id
|
|
197
|
+
# => "a1b2c3d4e5f6..."
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### `Pingops.active_span_id`
|
|
202
|
+
|
|
203
|
+
Get the current span ID (within a trace block).
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
Pingops.start_trace do
|
|
207
|
+
span_id = Pingops.active_span_id
|
|
208
|
+
# => "a1b2c3d4..."
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### `Pingops.initialized?`
|
|
213
|
+
|
|
214
|
+
Check if the SDK is initialized.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
Pingops.initialized? # => true/false
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Domain Rules
|
|
221
|
+
|
|
222
|
+
Domain rules support exact and suffix matching:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
domain_allow_list: [
|
|
226
|
+
# Exact match
|
|
227
|
+
{ domain: 'api.example.com' },
|
|
228
|
+
|
|
229
|
+
# Suffix match (starts with .)
|
|
230
|
+
{ domain: '.mycompany.com' }, # Matches api.mycompany.com, app.mycompany.com, etc.
|
|
231
|
+
|
|
232
|
+
# With path restriction
|
|
233
|
+
{ domain: 'api.example.com', paths: ['/api/', '/v1/'] },
|
|
234
|
+
|
|
235
|
+
# With body capture override
|
|
236
|
+
{
|
|
237
|
+
domain: 'external-api.com',
|
|
238
|
+
capture_request_body: true,
|
|
239
|
+
capture_response_body: false
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
# With header override
|
|
243
|
+
{
|
|
244
|
+
domain: 'partner-api.com',
|
|
245
|
+
headers_allow_list: ['content-type', 'x-partner-id']
|
|
246
|
+
}
|
|
247
|
+
]
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Header Redaction
|
|
251
|
+
|
|
252
|
+
Headers matching sensitive patterns are automatically redacted:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
header_redaction: {
|
|
256
|
+
sensitive_patterns: [
|
|
257
|
+
'authorization',
|
|
258
|
+
'cookie',
|
|
259
|
+
'x-api-key'
|
|
260
|
+
],
|
|
261
|
+
strategy: 'replace', # Entire value replaced
|
|
262
|
+
# strategy: 'partial', # First N chars visible: "Bear..."
|
|
263
|
+
# strategy: 'partial_end', # Last N chars visible: "...oken"
|
|
264
|
+
# strategy: 'remove', # Header removed entirely
|
|
265
|
+
redaction_string: '[REDACTED]',
|
|
266
|
+
visible_chars: 4,
|
|
267
|
+
enabled: true
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Default sensitive patterns include: authorization, cookie, api-key, token, secret, password, and many more.
|
|
272
|
+
|
|
273
|
+
## Auto-Initialization
|
|
274
|
+
|
|
275
|
+
For simple setups, use the register pattern:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
# Set environment variables
|
|
279
|
+
export PINGOPS_API_KEY=your-key
|
|
280
|
+
export PINGOPS_BASE_URL=https://api.pingops.com
|
|
281
|
+
export PINGOPS_SERVICE_NAME=my-service
|
|
282
|
+
|
|
283
|
+
# Run with auto-init
|
|
284
|
+
ruby -r pingops/register your_app.rb
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Or in your application:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
require 'pingops/register'
|
|
291
|
+
|
|
292
|
+
# SDK is automatically initialized from environment
|
|
293
|
+
Pingops.start_trace { ... }
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Rails Integration
|
|
297
|
+
|
|
298
|
+
Create `config/initializers/pingops.rb`:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
require 'pingops'
|
|
302
|
+
|
|
303
|
+
if Rails.env.production? || Rails.env.staging?
|
|
304
|
+
Pingops.initialize(
|
|
305
|
+
api_key: ENV.fetch('PINGOPS_API_KEY'),
|
|
306
|
+
base_url: ENV.fetch('PINGOPS_BASE_URL'),
|
|
307
|
+
service_name: Rails.application.class.module_parent_name,
|
|
308
|
+
debug: Rails.env.development?
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
at_exit { Pingops.shutdown if Pingops.initialized? }
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Add tracing to controllers:
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
class ApplicationController < ActionController::Base
|
|
319
|
+
around_action :trace_request
|
|
320
|
+
|
|
321
|
+
private
|
|
322
|
+
|
|
323
|
+
def trace_request
|
|
324
|
+
Pingops.start_trace(
|
|
325
|
+
attributes: {
|
|
326
|
+
user_id: current_user&.id,
|
|
327
|
+
session_id: session.id,
|
|
328
|
+
tags: [Rails.env, controller_name],
|
|
329
|
+
metadata: {
|
|
330
|
+
'controller' => controller_name,
|
|
331
|
+
'action' => action_name
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
) { yield }
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## Requirements
|
|
340
|
+
|
|
341
|
+
- Ruby 3.0+
|
|
342
|
+
- OpenTelemetry SDK 1.4+
|
|
343
|
+
|
|
344
|
+
## Development
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
# Install dependencies
|
|
348
|
+
bundle install
|
|
349
|
+
|
|
350
|
+
# Run tests
|
|
351
|
+
bundle exec rspec
|
|
352
|
+
|
|
353
|
+
# Run linter
|
|
354
|
+
bundle exec rubocop
|
|
355
|
+
|
|
356
|
+
# Generate documentation
|
|
357
|
+
bundle exec yard doc
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Publishing
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
# 1) Bump version (lib/pingops/version.rb) and update CHANGELOG if needed
|
|
364
|
+
|
|
365
|
+
# 2) Build the gem
|
|
366
|
+
gem build pingops.gemspec
|
|
367
|
+
|
|
368
|
+
# 3) Authenticate to RubyGems (first time only)
|
|
369
|
+
gem signin
|
|
370
|
+
|
|
371
|
+
# 4) Push the built gem
|
|
372
|
+
gem push pingops-<version>.gem
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Pingops
|
|
6
|
+
module Core
|
|
7
|
+
# Body capture utilities for request/response bodies
|
|
8
|
+
module BodyCapture
|
|
9
|
+
class << self
|
|
10
|
+
# Determine if request body should be captured for a span
|
|
11
|
+
# Priority: Context > Domain rule > Global config
|
|
12
|
+
#
|
|
13
|
+
# @param context [OpenTelemetry::Context] Current context
|
|
14
|
+
# @param domain_rule [DomainRule, nil] Matching domain rule
|
|
15
|
+
# @param global_config [Configuration] Global configuration
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def should_capture_request_body?(context, domain_rule, global_config)
|
|
18
|
+
# 1. Check context first
|
|
19
|
+
context_value = ContextKeys.capture_request_body?(context)
|
|
20
|
+
return context_value unless context_value.nil?
|
|
21
|
+
|
|
22
|
+
# 2. Check domain rule
|
|
23
|
+
return domain_rule.capture_request_body if domain_rule && !domain_rule.capture_request_body.nil?
|
|
24
|
+
|
|
25
|
+
# 3. Fall back to global config
|
|
26
|
+
global_config.capture_request_body
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Determine if response body should be captured for a span
|
|
30
|
+
# Priority: Context > Domain rule > Global config
|
|
31
|
+
#
|
|
32
|
+
# @param context [OpenTelemetry::Context] Current context
|
|
33
|
+
# @param domain_rule [DomainRule, nil] Matching domain rule
|
|
34
|
+
# @param global_config [Configuration] Global configuration
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def should_capture_response_body?(context, domain_rule, global_config)
|
|
37
|
+
# 1. Check context first
|
|
38
|
+
context_value = ContextKeys.capture_response_body?(context)
|
|
39
|
+
return context_value unless context_value.nil?
|
|
40
|
+
|
|
41
|
+
# 2. Check domain rule
|
|
42
|
+
return domain_rule.capture_response_body if domain_rule && !domain_rule.capture_response_body.nil?
|
|
43
|
+
|
|
44
|
+
# 3. Fall back to global config
|
|
45
|
+
global_config.capture_response_body
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Truncate body if it exceeds max size
|
|
49
|
+
# @param body [String] The body content
|
|
50
|
+
# @param max_size [Integer] Maximum size in bytes
|
|
51
|
+
# @return [String, nil] The body or nil if empty
|
|
52
|
+
def truncate_body(body, max_size)
|
|
53
|
+
return nil if body.nil? || body.empty?
|
|
54
|
+
|
|
55
|
+
if body.bytesize > max_size
|
|
56
|
+
"#{body.byteslice(0, max_size)}... [truncated, #{body.bytesize} bytes total]"
|
|
57
|
+
else
|
|
58
|
+
body
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if content encoding indicates compression
|
|
63
|
+
# @param encoding [String, nil] Content-Encoding header value
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def compressed?(encoding)
|
|
66
|
+
return false if encoding.nil? || encoding.empty?
|
|
67
|
+
|
|
68
|
+
Constants::COMPRESSED_ENCODINGS.any? { |e| encoding.downcase.include?(e) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Encode compressed body as base64
|
|
72
|
+
# @param body [String] Raw body bytes
|
|
73
|
+
# @param max_size [Integer] Maximum size in bytes
|
|
74
|
+
# @return [String, nil] Base64 encoded body or nil
|
|
75
|
+
def encode_compressed_body(body, max_size)
|
|
76
|
+
return nil if body.nil? || body.empty?
|
|
77
|
+
return nil if body.bytesize > max_size
|
|
78
|
+
|
|
79
|
+
Base64.strict_encode64(body)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Filter body attributes from span based on capture settings
|
|
83
|
+
# @param attributes [Hash] Span attributes
|
|
84
|
+
# @param capture_request [Boolean] Whether to keep request body
|
|
85
|
+
# @param capture_response [Boolean] Whether to keep response body
|
|
86
|
+
# @return [Hash] Filtered attributes
|
|
87
|
+
def filter_body_attributes(attributes, capture_request:, capture_response:)
|
|
88
|
+
result = attributes.dup
|
|
89
|
+
|
|
90
|
+
result.delete(Constants::ATTR_HTTP_REQUEST_BODY) unless capture_request
|
|
91
|
+
|
|
92
|
+
unless capture_response
|
|
93
|
+
result.delete(Constants::ATTR_HTTP_RESPONSE_BODY)
|
|
94
|
+
result.delete(Constants::ATTR_HTTP_RESPONSE_CONTENT_ENCODING)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Pingops
|
|
6
|
+
module Core
|
|
7
|
+
# Main configuration class for the PingOps SDK
|
|
8
|
+
class Configuration
|
|
9
|
+
attr_accessor :api_key, :base_url, :service_name, :debug,
|
|
10
|
+
:headers_allow_list, :headers_deny_list,
|
|
11
|
+
:capture_request_body, :capture_response_body,
|
|
12
|
+
:max_request_body_size, :max_response_body_size,
|
|
13
|
+
:domain_allow_list, :domain_deny_list,
|
|
14
|
+
:header_redaction, :batch_size, :batch_timeout, :export_mode
|
|
15
|
+
|
|
16
|
+
def initialize(options = {})
|
|
17
|
+
@api_key = options[:api_key]
|
|
18
|
+
@base_url = options[:base_url]
|
|
19
|
+
@service_name = options[:service_name]
|
|
20
|
+
@debug = options[:debug] || false
|
|
21
|
+
@headers_allow_list = options[:headers_allow_list]&.map { |h| h.to_s.downcase }
|
|
22
|
+
@headers_deny_list = options[:headers_deny_list]&.map { |h| h.to_s.downcase }
|
|
23
|
+
@capture_request_body = options[:capture_request_body] || false
|
|
24
|
+
@capture_response_body = options[:capture_response_body] || false
|
|
25
|
+
@max_request_body_size = options[:max_request_body_size] || Constants::DEFAULT_MAX_REQUEST_BODY_SIZE
|
|
26
|
+
@max_response_body_size = options[:max_response_body_size] || Constants::DEFAULT_MAX_RESPONSE_BODY_SIZE
|
|
27
|
+
@batch_size = options[:batch_size] || Constants::DEFAULT_BATCH_SIZE
|
|
28
|
+
@batch_timeout = options[:batch_timeout] || Constants::DEFAULT_BATCH_TIMEOUT
|
|
29
|
+
@export_mode = options[:export_mode] || Constants::EXPORT_MODE_BATCHED
|
|
30
|
+
|
|
31
|
+
# Parse domain rules
|
|
32
|
+
@domain_allow_list = parse_domain_rules(options[:domain_allow_list])
|
|
33
|
+
@domain_deny_list = parse_domain_rules(options[:domain_deny_list])
|
|
34
|
+
|
|
35
|
+
# Parse header redaction config
|
|
36
|
+
@header_redaction = parse_header_redaction(options[:header_redaction])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if configuration is valid (has required fields)
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def valid?
|
|
42
|
+
!@base_url.nil? && !@base_url.empty? &&
|
|
43
|
+
!@service_name.nil? && !@service_name.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Validate and raise if invalid
|
|
47
|
+
# @raise [Pingops::ConfigurationError] if required fields are missing
|
|
48
|
+
def validate!
|
|
49
|
+
missing = []
|
|
50
|
+
missing << 'baseUrl (or PINGOPS_BASE_URL)' if @base_url.nil? || @base_url.empty?
|
|
51
|
+
missing << 'serviceName (or PINGOPS_SERVICE_NAME)' if @service_name.nil? || @service_name.empty?
|
|
52
|
+
|
|
53
|
+
return if missing.empty?
|
|
54
|
+
|
|
55
|
+
raise Pingops::ConfigurationError, "Missing required configuration: #{missing.join(', ')}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Create configuration from a file path
|
|
59
|
+
# @param file_path [String] Path to JSON or YAML config file
|
|
60
|
+
# @return [Configuration]
|
|
61
|
+
def self.from_file(file_path)
|
|
62
|
+
content = File.read(file_path)
|
|
63
|
+
data = if file_path.end_with?('.yaml', '.yml')
|
|
64
|
+
require 'yaml'
|
|
65
|
+
YAML.safe_load(content, symbolize_names: true)
|
|
66
|
+
else
|
|
67
|
+
JSON.parse(content, symbolize_names: true)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
from_hash(data)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Create configuration from a hash
|
|
74
|
+
# @param hash [Hash] Configuration hash
|
|
75
|
+
# @return [Configuration]
|
|
76
|
+
def self.from_hash(hash)
|
|
77
|
+
return hash if hash.is_a?(Configuration)
|
|
78
|
+
|
|
79
|
+
new(
|
|
80
|
+
api_key: hash[:api_key] || hash[:apiKey] || hash['apiKey'],
|
|
81
|
+
base_url: hash[:base_url] || hash[:baseUrl] || hash['baseUrl'],
|
|
82
|
+
service_name: hash[:service_name] || hash[:serviceName] || hash['serviceName'],
|
|
83
|
+
debug: hash[:debug] || hash['debug'],
|
|
84
|
+
headers_allow_list: hash[:headers_allow_list] || hash[:headersAllowList] || hash['headersAllowList'],
|
|
85
|
+
headers_deny_list: hash[:headers_deny_list] || hash[:headersDenyList] || hash['headersDenyList'],
|
|
86
|
+
capture_request_body: hash[:capture_request_body] || hash[:captureRequestBody] || hash['captureRequestBody'],
|
|
87
|
+
capture_response_body: hash[:capture_response_body] || hash[:captureResponseBody] ||
|
|
88
|
+
hash['captureResponseBody'],
|
|
89
|
+
max_request_body_size: hash[:max_request_body_size] || hash[:maxRequestBodySize] ||
|
|
90
|
+
hash['maxRequestBodySize'],
|
|
91
|
+
max_response_body_size: hash[:max_response_body_size] || hash[:maxResponseBodySize] ||
|
|
92
|
+
hash['maxResponseBodySize'],
|
|
93
|
+
domain_allow_list: hash[:domain_allow_list] || hash[:domainAllowList] || hash['domainAllowList'],
|
|
94
|
+
domain_deny_list: hash[:domain_deny_list] || hash[:domainDenyList] || hash['domainDenyList'],
|
|
95
|
+
header_redaction: hash[:header_redaction] || hash[:headerRedaction] || hash['headerRedaction'],
|
|
96
|
+
batch_size: hash[:batch_size] || hash[:batchSize] || hash['batchSize'],
|
|
97
|
+
batch_timeout: hash[:batch_timeout] || hash[:batchTimeout] || hash['batchTimeout'],
|
|
98
|
+
export_mode: hash[:export_mode] || hash[:exportMode] || hash['exportMode']
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Create configuration from environment variables only
|
|
103
|
+
# @return [Configuration]
|
|
104
|
+
def self.from_env
|
|
105
|
+
new(
|
|
106
|
+
api_key: ENV.fetch('PINGOPS_API_KEY', nil),
|
|
107
|
+
base_url: ENV.fetch('PINGOPS_BASE_URL', nil),
|
|
108
|
+
service_name: ENV.fetch('PINGOPS_SERVICE_NAME', nil),
|
|
109
|
+
debug: ENV.fetch('PINGOPS_DEBUG', nil) == 'true',
|
|
110
|
+
batch_size: env_int('PINGOPS_BATCH_SIZE'),
|
|
111
|
+
batch_timeout: env_int('PINGOPS_BATCH_TIMEOUT'),
|
|
112
|
+
export_mode: ENV.fetch('PINGOPS_EXPORT_MODE', nil)
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Load from file and merge with environment variables (env wins)
|
|
117
|
+
# @param file_path [String] Path to config file
|
|
118
|
+
# @return [Configuration]
|
|
119
|
+
def self.load_with_env(file_path)
|
|
120
|
+
file_config = from_file(file_path)
|
|
121
|
+
env_config = from_env
|
|
122
|
+
|
|
123
|
+
merge(file_config, env_config)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Merge two configurations (second wins for non-nil values)
|
|
127
|
+
# @param base [Configuration] Base configuration
|
|
128
|
+
# @param override [Configuration] Override configuration
|
|
129
|
+
# @return [Configuration]
|
|
130
|
+
def self.merge(base, override)
|
|
131
|
+
new(
|
|
132
|
+
api_key: override.api_key || base.api_key,
|
|
133
|
+
base_url: override.base_url || base.base_url,
|
|
134
|
+
service_name: override.service_name || base.service_name,
|
|
135
|
+
debug: override.debug || base.debug,
|
|
136
|
+
headers_allow_list: override.headers_allow_list || base.headers_allow_list,
|
|
137
|
+
headers_deny_list: override.headers_deny_list || base.headers_deny_list,
|
|
138
|
+
capture_request_body: override.capture_request_body || base.capture_request_body,
|
|
139
|
+
capture_response_body: override.capture_response_body || base.capture_response_body,
|
|
140
|
+
max_request_body_size: non_default_or(override.max_request_body_size, base.max_request_body_size,
|
|
141
|
+
Constants::DEFAULT_MAX_REQUEST_BODY_SIZE),
|
|
142
|
+
max_response_body_size: non_default_or(override.max_response_body_size, base.max_response_body_size,
|
|
143
|
+
Constants::DEFAULT_MAX_RESPONSE_BODY_SIZE),
|
|
144
|
+
domain_allow_list: override.domain_allow_list&.any? ? override.domain_allow_list : base.domain_allow_list,
|
|
145
|
+
domain_deny_list: override.domain_deny_list&.any? ? override.domain_deny_list : base.domain_deny_list,
|
|
146
|
+
header_redaction: override.header_redaction || base.header_redaction,
|
|
147
|
+
batch_size: non_default_or(override.batch_size, base.batch_size, Constants::DEFAULT_BATCH_SIZE),
|
|
148
|
+
batch_timeout: non_default_or(override.batch_timeout, base.batch_timeout, Constants::DEFAULT_BATCH_TIMEOUT),
|
|
149
|
+
export_mode: non_default_or(override.export_mode, base.export_mode, Constants::EXPORT_MODE_BATCHED)
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def parse_domain_rules(rules)
|
|
156
|
+
return nil if rules.nil?
|
|
157
|
+
|
|
158
|
+
rules.map { |rule| DomainRule.from_hash(rule) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_header_redaction(config)
|
|
162
|
+
HeaderRedactionConfig.from_hash(config)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class << self
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def env_int(name)
|
|
169
|
+
value = ENV.fetch(name, nil)
|
|
170
|
+
value&.to_i
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def non_default_or(override, base, default)
|
|
174
|
+
return override if override && override != default
|
|
175
|
+
|
|
176
|
+
base
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|