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.
@@ -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