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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ module Core
5
+ # Domain rule for allow/deny lists
6
+ # @attr domain [String] Domain to match (exact or suffix if starts with ".")
7
+ # @attr paths [Array<String>, nil] If set, URL path must start with one of these
8
+ # @attr headers_allow_list [Array<String>, nil] Override global: only these headers for this domain
9
+ # @attr headers_deny_list [Array<String>, nil] Override global: exclude these headers for this domain
10
+ # @attr capture_request_body [Boolean, nil] Override global/context for this domain
11
+ # @attr capture_response_body [Boolean, nil] Override global/context for this domain
12
+ class DomainRule
13
+ attr_reader :domain, :paths, :headers_allow_list, :headers_deny_list,
14
+ :capture_request_body, :capture_response_body
15
+
16
+ def initialize(
17
+ domain:,
18
+ paths: nil,
19
+ headers_allow_list: nil,
20
+ headers_deny_list: nil,
21
+ capture_request_body: nil,
22
+ capture_response_body: nil
23
+ )
24
+ @domain = domain.to_s
25
+ @paths = paths&.map(&:to_s)
26
+ @headers_allow_list = headers_allow_list&.map { |h| h.to_s.downcase }
27
+ @headers_deny_list = headers_deny_list&.map { |h| h.to_s.downcase }
28
+ @capture_request_body = capture_request_body
29
+ @capture_response_body = capture_response_body
30
+ end
31
+
32
+ # Check if this rule matches the given domain
33
+ # @param target_domain [String] The domain to check
34
+ # @return [Boolean]
35
+ def matches_domain?(target_domain)
36
+ return false if target_domain.nil? || target_domain.empty?
37
+
38
+ target = target_domain.downcase
39
+ rule_domain = @domain.downcase
40
+
41
+ if rule_domain.start_with?('.')
42
+ # Suffix match: ".example.com" matches "api.example.com" or "example.com"
43
+ target.end_with?(rule_domain) || target == rule_domain[1..]
44
+ else
45
+ # Exact match
46
+ target == rule_domain
47
+ end
48
+ end
49
+
50
+ # Check if this rule matches the given path
51
+ # @param path [String] The path to check
52
+ # @return [Boolean]
53
+ def matches_path?(path)
54
+ return true if @paths.nil? || @paths.empty?
55
+
56
+ @paths.any? { |p| path.to_s.start_with?(p) }
57
+ end
58
+
59
+ # Create from hash (for config loading)
60
+ # @param hash [Hash] Hash representation
61
+ # @return [DomainRule]
62
+ def self.from_hash(hash)
63
+ return hash if hash.is_a?(DomainRule)
64
+
65
+ new(
66
+ domain: hash[:domain] || hash['domain'],
67
+ paths: hash[:paths] || hash['paths'],
68
+ headers_allow_list: hash[:headers_allow_list] || hash['headersAllowList'],
69
+ headers_deny_list: hash[:headers_deny_list] || hash['headersDenyList'],
70
+ capture_request_body: hash[:capture_request_body] || hash['captureRequestBody'],
71
+ capture_response_body: hash[:capture_response_body] || hash['captureResponseBody']
72
+ )
73
+ end
74
+ end
75
+
76
+ # Header redaction configuration
77
+ # @attr sensitive_patterns [Array<String>] Case-insensitive patterns to match header names
78
+ # @attr strategy [String] How to redact: replace, partial, partial_end, remove
79
+ # @attr redaction_string [String] String used for redaction
80
+ # @attr visible_chars [Integer] For partial strategies, how many chars to show
81
+ # @attr enabled [Boolean] If false, no redaction
82
+ class HeaderRedactionConfig
83
+ attr_reader :sensitive_patterns, :strategy, :redaction_string, :visible_chars, :enabled
84
+
85
+ def initialize(
86
+ sensitive_patterns: nil,
87
+ strategy: nil,
88
+ redaction_string: nil,
89
+ visible_chars: nil,
90
+ enabled: nil
91
+ )
92
+ @sensitive_patterns = (sensitive_patterns || Constants::DEFAULT_SENSITIVE_HEADER_PATTERNS).map(&:downcase)
93
+ @strategy = strategy || Constants::REDACTION_STRATEGY_REPLACE
94
+ @redaction_string = redaction_string || Constants::DEFAULT_REDACTION_STRING
95
+ @visible_chars = visible_chars || Constants::DEFAULT_VISIBLE_CHARS
96
+ @enabled = enabled.nil? || enabled
97
+ end
98
+
99
+ # Create from hash (for config loading)
100
+ # @param hash [Hash, nil] Hash representation
101
+ # @return [HeaderRedactionConfig]
102
+ def self.from_hash(hash)
103
+ return new if hash.nil?
104
+ return hash if hash.is_a?(HeaderRedactionConfig)
105
+
106
+ new(
107
+ sensitive_patterns: hash[:sensitive_patterns] || hash['sensitivePatterns'],
108
+ strategy: hash[:strategy] || hash['strategy'],
109
+ redaction_string: hash[:redaction_string] || hash['redactionString'],
110
+ visible_chars: hash[:visible_chars] || hash['visibleChars'],
111
+ enabled: hash[:enabled].nil? ? hash['enabled'] : hash[:enabled]
112
+ )
113
+ end
114
+ end
115
+
116
+ # Trace attributes for startTrace
117
+ # @attr trace_id [String, nil] Optional; if set, used as trace ID
118
+ # @attr user_id [String, nil] Propagated to context and span attribute
119
+ # @attr session_id [String, nil] Propagated to context and span attribute
120
+ # @attr tags [Array<String>, nil] Propagated to context and span attribute
121
+ # @attr metadata [Hash<String, String>, nil] Propagated to context and span attributes
122
+ # @attr capture_request_body [Boolean, nil] Override for request body capture
123
+ # @attr capture_response_body [Boolean, nil] Override for response body capture
124
+ class TraceAttributes
125
+ attr_reader :trace_id, :user_id, :session_id, :tags, :metadata,
126
+ :capture_request_body, :capture_response_body
127
+
128
+ def initialize(
129
+ trace_id: nil,
130
+ user_id: nil,
131
+ session_id: nil,
132
+ tags: nil,
133
+ metadata: nil,
134
+ capture_request_body: nil,
135
+ capture_response_body: nil
136
+ )
137
+ @trace_id = trace_id
138
+ @user_id = user_id
139
+ @session_id = session_id
140
+ @tags = tags
141
+ @metadata = metadata
142
+ @capture_request_body = capture_request_body
143
+ @capture_response_body = capture_response_body
144
+ end
145
+
146
+ # Create from hash
147
+ # @param hash [Hash, nil] Hash representation
148
+ # @return [TraceAttributes, nil]
149
+ def self.from_hash(hash)
150
+ return nil if hash.nil?
151
+ return hash if hash.is_a?(TraceAttributes)
152
+
153
+ new(
154
+ trace_id: hash[:trace_id] || hash['traceId'],
155
+ user_id: hash[:user_id] || hash['userId'],
156
+ session_id: hash[:session_id] || hash['sessionId'],
157
+ tags: hash[:tags] || hash['tags'],
158
+ metadata: hash[:metadata] || hash['metadata'],
159
+ capture_request_body: hash[:capture_request_body] || hash['captureRequestBody'],
160
+ capture_response_body: hash[:capture_response_body] || hash['captureResponseBody']
161
+ )
162
+ end
163
+ end
164
+
165
+ # Options for startTrace
166
+ # @attr attributes [TraceAttributes, nil] Optional attributes to set in context
167
+ # @attr seed [String, nil] Optional. If provided, trace ID is deterministic
168
+ class StartTraceOptions
169
+ attr_reader :attributes, :seed
170
+
171
+ def initialize(attributes: nil, seed: nil)
172
+ @attributes = attributes.is_a?(Hash) ? TraceAttributes.from_hash(attributes) : attributes
173
+ @seed = seed
174
+ end
175
+
176
+ # Create from hash
177
+ # @param hash [Hash, nil] Hash representation
178
+ # @return [StartTraceOptions]
179
+ def self.from_hash(hash)
180
+ return new if hash.nil?
181
+ return hash if hash.is_a?(StartTraceOptions)
182
+
183
+ new(
184
+ attributes: hash[:attributes] || hash['attributes'],
185
+ seed: hash[:seed] || hash['seed']
186
+ )
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ # Base error class for all PingOps errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when configuration is invalid or missing required fields
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when the SDK is not initialized
11
+ class NotInitializedError < Error; end
12
+
13
+ # Raised when auto-initialization fails
14
+ class AutoInitializationError < Error; end
15
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ module Instrumentation
5
+ # Manages all PingOps instrumentations
6
+ module Manager
7
+ @installed = false
8
+ @mutex = Mutex.new
9
+
10
+ class << self
11
+ # Install all instrumentations
12
+ # @param config [Core::Configuration] Configuration
13
+ def install(config)
14
+ @mutex.synchronize do
15
+ return if @installed
16
+
17
+ # Store config in global store for instrumentations
18
+ Otel::ConfigStore.set(config)
19
+
20
+ # Install Net::HTTP instrumentation
21
+ NetHttp.install
22
+
23
+ @installed = true
24
+ log_debug(config, 'Instrumentations installed')
25
+ end
26
+ end
27
+
28
+ # Check if instrumentations are installed
29
+ # @return [Boolean]
30
+ def installed?
31
+ @mutex.synchronize do
32
+ @installed
33
+ end
34
+ end
35
+
36
+ # Reset state (for testing)
37
+ def reset!
38
+ @mutex.synchronize do
39
+ @installed = false
40
+ Otel::ConfigStore.clear!
41
+ NetHttp.reset!
42
+ end
43
+ end
44
+
45
+ # Get OpenTelemetry instrumentations to register with the SDK
46
+ # @return [Array] Array of instrumentation instances
47
+ def otel_instrumentations
48
+ instrumentations = []
49
+
50
+ # Add Net::HTTP instrumentation
51
+ begin
52
+ require 'opentelemetry/instrumentation/net_http'
53
+ instrumentations << OpenTelemetry::Instrumentation::Net::HTTP::Instrumentation.instance
54
+ rescue LoadError
55
+ # Net::HTTP instrumentation not available
56
+ end
57
+
58
+ # Add Faraday instrumentation if available
59
+ begin
60
+ require 'opentelemetry/instrumentation/faraday'
61
+ instrumentations << OpenTelemetry::Instrumentation::Faraday::Instrumentation.instance
62
+ rescue LoadError
63
+ # Faraday instrumentation not available
64
+ end
65
+
66
+ # Add HTTP client instrumentation if available
67
+ begin
68
+ require 'opentelemetry/instrumentation/http_client'
69
+ instrumentations << OpenTelemetry::Instrumentation::HttpClient::Instrumentation.instance
70
+ rescue LoadError
71
+ # HTTP client instrumentation not available
72
+ end
73
+
74
+ instrumentations
75
+ end
76
+
77
+ private
78
+
79
+ def log_debug(config, message)
80
+ return unless config.debug
81
+
82
+ puts "[Pingops DEBUG] #{message}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Pingops
6
+ module Instrumentation
7
+ # Net::HTTP instrumentation for capturing headers and bodies
8
+ # This enhances the standard OpenTelemetry Net::HTTP instrumentation
9
+ module NetHttp
10
+ class << self
11
+ # Install the instrumentation
12
+ def install
13
+ return if @installed
14
+
15
+ # Prepend our module to Net::HTTP
16
+ ::Net::HTTP.prepend(RequestPatch)
17
+ @installed = true
18
+ end
19
+
20
+ # Check if installed
21
+ # @return [Boolean]
22
+ def installed?
23
+ @installed || false
24
+ end
25
+
26
+ # Uninstall (for testing)
27
+ def reset!
28
+ @installed = false
29
+ end
30
+ end
31
+
32
+ # Patch for Net::HTTP#request
33
+ module RequestPatch
34
+ def request(req, body = nil, &block)
35
+ # Get current span from context
36
+ span = OpenTelemetry::Trace.current_span
37
+ return super unless span&.recording?
38
+
39
+ # Capture request headers
40
+ capture_request_headers(span, req)
41
+
42
+ # Capture request body if enabled
43
+ capture_request_body(span, body || req.body)
44
+
45
+ # Execute the request
46
+ response = super
47
+
48
+ # Capture response headers
49
+ capture_response_headers(span, response)
50
+
51
+ # Capture response body if enabled
52
+ capture_response_body(span, response)
53
+
54
+ response
55
+ end
56
+
57
+ private
58
+
59
+ def capture_request_headers(span, request)
60
+ request.each_header do |name, value|
61
+ attr_name = "#{Core::Constants::ATTR_HTTP_REQUEST_HEADER_PREFIX}#{name.downcase}"
62
+ span.set_attribute(attr_name, value)
63
+ end
64
+ rescue StandardError => e
65
+ log_debug("Error capturing request headers: #{e.message}")
66
+ end
67
+
68
+ def capture_response_headers(span, response)
69
+ response.each_header do |name, value|
70
+ attr_name = "#{Core::Constants::ATTR_HTTP_RESPONSE_HEADER_PREFIX}#{name.downcase}"
71
+ span.set_attribute(attr_name, value)
72
+ end
73
+ rescue StandardError => e
74
+ log_debug("Error capturing response headers: #{e.message}")
75
+ end
76
+
77
+ def capture_request_body(span, body)
78
+ return unless should_capture_request_body?
79
+ return if body.nil? || body.empty?
80
+
81
+ max_size = Otel::ConfigStore.max_request_body_size
82
+ truncated = Core::BodyCapture.truncate_body(body.to_s, max_size)
83
+ span.set_attribute(Core::Constants::ATTR_HTTP_REQUEST_BODY, truncated) if truncated
84
+ rescue StandardError => e
85
+ log_debug("Error capturing request body: #{e.message}")
86
+ end
87
+
88
+ def capture_response_body(span, response)
89
+ return unless should_capture_response_body?
90
+
91
+ body = read_response_body(response)
92
+ return if body.nil? || body.empty?
93
+
94
+ max_size = Otel::ConfigStore.max_response_body_size
95
+ content_encoding = response['Content-Encoding']
96
+
97
+ if Core::BodyCapture.compressed?(content_encoding)
98
+ encoded = Core::BodyCapture.encode_compressed_body(body, max_size)
99
+ if encoded
100
+ span.set_attribute(Core::Constants::ATTR_HTTP_RESPONSE_BODY, encoded)
101
+ span.set_attribute(Core::Constants::ATTR_HTTP_RESPONSE_CONTENT_ENCODING, content_encoding)
102
+ end
103
+ else
104
+ truncated = Core::BodyCapture.truncate_body(body, max_size)
105
+ span.set_attribute(Core::Constants::ATTR_HTTP_RESPONSE_BODY, truncated) if truncated
106
+ end
107
+ rescue StandardError => e
108
+ log_debug("Error capturing response body: #{e.message}")
109
+ end
110
+
111
+ def read_response_body(response)
112
+ # Try to get the body without consuming it
113
+ return unless response.respond_to?(:body)
114
+
115
+ response.body
116
+ end
117
+
118
+ def should_capture_request_body?
119
+ # Check context first
120
+ context = OpenTelemetry::Context.current
121
+ context_value = Core::ContextKeys.capture_request_body?(context)
122
+ return context_value unless context_value.nil?
123
+
124
+ # Fall back to global config
125
+ Otel::ConfigStore.capture_request_body?
126
+ end
127
+
128
+ def should_capture_response_body?
129
+ # Check context first
130
+ context = OpenTelemetry::Context.current
131
+ context_value = Core::ContextKeys.capture_response_body?(context)
132
+ return context_value unless context_value.nil?
133
+
134
+ # Fall back to global config
135
+ Otel::ConfigStore.capture_response_body?
136
+ end
137
+
138
+ def log_debug(message)
139
+ return unless Otel::ConfigStore.debug?
140
+
141
+ puts "[Pingops DEBUG] NetHttp: #{message}"
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ module Otel
5
+ # Global configuration store for instrumentations
6
+ # Instrumentations read these settings at span creation/body capture time
7
+ module ConfigStore
8
+ @config = nil
9
+ @mutex = Mutex.new
10
+
11
+ class << self
12
+ # Set the global configuration
13
+ # @param config [Pingops::Core::Configuration] The configuration
14
+ def set(config)
15
+ @mutex.synchronize do
16
+ @config = config
17
+ end
18
+ end
19
+
20
+ # Get the global configuration
21
+ # @return [Pingops::Core::Configuration, nil]
22
+ def get
23
+ @mutex.synchronize do
24
+ @config
25
+ end
26
+ end
27
+
28
+ # Check if configuration is set
29
+ # @return [Boolean]
30
+ def set?
31
+ @mutex.synchronize do
32
+ !@config.nil?
33
+ end
34
+ end
35
+
36
+ # Clear the configuration (for testing/shutdown)
37
+ def clear!
38
+ @mutex.synchronize do
39
+ @config = nil
40
+ end
41
+ end
42
+
43
+ # Get capture request body setting
44
+ # @return [Boolean]
45
+ def capture_request_body?
46
+ @mutex.synchronize do
47
+ @config&.capture_request_body || false
48
+ end
49
+ end
50
+
51
+ # Get capture response body setting
52
+ # @return [Boolean]
53
+ def capture_response_body?
54
+ @mutex.synchronize do
55
+ @config&.capture_response_body || false
56
+ end
57
+ end
58
+
59
+ # Get max request body size
60
+ # @return [Integer]
61
+ def max_request_body_size
62
+ @mutex.synchronize do
63
+ @config&.max_request_body_size || Core::Constants::DEFAULT_MAX_REQUEST_BODY_SIZE
64
+ end
65
+ end
66
+
67
+ # Get max response body size
68
+ # @return [Integer]
69
+ def max_response_body_size
70
+ @mutex.synchronize do
71
+ @config&.max_response_body_size || Core::Constants::DEFAULT_MAX_RESPONSE_BODY_SIZE
72
+ end
73
+ end
74
+
75
+ # Get domain allow list
76
+ # @return [Array<Core::DomainRule>, nil]
77
+ def domain_allow_list
78
+ @mutex.synchronize do
79
+ @config&.domain_allow_list
80
+ end
81
+ end
82
+
83
+ # Get domain deny list
84
+ # @return [Array<Core::DomainRule>, nil]
85
+ def domain_deny_list
86
+ @mutex.synchronize do
87
+ @config&.domain_deny_list
88
+ end
89
+ end
90
+
91
+ # Check if debug mode is enabled
92
+ # @return [Boolean]
93
+ def debug?
94
+ @mutex.synchronize do
95
+ @config&.debug || false
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end