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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ module Core
5
+ # Constants used throughout the SDK
6
+ module Constants
7
+ # Tracer identification
8
+ TRACER_NAME = 'pingops-sdk'
9
+ TRACER_VERSION = '0.0.1'
10
+
11
+ # Root span name for startTrace
12
+ ROOT_SPAN_NAME = 'pingops-trace'
13
+
14
+ # Default sizes and timeouts
15
+ DEFAULT_MAX_REQUEST_BODY_SIZE = 4096
16
+ DEFAULT_MAX_RESPONSE_BODY_SIZE = 4096
17
+ DEFAULT_BATCH_SIZE = 50
18
+ DEFAULT_BATCH_TIMEOUT = 5000
19
+ DEFAULT_EXPORT_TIMEOUT = 5000
20
+
21
+ # Export modes
22
+ EXPORT_MODE_BATCHED = 'batched'
23
+ EXPORT_MODE_IMMEDIATE = 'immediate'
24
+
25
+ # Context key names
26
+ CONTEXT_KEY_TRACE_ID = 'pingops-trace-id'
27
+ CONTEXT_KEY_USER_ID = 'pingops-user-id'
28
+ CONTEXT_KEY_SESSION_ID = 'pingops-session-id'
29
+ CONTEXT_KEY_TAGS = 'pingops-tags'
30
+ CONTEXT_KEY_METADATA = 'pingops-metadata'
31
+ CONTEXT_KEY_CAPTURE_REQUEST_BODY = 'pingops-capture-request-body'
32
+ CONTEXT_KEY_CAPTURE_RESPONSE_BODY = 'pingops-capture-response-body'
33
+
34
+ # Span attribute names
35
+ ATTR_PINGOPS_TRACE_ID = 'pingops.trace_id'
36
+ ATTR_PINGOPS_USER_ID = 'pingops.user_id'
37
+ ATTR_PINGOPS_SESSION_ID = 'pingops.session_id'
38
+ ATTR_PINGOPS_TAGS = 'pingops.tags'
39
+ ATTR_PINGOPS_METADATA_PREFIX = 'pingops.metadata.'
40
+
41
+ ATTR_HTTP_REQUEST_BODY = 'http.request.body'
42
+ ATTR_HTTP_RESPONSE_BODY = 'http.response.body'
43
+ ATTR_HTTP_RESPONSE_CONTENT_ENCODING = 'http.response.content_encoding'
44
+ ATTR_HTTP_REQUEST_HEADER_PREFIX = 'http.request.header.'
45
+ ATTR_HTTP_RESPONSE_HEADER_PREFIX = 'http.response.header.'
46
+
47
+ # HTTP attributes for eligibility checking
48
+ ATTR_HTTP_METHOD = 'http.method'
49
+ ATTR_HTTP_REQUEST_METHOD = 'http.request.method'
50
+ ATTR_HTTP_URL = 'http.url'
51
+ ATTR_URL_FULL = 'url.full'
52
+ ATTR_SERVER_ADDRESS = 'server.address'
53
+ ATTR_SERVER_PORT = 'server.port'
54
+ ATTR_HTTP_STATUS_CODE = 'http.response.status_code'
55
+
56
+ # Compressed body encodings
57
+ COMPRESSED_ENCODINGS = %w[gzip br deflate x-gzip x-deflate].freeze
58
+
59
+ # Default sensitive header patterns (case-insensitive, substring match)
60
+ DEFAULT_SENSITIVE_HEADER_PATTERNS = %w[
61
+ authorization
62
+ www-authenticate
63
+ proxy-authenticate
64
+ proxy-authorization
65
+ x-auth-token
66
+ x-api-key
67
+ x-api-token
68
+ x-access-token
69
+ x-auth-user
70
+ x-auth-password
71
+ x-csrf-token
72
+ x-xsrf-token
73
+ api-key
74
+ apikey
75
+ api_key
76
+ access-key
77
+ accesskey
78
+ access_key
79
+ secret-key
80
+ secretkey
81
+ secret_key
82
+ private-key
83
+ privatekey
84
+ private_key
85
+ cookie
86
+ set-cookie
87
+ session-id
88
+ sessionid
89
+ session_id
90
+ session-token
91
+ sessiontoken
92
+ session_token
93
+ oauth-token
94
+ oauth_token
95
+ oauth2-token
96
+ oauth2_token
97
+ bearer
98
+ x-amz-security-token
99
+ x-amz-signature
100
+ x-aws-access-key
101
+ x-aws-secret-key
102
+ x-aws-session-token
103
+ x-password
104
+ x-secret
105
+ x-token
106
+ x-jwt
107
+ x-jwt-token
108
+ x-refresh-token
109
+ x-client-secret
110
+ x-client-id
111
+ x-user-token
112
+ x-service-key
113
+ ].freeze
114
+
115
+ # Redaction strategies
116
+ REDACTION_STRATEGY_REPLACE = 'replace'
117
+ REDACTION_STRATEGY_PARTIAL = 'partial'
118
+ REDACTION_STRATEGY_PARTIAL_END = 'partial_end'
119
+ REDACTION_STRATEGY_REMOVE = 'remove'
120
+
121
+ # Default redaction settings
122
+ DEFAULT_REDACTION_STRING = '[REDACTED]'
123
+ DEFAULT_VISIBLE_CHARS = 4
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-api'
4
+
5
+ module Pingops
6
+ module Core
7
+ # Context keys for propagating PingOps attributes through OpenTelemetry context
8
+ module ContextKeys
9
+ # Create OpenTelemetry context keys for PingOps attributes
10
+ TRACE_ID_KEY = OpenTelemetry::Context.create_key(Constants::CONTEXT_KEY_TRACE_ID)
11
+ USER_ID_KEY = OpenTelemetry::Context.create_key(Constants::CONTEXT_KEY_USER_ID)
12
+ SESSION_ID_KEY = OpenTelemetry::Context.create_key(Constants::CONTEXT_KEY_SESSION_ID)
13
+ TAGS_KEY = OpenTelemetry::Context.create_key(Constants::CONTEXT_KEY_TAGS)
14
+ METADATA_KEY = OpenTelemetry::Context.create_key(Constants::CONTEXT_KEY_METADATA)
15
+ CAPTURE_REQUEST_BODY_KEY = OpenTelemetry::Context.create_key(Constants::CONTEXT_KEY_CAPTURE_REQUEST_BODY)
16
+ CAPTURE_RESPONSE_BODY_KEY = OpenTelemetry::Context.create_key(Constants::CONTEXT_KEY_CAPTURE_RESPONSE_BODY)
17
+
18
+ class << self
19
+ # Set trace attributes on a context
20
+ # @param context [OpenTelemetry::Context] The context to modify
21
+ # @param attributes [TraceAttributes, nil] The attributes to set
22
+ # @return [OpenTelemetry::Context] The modified context
23
+ def set_attributes(context, attributes)
24
+ return context if attributes.nil?
25
+
26
+ ctx = context
27
+ ctx = ctx.set_value(TRACE_ID_KEY, attributes.trace_id) if attributes.trace_id
28
+ ctx = ctx.set_value(USER_ID_KEY, attributes.user_id) if attributes.user_id
29
+ ctx = ctx.set_value(SESSION_ID_KEY, attributes.session_id) if attributes.session_id
30
+ ctx = ctx.set_value(TAGS_KEY, attributes.tags) if attributes.tags
31
+ ctx = ctx.set_value(METADATA_KEY, attributes.metadata) if attributes.metadata
32
+
33
+ unless attributes.capture_request_body.nil?
34
+ ctx = ctx.set_value(CAPTURE_REQUEST_BODY_KEY, attributes.capture_request_body)
35
+ end
36
+
37
+ unless attributes.capture_response_body.nil?
38
+ ctx = ctx.set_value(CAPTURE_RESPONSE_BODY_KEY, attributes.capture_response_body)
39
+ end
40
+
41
+ ctx
42
+ end
43
+
44
+ # Set the trace ID on a context
45
+ # @param context [OpenTelemetry::Context] The context to modify
46
+ # @param trace_id [String] The trace ID
47
+ # @return [OpenTelemetry::Context] The modified context
48
+ def set_trace_id(context, trace_id)
49
+ context.set_value(TRACE_ID_KEY, trace_id)
50
+ end
51
+
52
+ # Extract all PingOps attributes from a context
53
+ # @param context [OpenTelemetry::Context] The context to read from
54
+ # @return [Hash] Hash of attribute name to value
55
+ def extract_attributes(context)
56
+ attributes = {}
57
+
58
+ trace_id = context.value(TRACE_ID_KEY)
59
+ attributes[Constants::ATTR_PINGOPS_TRACE_ID] = trace_id if trace_id
60
+
61
+ user_id = context.value(USER_ID_KEY)
62
+ attributes[Constants::ATTR_PINGOPS_USER_ID] = user_id if user_id
63
+
64
+ session_id = context.value(SESSION_ID_KEY)
65
+ attributes[Constants::ATTR_PINGOPS_SESSION_ID] = session_id if session_id
66
+
67
+ tags = context.value(TAGS_KEY)
68
+ attributes[Constants::ATTR_PINGOPS_TAGS] = tags if tags&.any?
69
+
70
+ metadata = context.value(METADATA_KEY)
71
+ if metadata.is_a?(Hash)
72
+ metadata.each do |key, value|
73
+ attributes["#{Constants::ATTR_PINGOPS_METADATA_PREFIX}#{key}"] = value.to_s if value
74
+ end
75
+ end
76
+
77
+ attributes
78
+ end
79
+
80
+ # Get capture request body setting from context
81
+ # @param context [OpenTelemetry::Context] The context to read from
82
+ # @return [Boolean, nil] The setting or nil if not set
83
+ def capture_request_body?(context)
84
+ context.value(CAPTURE_REQUEST_BODY_KEY)
85
+ end
86
+
87
+ # Get capture response body setting from context
88
+ # @param context [OpenTelemetry::Context] The context to read from
89
+ # @return [Boolean, nil] The setting or nil if not set
90
+ def capture_response_body?(context)
91
+ context.value(CAPTURE_RESPONSE_BODY_KEY)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Pingops
6
+ module Core
7
+ # Domain filtering for span eligibility
8
+ module DomainFilter
9
+ # Result of domain filter check
10
+ class Result
11
+ attr_reader :allowed, :matching_rule
12
+
13
+ def initialize(allowed:, matching_rule: nil)
14
+ @allowed = allowed
15
+ @matching_rule = matching_rule
16
+ end
17
+
18
+ def allowed?
19
+ @allowed
20
+ end
21
+
22
+ def denied?
23
+ !@allowed
24
+ end
25
+ end
26
+
27
+ class << self
28
+ # Check if a URL passes domain filtering
29
+ # @param url [String] The URL to check
30
+ # @param allow_list [Array<DomainRule>, nil] Domain allow list
31
+ # @param deny_list [Array<DomainRule>, nil] Domain deny list
32
+ # @return [Result] Filter result with allowed status and matching rule
33
+ def check(url, allow_list: nil, deny_list: nil)
34
+ return Result.new(allowed: true) if url.nil? || url.empty?
35
+
36
+ parsed = parse_url(url)
37
+ return Result.new(allowed: true) unless parsed
38
+
39
+ domain = parsed[:host]
40
+ path = parsed[:path] || '/'
41
+
42
+ # Step 1: Check deny list first
43
+ if deny_list&.any?
44
+ deny_list.each do |rule|
45
+ return Result.new(allowed: false, matching_rule: rule) if rule.matches_domain?(domain)
46
+ end
47
+ end
48
+
49
+ # Step 2: If no allow list, allow everything
50
+ return Result.new(allowed: true) if allow_list.nil? || allow_list.empty?
51
+
52
+ # Step 3: Check allow list
53
+ allow_list.each do |rule|
54
+ if rule.matches_domain?(domain)
55
+ # If rule has paths, check path match
56
+ if rule.paths.nil? || rule.paths.empty?
57
+ return Result.new(allowed: true, matching_rule: rule)
58
+ elsif rule.matches_path?(path)
59
+ return Result.new(allowed: true, matching_rule: rule)
60
+ end
61
+ # Continue to next rule if paths don't match
62
+ end
63
+ end
64
+
65
+ # No allow rule matched
66
+ Result.new(allowed: false)
67
+ end
68
+
69
+ # Extract URL from span attributes
70
+ # @param attributes [Hash] Span attributes
71
+ # @return [String, nil] The URL or nil
72
+ def extract_url(attributes)
73
+ url = attributes[Constants::ATTR_HTTP_URL] ||
74
+ attributes[Constants::ATTR_URL_FULL]
75
+
76
+ if url.nil? || url.empty?
77
+ server_address = attributes[Constants::ATTR_SERVER_ADDRESS]
78
+ if server_address && !server_address.empty?
79
+ port = attributes[Constants::ATTR_SERVER_PORT]
80
+ url = if port && port != 443 && port != 80
81
+ "https://#{server_address}:#{port}"
82
+ else
83
+ "https://#{server_address}"
84
+ end
85
+ end
86
+ end
87
+
88
+ url
89
+ end
90
+
91
+ # Find matching allow rule for a URL
92
+ # @param url [String] The URL to check
93
+ # @param allow_list [Array<DomainRule>, nil] Domain allow list
94
+ # @return [DomainRule, nil] The matching rule or nil
95
+ def find_matching_rule(url, allow_list)
96
+ return nil if url.nil? || url.empty? || allow_list.nil? || allow_list.empty?
97
+
98
+ parsed = parse_url(url)
99
+ return nil unless parsed
100
+
101
+ domain = parsed[:host]
102
+ path = parsed[:path] || '/'
103
+
104
+ allow_list.each do |rule|
105
+ next unless rule.matches_domain?(domain)
106
+ return rule if rule.paths.nil? || rule.paths.empty? || rule.matches_path?(path)
107
+ end
108
+
109
+ nil
110
+ end
111
+
112
+ private
113
+
114
+ def parse_url(url)
115
+ uri = URI.parse(url)
116
+ { host: uri.host&.downcase, path: uri.path }
117
+ rescue URI::InvalidURIError
118
+ nil
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ module Core
5
+ # Header filtering and redaction
6
+ module HeaderFilter
7
+ class << self
8
+ # Filter and redact headers according to configuration
9
+ # @param headers [Hash] Headers to filter (name => value)
10
+ # @param allow_list [Array<String>, nil] Header names to include (case-insensitive)
11
+ # @param deny_list [Array<String>, nil] Header names to exclude (case-insensitive)
12
+ # @param redaction_config [HeaderRedactionConfig] Redaction configuration
13
+ # @return [Hash] Filtered and redacted headers
14
+ def filter(headers, allow_list: nil, deny_list: nil, redaction_config: nil)
15
+ return {} if headers.nil? || headers.empty?
16
+
17
+ redaction = redaction_config || HeaderRedactionConfig.new
18
+ result = {}
19
+
20
+ headers.each do |name, value|
21
+ normalized_name = name.to_s.downcase
22
+
23
+ # Step 1: Apply deny list (headers in deny list are removed)
24
+ next if deny_list&.any? { |h| h.downcase == normalized_name }
25
+
26
+ # Step 2: Apply allow list (only headers in allow list are kept)
27
+ next if allow_list && !allow_list.empty? && allow_list.none? { |h| h.downcase == normalized_name }
28
+
29
+ # Step 3: Apply redaction
30
+ redacted_value = redact_value(normalized_name, value, redaction)
31
+ result[name] = redacted_value unless redacted_value.nil?
32
+ end
33
+
34
+ result
35
+ end
36
+
37
+ # Filter span attributes to remove/redact headers
38
+ # @param attributes [Hash] Span attributes
39
+ # @param allow_list [Array<String>, nil] Header names to include
40
+ # @param deny_list [Array<String>, nil] Header names to exclude
41
+ # @param redaction_config [HeaderRedactionConfig] Redaction configuration
42
+ # @return [Hash] Filtered attributes
43
+ def filter_span_attributes(attributes, allow_list: nil, deny_list: nil, redaction_config: nil)
44
+ return {} if attributes.nil?
45
+
46
+ redaction = redaction_config || HeaderRedactionConfig.new
47
+ result = {}
48
+
49
+ attributes.each do |key, value|
50
+ key_str = key.to_s
51
+
52
+ # Check if this is a header attribute
53
+ if key_str.start_with?(Constants::ATTR_HTTP_REQUEST_HEADER_PREFIX)
54
+ header_name = key_str.sub(Constants::ATTR_HTTP_REQUEST_HEADER_PREFIX, '')
55
+ filtered_value = filter_header_value(header_name, value, allow_list, deny_list, redaction)
56
+ result[key] = filtered_value unless filtered_value.nil?
57
+ elsif key_str.start_with?(Constants::ATTR_HTTP_RESPONSE_HEADER_PREFIX)
58
+ header_name = key_str.sub(Constants::ATTR_HTTP_RESPONSE_HEADER_PREFIX, '')
59
+ filtered_value = filter_header_value(header_name, value, allow_list, deny_list, redaction)
60
+ result[key] = filtered_value unless filtered_value.nil?
61
+ else
62
+ # Not a header attribute, keep as-is
63
+ result[key] = value
64
+ end
65
+ end
66
+
67
+ result
68
+ end
69
+
70
+ private
71
+
72
+ def filter_header_value(header_name, value, allow_list, deny_list, redaction)
73
+ normalized_name = header_name.downcase
74
+
75
+ # Step 1: Deny list check
76
+ return nil if deny_list&.any? { |h| h.downcase == normalized_name }
77
+
78
+ # Step 2: Allow list check
79
+ return nil if allow_list && !allow_list.empty? && allow_list.none? { |h| h.downcase == normalized_name }
80
+
81
+ # Step 3: Redaction
82
+ redact_value(normalized_name, value, redaction)
83
+ end
84
+
85
+ def redact_value(header_name, value, redaction_config)
86
+ return value unless redaction_config.enabled
87
+ return value unless sensitive?(header_name, redaction_config.sensitive_patterns)
88
+
89
+ case redaction_config.strategy
90
+ when Constants::REDACTION_STRATEGY_REMOVE
91
+ nil
92
+ when Constants::REDACTION_STRATEGY_PARTIAL
93
+ partial_redact(value.to_s, redaction_config.visible_chars, redaction_config.redaction_string, :start)
94
+ when Constants::REDACTION_STRATEGY_PARTIAL_END
95
+ partial_redact(value.to_s, redaction_config.visible_chars, redaction_config.redaction_string, :end)
96
+ else # replace (default)
97
+ redaction_config.redaction_string
98
+ end
99
+ end
100
+
101
+ def sensitive?(header_name, patterns)
102
+ normalized = header_name.downcase
103
+ patterns.any? { |pattern| normalized.include?(pattern.downcase) }
104
+ end
105
+
106
+ def partial_redact(value, visible_chars, redaction_string, position)
107
+ return redaction_string if value.length <= visible_chars
108
+
109
+ case position
110
+ when :start
111
+ # Show first N chars + redaction string
112
+ "#{value[0, visible_chars]}#{redaction_string}"
113
+ when :end
114
+ # Redaction string + last N chars
115
+ "#{redaction_string}#{value[-visible_chars, visible_chars]}"
116
+ else
117
+ redaction_string
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+
6
+ module Pingops
7
+ module Core
8
+ # Generates trace IDs and span IDs according to the specification
9
+ module IdGenerator
10
+ class << self
11
+ # Generate a trace ID
12
+ # @param seed [String, nil] Optional seed for deterministic generation
13
+ # @return [String] 32 hex character trace ID (lowercase)
14
+ def generate_trace_id(seed = nil)
15
+ if seed
16
+ # Deterministic: first 32 hex chars of SHA-256(seed)
17
+ Digest::SHA256.hexdigest(seed)[0, 32]
18
+ else
19
+ # Random: 16 bytes as 32 hex chars
20
+ SecureRandom.hex(16)
21
+ end
22
+ end
23
+
24
+ # Generate a span ID
25
+ # @return [String] 16 hex character span ID (lowercase)
26
+ def generate_span_id
27
+ SecureRandom.hex(8)
28
+ end
29
+
30
+ # Validate trace ID format
31
+ # @param trace_id [String] The trace ID to validate
32
+ # @return [Boolean] True if valid format
33
+ def valid_trace_id?(trace_id)
34
+ return false if trace_id.nil?
35
+
36
+ trace_id.match?(/\A[0-9a-f]{32}\z/i)
37
+ end
38
+
39
+ # Validate span ID format
40
+ # @param span_id [String] The span ID to validate
41
+ # @return [Boolean] True if valid format
42
+ def valid_span_id?(span_id)
43
+ return false if span_id.nil?
44
+
45
+ span_id.match?(/\A[0-9a-f]{16}\z/i)
46
+ end
47
+
48
+ # Convert trace ID bytes to hex string
49
+ # @param bytes [String] 16 bytes
50
+ # @return [String] 32 hex character string
51
+ def bytes_to_trace_id(bytes)
52
+ bytes.unpack1('H*')
53
+ end
54
+
55
+ # Convert hex trace ID to bytes
56
+ # @param trace_id [String] 32 hex character string
57
+ # @return [String] 16 bytes
58
+ def trace_id_to_bytes(trace_id)
59
+ [trace_id].pack('H*')
60
+ end
61
+
62
+ # Convert span ID bytes to hex string
63
+ # @param bytes [String] 8 bytes
64
+ # @return [String] 16 hex character string
65
+ def bytes_to_span_id(bytes)
66
+ bytes.unpack1('H*')
67
+ end
68
+
69
+ # Convert hex span ID to bytes
70
+ # @param span_id [String] 16 hex character string
71
+ # @return [String] 8 bytes
72
+ def span_id_to_bytes(span_id)
73
+ [span_id].pack('H*')
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pingops
4
+ module Core
5
+ # Span eligibility checking for PingOps export
6
+ module SpanEligibility
7
+ class << self
8
+ # Check if a span is eligible for export to PingOps
9
+ # A span is eligible only if:
10
+ # - span.kind === SpanKind.CLIENT, and
11
+ # - At least one of http.method, http.request.method, http.url, or server.address is present
12
+ #
13
+ # @param span [OpenTelemetry::SDK::Trace::Span] The span to check
14
+ # @return [Boolean] True if eligible for export
15
+ def eligible?(span)
16
+ # Check kind is CLIENT
17
+ return false unless client_span?(span)
18
+
19
+ # Check for HTTP attributes
20
+ has_http_attributes?(span)
21
+ end
22
+
23
+ # Check if span is a CLIENT span
24
+ # @param span [OpenTelemetry::SDK::Trace::Span] The span to check
25
+ # @return [Boolean]
26
+ def client_span?(span)
27
+ span.kind == :client
28
+ end
29
+
30
+ # Check if span has HTTP attributes
31
+ # @param span [OpenTelemetry::SDK::Trace::Span] The span to check
32
+ # @return [Boolean]
33
+ def has_http_attributes?(span)
34
+ attrs = span_attributes(span)
35
+
36
+ attrs.key?(Constants::ATTR_HTTP_METHOD) ||
37
+ attrs.key?(Constants::ATTR_HTTP_REQUEST_METHOD) ||
38
+ attrs.key?(Constants::ATTR_HTTP_URL) ||
39
+ attrs.key?(Constants::ATTR_URL_FULL) ||
40
+ attrs.key?(Constants::ATTR_SERVER_ADDRESS)
41
+ end
42
+
43
+ private
44
+
45
+ def span_attributes(span)
46
+ # Handle both ReadableSpan and regular span
47
+ if span.respond_to?(:attributes)
48
+ span.attributes || {}
49
+ else
50
+ {}
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end