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,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
|