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
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'opentelemetry-sdk'
|
|
4
|
+
require 'opentelemetry/exporter/otlp'
|
|
5
|
+
|
|
6
|
+
module Pingops
|
|
7
|
+
module Otel
|
|
8
|
+
# PingOps Span Processor
|
|
9
|
+
# Implements OpenTelemetry SpanProcessor interface:
|
|
10
|
+
# - onStart: copy propagated attributes from context onto span
|
|
11
|
+
# - onEnd: filter eligible spans and export via OTLP
|
|
12
|
+
class SpanProcessor
|
|
13
|
+
# @param config [Pingops::Core::Configuration] Resolved configuration
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
@debug = config.debug
|
|
17
|
+
@underlying_processor = create_underlying_processor(config)
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
|
|
20
|
+
log_debug("SpanProcessor initialized with export_mode=#{config.export_mode}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Called when a span is started
|
|
24
|
+
# Copy propagated attributes from parent context onto the span
|
|
25
|
+
# @param span [OpenTelemetry::SDK::Trace::Span] The span being started
|
|
26
|
+
# @param parent_context [OpenTelemetry::Context] The parent context
|
|
27
|
+
def on_start(span, parent_context)
|
|
28
|
+
# Extract PingOps attributes from context and set on span
|
|
29
|
+
begin
|
|
30
|
+
attributes = Core::ContextKeys.extract_attributes(parent_context)
|
|
31
|
+
attributes.each do |key, value|
|
|
32
|
+
next if value.nil?
|
|
33
|
+
|
|
34
|
+
if value.is_a?(Array)
|
|
35
|
+
span.set_attribute(key, value)
|
|
36
|
+
else
|
|
37
|
+
span.set_attribute(key, value.to_s)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
log_debug("Error in on_start: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Delegate to underlying processor
|
|
45
|
+
@underlying_processor.on_start(span, parent_context)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Called when a span ends
|
|
49
|
+
# Check eligibility, apply filters, and export
|
|
50
|
+
# @param span [OpenTelemetry::SDK::Trace::Span] The span that ended
|
|
51
|
+
def on_end(span)
|
|
52
|
+
# Step 1: Check eligibility (CLIENT kind + HTTP attributes)
|
|
53
|
+
unless Core::SpanEligibility.eligible?(span)
|
|
54
|
+
log_debug("Span not eligible: kind=#{span.kind}, name=#{span.name}")
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Step 2: Extract URL and check domain filter
|
|
59
|
+
attributes = span.attributes || {}
|
|
60
|
+
url = Core::DomainFilter.extract_url(attributes)
|
|
61
|
+
|
|
62
|
+
if url && !url.empty?
|
|
63
|
+
result = Core::DomainFilter.check(
|
|
64
|
+
url,
|
|
65
|
+
allow_list: @config.domain_allow_list,
|
|
66
|
+
deny_list: @config.domain_deny_list
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if result.denied?
|
|
70
|
+
log_debug("Span denied by domain filter: url=#{url}")
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Step 3: Create filtered span with header/body filtering applied
|
|
76
|
+
filtered_span = create_filtered_span(span, url)
|
|
77
|
+
|
|
78
|
+
# Step 4: Pass to underlying processor for export
|
|
79
|
+
@underlying_processor.on_end(filtered_span)
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
log_debug("Error in on_end: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Force flush any pending spans
|
|
85
|
+
# @param timeout [Numeric, nil] Optional timeout in seconds
|
|
86
|
+
# @return [Integer] Export result code
|
|
87
|
+
def force_flush(timeout: nil)
|
|
88
|
+
@underlying_processor.force_flush(timeout: timeout)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Shut down the processor
|
|
92
|
+
# @param timeout [Numeric, nil] Optional timeout in seconds
|
|
93
|
+
# @return [Integer] Export result code
|
|
94
|
+
def shutdown(timeout: nil)
|
|
95
|
+
@underlying_processor.shutdown(timeout: timeout)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Alias for on_end (OpenTelemetry SDK compatibility)
|
|
99
|
+
alias on_finish on_end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def create_underlying_processor(config)
|
|
104
|
+
exporter = create_exporter(config)
|
|
105
|
+
|
|
106
|
+
if config.export_mode == Core::Constants::EXPORT_MODE_IMMEDIATE
|
|
107
|
+
OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
|
|
108
|
+
else
|
|
109
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
110
|
+
exporter,
|
|
111
|
+
max_export_batch_size: config.batch_size,
|
|
112
|
+
schedule_delay: config.batch_timeout
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def create_exporter(config)
|
|
118
|
+
endpoint = "#{config.base_url}/v1/traces"
|
|
119
|
+
|
|
120
|
+
headers = {
|
|
121
|
+
'Content-Type' => 'application/json'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
headers['Authorization'] = "Bearer #{config.api_key}" if config.api_key && !config.api_key.empty?
|
|
125
|
+
|
|
126
|
+
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
127
|
+
endpoint: endpoint,
|
|
128
|
+
headers: headers,
|
|
129
|
+
timeout: Core::Constants::DEFAULT_EXPORT_TIMEOUT / 1000.0
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def create_filtered_span(span, url)
|
|
134
|
+
# Find matching domain rule for header/body overrides
|
|
135
|
+
domain_rule = Core::DomainFilter.find_matching_rule(url, @config.domain_allow_list)
|
|
136
|
+
|
|
137
|
+
# Get current context for body capture settings
|
|
138
|
+
context = OpenTelemetry::Context.current
|
|
139
|
+
|
|
140
|
+
# Determine header filtering settings
|
|
141
|
+
headers_allow = domain_rule&.headers_allow_list || @config.headers_allow_list
|
|
142
|
+
headers_deny = domain_rule&.headers_deny_list || @config.headers_deny_list
|
|
143
|
+
|
|
144
|
+
# Filter span attributes (headers)
|
|
145
|
+
filtered_attributes = Core::HeaderFilter.filter_span_attributes(
|
|
146
|
+
span.attributes || {},
|
|
147
|
+
allow_list: headers_allow,
|
|
148
|
+
deny_list: headers_deny,
|
|
149
|
+
redaction_config: @config.header_redaction
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Filter body attributes
|
|
153
|
+
capture_request = Core::BodyCapture.should_capture_request_body?(
|
|
154
|
+
context, domain_rule, @config
|
|
155
|
+
)
|
|
156
|
+
capture_response = Core::BodyCapture.should_capture_response_body?(
|
|
157
|
+
context, domain_rule, @config
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
filtered_attributes = Core::BodyCapture.filter_body_attributes(
|
|
161
|
+
filtered_attributes,
|
|
162
|
+
capture_request: capture_request,
|
|
163
|
+
capture_response: capture_response
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Create a wrapper that presents filtered attributes
|
|
167
|
+
FilteredSpan.new(span, filtered_attributes)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def log_debug(message)
|
|
171
|
+
return unless @debug
|
|
172
|
+
|
|
173
|
+
puts "[Pingops DEBUG] #{message}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Wrapper for a span with filtered attributes
|
|
178
|
+
# Delegates all methods to the underlying span except attributes
|
|
179
|
+
class FilteredSpan
|
|
180
|
+
def initialize(span, filtered_attributes)
|
|
181
|
+
@span = span
|
|
182
|
+
@filtered_attributes = filtered_attributes
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def attributes
|
|
186
|
+
@filtered_attributes
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Delegate all other methods to the underlying span
|
|
190
|
+
def method_missing(method, ...)
|
|
191
|
+
@span.send(method, ...)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def respond_to_missing?(method, include_private = false)
|
|
195
|
+
@span.respond_to?(method, include_private)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Explicitly delegate common span methods for better performance
|
|
199
|
+
def name
|
|
200
|
+
@span.name
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def kind
|
|
204
|
+
@span.kind
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def status
|
|
208
|
+
@span.status
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def parent_span_id
|
|
212
|
+
@span.parent_span_id
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def start_timestamp
|
|
216
|
+
@span.start_timestamp
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def end_timestamp
|
|
220
|
+
@span.end_timestamp
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def links
|
|
224
|
+
@span.links
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def events
|
|
228
|
+
@span.events
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def resource
|
|
232
|
+
@span.resource
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def instrumentation_scope
|
|
236
|
+
@span.instrumentation_scope
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def instrumentation_library
|
|
240
|
+
@span.instrumentation_library
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def span_context
|
|
244
|
+
@span.span_context
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def trace_id
|
|
248
|
+
@span.trace_id
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def span_id
|
|
252
|
+
@span.span_id
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def trace_flags
|
|
256
|
+
@span.trace_flags
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def tracestate
|
|
260
|
+
@span.tracestate
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def to_span_data
|
|
264
|
+
# For OTLP export, we need to return span data with filtered attributes
|
|
265
|
+
if @span.respond_to?(:to_span_data)
|
|
266
|
+
span_data = @span.to_span_data
|
|
267
|
+
# The OTLP exporter uses span_data, so we need to ensure our filtered
|
|
268
|
+
# attributes are used. SpanData is immutable, so we create a new one.
|
|
269
|
+
OpenTelemetry::SDK::Trace::SpanData.new(
|
|
270
|
+
span_data.name,
|
|
271
|
+
span_data.kind,
|
|
272
|
+
span_data.status,
|
|
273
|
+
span_data.parent_span_id,
|
|
274
|
+
span_data.total_recorded_attributes,
|
|
275
|
+
span_data.total_recorded_events,
|
|
276
|
+
span_data.total_recorded_links,
|
|
277
|
+
span_data.start_timestamp,
|
|
278
|
+
span_data.end_timestamp,
|
|
279
|
+
@filtered_attributes,
|
|
280
|
+
span_data.links,
|
|
281
|
+
span_data.events,
|
|
282
|
+
span_data.resource,
|
|
283
|
+
span_data.instrumentation_scope,
|
|
284
|
+
span_data.span_context,
|
|
285
|
+
span_data.tracestate
|
|
286
|
+
)
|
|
287
|
+
else
|
|
288
|
+
@span
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'opentelemetry-sdk'
|
|
4
|
+
|
|
5
|
+
module Pingops
|
|
6
|
+
module Otel
|
|
7
|
+
# Tracer Provider management for PingOps
|
|
8
|
+
# Manages the isolated TracerProvider that is registered globally
|
|
9
|
+
module TracerProvider
|
|
10
|
+
@provider = nil
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Set the PingOps TracerProvider
|
|
15
|
+
# @param provider [OpenTelemetry::SDK::Trace::TracerProvider, nil] The provider to set
|
|
16
|
+
def set(provider)
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
@provider = provider
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get the PingOps TracerProvider
|
|
23
|
+
# @return [OpenTelemetry::SDK::Trace::TracerProvider] The stored provider or global provider
|
|
24
|
+
def get
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@provider || OpenTelemetry.tracer_provider
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get a tracer from the PingOps TracerProvider
|
|
31
|
+
# @param name [String] Tracer name (default: "pingops-sdk")
|
|
32
|
+
# @param version [String] Tracer version (default: "0.0.1")
|
|
33
|
+
# @return [OpenTelemetry::Trace::Tracer]
|
|
34
|
+
def tracer(name: Core::Constants::TRACER_NAME, version: Core::Constants::TRACER_VERSION)
|
|
35
|
+
get.tracer(name, version)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Create an isolated TracerProvider with the given resource and span processor
|
|
39
|
+
# @param resource [OpenTelemetry::SDK::Resources::Resource] The resource
|
|
40
|
+
# @param span_processor [Pingops::Otel::SpanProcessor] The span processor
|
|
41
|
+
# @return [OpenTelemetry::SDK::Trace::TracerProvider]
|
|
42
|
+
def create_isolated(resource:, span_processor:)
|
|
43
|
+
provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
|
|
44
|
+
provider.add_span_processor(span_processor)
|
|
45
|
+
provider
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Shutdown the stored TracerProvider
|
|
49
|
+
# @param timeout [Numeric, nil] Optional timeout in seconds
|
|
50
|
+
# @return [void]
|
|
51
|
+
def shutdown(timeout: nil)
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
return unless @provider
|
|
54
|
+
|
|
55
|
+
@provider.shutdown(timeout: timeout) if @provider.respond_to?(:shutdown)
|
|
56
|
+
@provider = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Force flush the stored TracerProvider
|
|
61
|
+
# @param timeout [Numeric, nil] Optional timeout in seconds
|
|
62
|
+
# @return [Integer] Export result code
|
|
63
|
+
def force_flush(timeout: nil)
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
return OpenTelemetry::SDK::Trace::Export::SUCCESS unless @provider
|
|
66
|
+
|
|
67
|
+
if @provider.respond_to?(:force_flush)
|
|
68
|
+
@provider.force_flush(timeout: timeout)
|
|
69
|
+
else
|
|
70
|
+
OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if a provider is set
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def set?
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
!@provider.nil?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Clear the stored provider (for testing)
|
|
84
|
+
# @return [void]
|
|
85
|
+
def clear!
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@provider = nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Register entry point for auto-initialization
|
|
4
|
+
# Usage: ruby -r pingops/register your_app.rb
|
|
5
|
+
# Or: require "pingops/register" at the top of your application
|
|
6
|
+
|
|
7
|
+
require_relative '../pingops'
|
|
8
|
+
|
|
9
|
+
# Auto-initialize from environment and/or config file
|
|
10
|
+
module Pingops
|
|
11
|
+
module Register
|
|
12
|
+
class << self
|
|
13
|
+
def auto_initialize
|
|
14
|
+
config_file = ENV.fetch('PINGOPS_CONFIG_FILE', nil)
|
|
15
|
+
|
|
16
|
+
config = if config_file && File.exist?(config_file)
|
|
17
|
+
# Load from file and merge with env
|
|
18
|
+
Core::Configuration.load_with_env(config_file)
|
|
19
|
+
else
|
|
20
|
+
# Use env only
|
|
21
|
+
Core::Configuration.from_env
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Only initialize if we have required fields
|
|
25
|
+
if config.base_url && config.service_name
|
|
26
|
+
Pingops.initialize(config)
|
|
27
|
+
log_debug('Auto-initialized PingOps SDK')
|
|
28
|
+
else
|
|
29
|
+
log_debug('Skipping auto-init: missing baseUrl or serviceName')
|
|
30
|
+
end
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
# Don't crash the application on auto-init failure
|
|
33
|
+
warn "[Pingops] Auto-initialization failed: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def log_debug(message)
|
|
39
|
+
return unless ENV.fetch('PINGOPS_DEBUG', nil) == 'true'
|
|
40
|
+
|
|
41
|
+
puts "[Pingops DEBUG] #{message}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Run auto-initialization when this file is required
|
|
48
|
+
Pingops::Register.auto_initialize
|