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,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+ require 'opentelemetry-api'
5
+
6
+ module Pingops
7
+ # Main SDK module providing the public API
8
+ module SDK
9
+ @initialized = false
10
+ @config = nil
11
+ @span_processor = nil
12
+ @mutex = Mutex.new
13
+
14
+ class << self
15
+ # Initialize the PingOps SDK
16
+ #
17
+ # @overload initialize(config)
18
+ # @param config [Hash, Core::Configuration] Full processor config object
19
+ # @overload initialize(config_file_path)
20
+ # @param config_file_path [String] Path to a JSON or YAML config file
21
+ # @overload initialize(config)
22
+ # @param config [Hash] Hash with :config_file key pointing to config file
23
+ #
24
+ # @raise [Pingops::ConfigurationError] if required fields are missing
25
+ # @return [void]
26
+ def initialize_pingops(config)
27
+ @mutex.synchronize do
28
+ if @initialized
29
+ log_debug('SDK already initialized, skipping')
30
+ return
31
+ end
32
+
33
+ # Resolve configuration
34
+ resolved_config = resolve_config(config)
35
+ resolved_config.validate!
36
+
37
+ @config = resolved_config
38
+ log_debug("Configuration resolved: service_name=#{resolved_config.service_name}")
39
+
40
+ # Create resource with service.name
41
+ resource = create_resource(resolved_config)
42
+
43
+ # Create span processor
44
+ @span_processor = Otel::SpanProcessor.new(resolved_config)
45
+ log_debug('Span processor created')
46
+
47
+ # Install instrumentations
48
+ Instrumentation::Manager.install(resolved_config)
49
+
50
+ # Configure OpenTelemetry SDK
51
+ configure_otel_sdk(resource, @span_processor, resolved_config)
52
+
53
+ # Create and register isolated TracerProvider
54
+ isolated_provider = Otel::TracerProvider.create_isolated(
55
+ resource: resource,
56
+ span_processor: @span_processor
57
+ )
58
+ Otel::TracerProvider.set(isolated_provider)
59
+
60
+ # Register as global provider (after main SDK, so it becomes the default)
61
+ OpenTelemetry.tracer_provider = isolated_provider
62
+
63
+ @initialized = true
64
+ log_debug('SDK initialized successfully')
65
+ end
66
+ end
67
+
68
+ # Shutdown the PingOps SDK
69
+ # Shuts down isolated TracerProvider first, then main OTEL SDK
70
+ #
71
+ # @return [void]
72
+ def shutdown_pingops
73
+ @mutex.synchronize do
74
+ return unless @initialized
75
+
76
+ log_debug('Shutting down SDK...')
77
+
78
+ # 1. Shutdown isolated TracerProvider
79
+ Otel::TracerProvider.shutdown
80
+
81
+ # 2. Shutdown span processor
82
+ @span_processor&.shutdown
83
+
84
+ # 3. Clear instrumentations
85
+ Instrumentation::Manager.reset!
86
+
87
+ # 4. Clear state
88
+ @initialized = false
89
+ @config = nil
90
+ @span_processor = nil
91
+
92
+ log_debug('SDK shutdown complete')
93
+ end
94
+ end
95
+
96
+ # Start a trace with custom attributes
97
+ #
98
+ # @param options [Hash, Core::StartTraceOptions] Trace options
99
+ # @option options [Hash, Core::TraceAttributes] :attributes Custom attributes
100
+ # @option options [String] :seed Optional seed for deterministic trace ID
101
+ # @yield Block to execute within the trace
102
+ # @return [Object] Result of the block
103
+ #
104
+ # @example
105
+ # Pingops.start_trace(attributes: { user_id: "123" }) do
106
+ # # Make HTTP requests here
107
+ # Net::HTTP.get(URI("https://api.example.com"))
108
+ # end
109
+ def start_trace(options = {}, &block)
110
+ raise ArgumentError, 'Block required for start_trace' unless block_given?
111
+
112
+ # Auto-initialize if not initialized
113
+ auto_initialize_if_needed
114
+
115
+ opts = options.is_a?(Core::StartTraceOptions) ? options : Core::StartTraceOptions.from_hash(options)
116
+
117
+ # Compute trace ID
118
+ trace_id = compute_trace_id(opts)
119
+ span_id = Core::IdGenerator.generate_span_id
120
+
121
+ # Create span context
122
+ span_context = OpenTelemetry::Trace::SpanContext.new(
123
+ trace_id: Core::IdGenerator.trace_id_to_bytes(trace_id),
124
+ span_id: Core::IdGenerator.span_id_to_bytes(span_id),
125
+ trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED
126
+ )
127
+
128
+ # Get current context and set trace ID
129
+ context = OpenTelemetry::Context.current
130
+ context = Core::ContextKeys.set_trace_id(context, trace_id)
131
+
132
+ # Set attributes on context if provided
133
+ context = Core::ContextKeys.set_attributes(context, opts.attributes) if opts.attributes
134
+
135
+ # Get tracer from PingOps provider
136
+ tracer = Otel::TracerProvider.tracer
137
+
138
+ # Create a non-recording span to establish the context
139
+ parent_span = OpenTelemetry::Trace.non_recording_span(span_context)
140
+ context = OpenTelemetry::Trace.context_with_span(parent_span, parent_context: context)
141
+
142
+ # Run the block within the span context
143
+ OpenTelemetry::Context.with_current(context) do
144
+ tracer.in_span(
145
+ Core::Constants::ROOT_SPAN_NAME,
146
+ kind: :internal,
147
+ &block
148
+ )
149
+ end
150
+ end
151
+
152
+ # Get the active trace ID from the current context
153
+ #
154
+ # @return [String, nil] The trace ID or nil if no active span
155
+ def active_trace_id
156
+ span = OpenTelemetry::Trace.current_span
157
+ return nil unless span
158
+
159
+ span_context = span.context
160
+ return nil unless span_context&.valid?
161
+
162
+ Core::IdGenerator.bytes_to_trace_id(span_context.trace_id)
163
+ end
164
+
165
+ # Get the active span ID from the current context
166
+ #
167
+ # @return [String, nil] The span ID or nil if no active span
168
+ def active_span_id
169
+ span = OpenTelemetry::Trace.current_span
170
+ return nil unless span
171
+
172
+ span_context = span.context
173
+ return nil unless span_context&.valid?
174
+
175
+ Core::IdGenerator.bytes_to_span_id(span_context.span_id)
176
+ end
177
+
178
+ # Check if the SDK is initialized
179
+ #
180
+ # @return [Boolean]
181
+ def initialized?
182
+ @mutex.synchronize do
183
+ @initialized
184
+ end
185
+ end
186
+
187
+ # Get the current configuration
188
+ #
189
+ # @return [Core::Configuration, nil]
190
+ def configuration
191
+ @mutex.synchronize do
192
+ @config
193
+ end
194
+ end
195
+
196
+ # Reset SDK state (for testing)
197
+ # @api private
198
+ def reset!
199
+ @mutex.synchronize do
200
+ Otel::TracerProvider.clear!
201
+ Otel::ConfigStore.clear!
202
+ Instrumentation::Manager.reset!
203
+ @initialized = false
204
+ @config = nil
205
+ @span_processor = nil
206
+ end
207
+ end
208
+
209
+ private
210
+
211
+ def resolve_config(config)
212
+ case config
213
+ when String
214
+ # File path
215
+ Core::Configuration.load_with_env(config)
216
+ when Hash
217
+ if config[:config_file] || config['configFile']
218
+ # Hash with config_file key
219
+ file_path = config[:config_file] || config['configFile']
220
+ Core::Configuration.load_with_env(file_path)
221
+ else
222
+ # Regular config hash - merge with env
223
+ base_config = Core::Configuration.from_hash(config)
224
+ env_config = Core::Configuration.from_env
225
+ Core::Configuration.merge(base_config, env_config)
226
+ end
227
+ when Core::Configuration
228
+ # Already a Configuration object
229
+ config
230
+ else
231
+ raise ArgumentError, "Invalid config type: #{config.class}"
232
+ end
233
+ end
234
+
235
+ def create_resource(config)
236
+ OpenTelemetry::SDK::Resources::Resource.create(
237
+ 'service.name' => config.service_name
238
+ )
239
+ end
240
+
241
+ def configure_otel_sdk(resource, _span_processor, _config)
242
+ OpenTelemetry::SDK.configure do |c|
243
+ c.resource = resource
244
+
245
+ # Get instrumentations
246
+ Instrumentation::Manager.otel_instrumentations.each do |instrumentation|
247
+ c.use(instrumentation.class.name.split('::').last)
248
+ rescue StandardError
249
+ # Ignore if instrumentation can't be used
250
+ end
251
+ end
252
+ end
253
+
254
+ def auto_initialize_if_needed
255
+ return if initialized?
256
+
257
+ # Check for required environment variables
258
+ api_key = ENV.fetch('PINGOPS_API_KEY', nil)
259
+ base_url = ENV.fetch('PINGOPS_BASE_URL', nil)
260
+ service_name = ENV.fetch('PINGOPS_SERVICE_NAME', nil)
261
+
262
+ missing = []
263
+ missing << 'PINGOPS_API_KEY' unless api_key
264
+ missing << 'PINGOPS_BASE_URL' unless base_url
265
+ missing << 'PINGOPS_SERVICE_NAME' unless service_name
266
+
267
+ raise ConfigurationError, "Auto-initialization requires: #{missing.join(', ')}" unless missing.empty?
268
+
269
+ # Initialize from environment
270
+ initialize_pingops(Core::Configuration.from_env)
271
+ end
272
+
273
+ def compute_trace_id(options)
274
+ # Priority: attributes.traceId > seed > random
275
+ if options.attributes&.trace_id
276
+ options.attributes.trace_id
277
+ elsif options.seed
278
+ Core::IdGenerator.generate_trace_id(options.seed)
279
+ else
280
+ Core::IdGenerator.generate_trace_id
281
+ end
282
+ end
283
+
284
+ def log_debug(message)
285
+ return unless @config&.debug
286
+
287
+ puts "[Pingops DEBUG] #{message}"
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ VERSION = '0.0.1'
5
+ end
data/lib/pingops.rb ADDED
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pingops/version'
4
+ require_relative 'pingops/errors'
5
+
6
+ # Core components (pure logic, no OpenTelemetry dependencies loaded yet)
7
+ require_relative 'pingops/core/constants'
8
+ require_relative 'pingops/core/types'
9
+ require_relative 'pingops/core/configuration'
10
+ require_relative 'pingops/core/id_generator'
11
+ require_relative 'pingops/core/domain_filter'
12
+ require_relative 'pingops/core/header_filter'
13
+ require_relative 'pingops/core/span_eligibility'
14
+ require_relative 'pingops/core/body_capture'
15
+ require_relative 'pingops/core/context_keys'
16
+
17
+ # OpenTelemetry integration
18
+ require_relative 'pingops/otel/config_store'
19
+ require_relative 'pingops/otel/tracer_provider'
20
+ require_relative 'pingops/otel/span_processor'
21
+
22
+ # Instrumentation
23
+ require_relative 'pingops/instrumentation/net_http'
24
+ require_relative 'pingops/instrumentation/manager'
25
+
26
+ # SDK public API
27
+ require_relative 'pingops/sdk'
28
+
29
+ # Main Pingops module
30
+ # Provides a convenient interface to the PingOps SDK
31
+ module Pingops
32
+ class << self
33
+ # Initialize the PingOps SDK
34
+ #
35
+ # @overload initialize(config)
36
+ # @param config [Hash] Full processor config object
37
+ # @overload initialize(config_file_path)
38
+ # @param config_file_path [String] Path to a JSON or YAML config file
39
+ #
40
+ # @example Initialize with a hash
41
+ # Pingops.initialize(
42
+ # api_key: "your-api-key",
43
+ # base_url: "https://api.pingops.com",
44
+ # service_name: "my-service"
45
+ # )
46
+ #
47
+ # @example Initialize with a config file
48
+ # Pingops.initialize("/path/to/pingops.json")
49
+ #
50
+ # @example Initialize with camelCase keys (compatible with JS/Node)
51
+ # Pingops.initialize(
52
+ # apiKey: "your-api-key",
53
+ # baseUrl: "https://api.pingops.com",
54
+ # serviceName: "my-service"
55
+ # )
56
+ #
57
+ # @raise [Pingops::ConfigurationError] if required fields are missing
58
+ # @return [void]
59
+ def initialize(config = nil)
60
+ if config.nil?
61
+ # Try to initialize from environment
62
+ SDK.initialize_pingops(Core::Configuration.from_env)
63
+ else
64
+ SDK.initialize_pingops(config)
65
+ end
66
+ end
67
+
68
+ # Shutdown the PingOps SDK
69
+ #
70
+ # @return [void]
71
+ def shutdown
72
+ SDK.shutdown_pingops
73
+ end
74
+
75
+ # Start a trace with custom attributes
76
+ #
77
+ # @param options [Hash] Trace options
78
+ # @option options [Hash] :attributes Custom attributes (user_id, session_id, tags, metadata)
79
+ # @option options [String] :seed Optional seed for deterministic trace ID
80
+ # @yield Block to execute within the trace
81
+ # @return [Object] Result of the block
82
+ #
83
+ # @example Basic usage
84
+ # Pingops.start_trace do
85
+ # Net::HTTP.get(URI("https://api.example.com"))
86
+ # end
87
+ #
88
+ # @example With custom attributes
89
+ # Pingops.start_trace(
90
+ # attributes: {
91
+ # user_id: "user-123",
92
+ # session_id: "session-456",
93
+ # tags: ["production", "web"],
94
+ # metadata: { "request_id" => "abc123" }
95
+ # }
96
+ # ) do
97
+ # # Your code here
98
+ # end
99
+ #
100
+ # @example With deterministic trace ID
101
+ # Pingops.start_trace(seed: "my-unique-seed") do
102
+ # # Same seed always produces the same trace ID
103
+ # end
104
+ def start_trace(options = {}, &block)
105
+ SDK.start_trace(options, &block)
106
+ end
107
+
108
+ # Get the active trace ID from the current context
109
+ #
110
+ # @return [String, nil] The trace ID or nil if no active span
111
+ #
112
+ # @example
113
+ # Pingops.start_trace do
114
+ # trace_id = Pingops.active_trace_id
115
+ # puts "Current trace ID: #{trace_id}"
116
+ # end
117
+ def active_trace_id
118
+ SDK.active_trace_id
119
+ end
120
+
121
+ # Get the active span ID from the current context
122
+ #
123
+ # @return [String, nil] The span ID or nil if no active span
124
+ #
125
+ # @example
126
+ # Pingops.start_trace do
127
+ # span_id = Pingops.active_span_id
128
+ # puts "Current span ID: #{span_id}"
129
+ # end
130
+ def active_span_id
131
+ SDK.active_span_id
132
+ end
133
+
134
+ # Check if the SDK is initialized
135
+ #
136
+ # @return [Boolean]
137
+ def initialized?
138
+ SDK.initialized?
139
+ end
140
+
141
+ # Get the current configuration
142
+ #
143
+ # @return [Pingops::Core::Configuration, nil]
144
+ def configuration
145
+ SDK.configuration
146
+ end
147
+
148
+ # Alias for initialize (follows JS/Node naming convention)
149
+ alias initialize_pingops initialize
150
+
151
+ # Alias for shutdown (follows JS/Node naming convention)
152
+ alias shutdown_pingops shutdown
153
+
154
+ # Alias for active_trace_id (follows JS/Node naming convention)
155
+ alias get_active_trace_id active_trace_id
156
+
157
+ # Alias for active_span_id (follows JS/Node naming convention)
158
+ alias get_active_span_id active_span_id
159
+ end
160
+ end
data/pingops.gemspec ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/pingops/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'pingops'
7
+ spec.version = Pingops::VERSION
8
+ spec.authors = ['PingOps']
9
+ spec.email = ['support@pingops.com']
10
+
11
+ spec.summary = 'PingOps SDK for Ruby - OpenTelemetry-based HTTP tracing'
12
+ spec.description = 'Instruments outgoing HTTP requests, exports spans to PingOps backend via OTLP, ' \
13
+ 'and supports manual tracing with custom attributes, domain filtering, and header redaction.'
14
+ spec.homepage = 'https://github.com/pingops/pingops-rb'
15
+ spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.0.0'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = spec.homepage
20
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata['rubygems_mfa_required'] = 'true'
22
+
23
+ spec.files = Dir.glob('{lib,exe}/**/*') + %w[
24
+ README.md
25
+ CHANGELOG.md
26
+ pingops.gemspec
27
+ ]
28
+
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ # OpenTelemetry dependencies
34
+ spec.add_dependency 'opentelemetry-api', '~> 1.2'
35
+ spec.add_dependency 'opentelemetry-exporter-otlp', '~> 0.28'
36
+ spec.add_dependency 'opentelemetry-instrumentation-faraday', '~> 0.24'
37
+ spec.add_dependency 'opentelemetry-instrumentation-http_client', '~> 0.22'
38
+ spec.add_dependency 'opentelemetry-instrumentation-net_http', '~> 0.22'
39
+ spec.add_dependency 'opentelemetry-sdk', '~> 1.4'
40
+
41
+ # Configuration
42
+ spec.add_dependency 'base64', '~> 0.2'
43
+ spec.add_dependency 'json', '~> 2.0'
44
+
45
+ # Development dependencies
46
+ spec.add_development_dependency 'bundler', '~> 2.0'
47
+ spec.add_development_dependency 'rake', '~> 13.0'
48
+ spec.add_development_dependency 'rspec', '~> 3.12'
49
+ spec.add_development_dependency 'rubocop', '~> 1.50'
50
+ spec.add_development_dependency 'rubocop-rspec', '~> 2.20'
51
+ spec.add_development_dependency 'simplecov', '~> 0.22'
52
+ spec.add_development_dependency 'webmock', '~> 3.18'
53
+ spec.add_development_dependency 'yard', '~> 0.9'
54
+ end