logbrew-sdk 0.1.0 → 0.1.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 +4 -4
- data/README.md +219 -23
- data/examples/Makefile +9 -1
- data/examples/first_useful_telemetry.rb +118 -0
- data/examples/http_trace_correlation.rb +99 -0
- data/examples/real_user_smoke.rb +15 -0
- data/lib/logbrew/operation_tracing.rb +179 -0
- data/lib/logbrew/product_timeline.rb +153 -0
- data/lib/logbrew/support_ticket.rb +187 -0
- data/lib/logbrew/trace.rb +298 -0
- data/lib/logbrew/traceparent.rb +145 -0
- data/lib/logbrew.rb +71 -8
- metadata +9 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
# Explicit dependency spans for app-owned database, cache, and queue work.
|
|
5
|
+
module OperationTracing
|
|
6
|
+
UNSAFE_METADATA_PATTERNS = [
|
|
7
|
+
/authorization/i,
|
|
8
|
+
/body/i,
|
|
9
|
+
/broker/i,
|
|
10
|
+
/cache.?key/i,
|
|
11
|
+
/command/i,
|
|
12
|
+
/connection/i,
|
|
13
|
+
/cookie/i,
|
|
14
|
+
/dsn/i,
|
|
15
|
+
/header/i,
|
|
16
|
+
/host/i,
|
|
17
|
+
/\bjid\z/i,
|
|
18
|
+
/job.?id/i,
|
|
19
|
+
/key/i,
|
|
20
|
+
/message/i,
|
|
21
|
+
/param/i,
|
|
22
|
+
/pass#{'word'}/i,
|
|
23
|
+
/payload/i,
|
|
24
|
+
/query/i,
|
|
25
|
+
/sec#{'ret'}/i,
|
|
26
|
+
/sql/i,
|
|
27
|
+
/statement/i,
|
|
28
|
+
/to#{'ken'}/i,
|
|
29
|
+
/url/i,
|
|
30
|
+
/username/i,
|
|
31
|
+
/value/i
|
|
32
|
+
].freeze
|
|
33
|
+
private_constant :UNSAFE_METADATA_PATTERNS
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def database_operation(client, name, **options, &block)
|
|
38
|
+
capture_operation(client, "database", name, options, &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cache_operation(client, name, **options, &block)
|
|
42
|
+
capture_operation(client, "cache", name, options, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def queue_operation(client, name, **options, &block)
|
|
46
|
+
capture_operation(client, "queue", name, options, &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def capture_operation(client, kind, name, options)
|
|
50
|
+
raise SdkError.new("validation_error", "#{kind} operation block is required") unless block_given?
|
|
51
|
+
|
|
52
|
+
Validation.require_non_empty("#{kind} operation name", name)
|
|
53
|
+
started_at = monotonic_time
|
|
54
|
+
context = child_context
|
|
55
|
+
error = nil
|
|
56
|
+
result = nil
|
|
57
|
+
|
|
58
|
+
Trace.with_context(context) do
|
|
59
|
+
begin
|
|
60
|
+
result = yield context
|
|
61
|
+
rescue StandardError => captured
|
|
62
|
+
error = captured
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
capture_span(client, kind, name, context, started_at, options, error)
|
|
67
|
+
raise error if error
|
|
68
|
+
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def capture_span(client, kind, name, context, started_at, options, error)
|
|
73
|
+
client.span(
|
|
74
|
+
event_id(kind, context, options),
|
|
75
|
+
timestamp(options),
|
|
76
|
+
{
|
|
77
|
+
name: "#{kind}.operation:#{name}",
|
|
78
|
+
traceId: context.trace_id,
|
|
79
|
+
spanId: context.span_id,
|
|
80
|
+
parentSpanId: context.parent_span_id,
|
|
81
|
+
status: error ? "error" : "ok",
|
|
82
|
+
durationMs: duration_ms(started_at, options),
|
|
83
|
+
metadata: span_metadata(kind, options, error)
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
rescue StandardError => capture_error
|
|
87
|
+
on_error = read_option(options, :on_error)
|
|
88
|
+
begin
|
|
89
|
+
on_error.call(capture_error) if on_error.respond_to?(:call)
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def child_context
|
|
96
|
+
parent = Trace.current
|
|
97
|
+
return Trace.create_root unless parent
|
|
98
|
+
|
|
99
|
+
Trace.create(
|
|
100
|
+
trace_id: parent.trace_id,
|
|
101
|
+
span_id: generate_span_id,
|
|
102
|
+
parent_span_id: parent.span_id,
|
|
103
|
+
trace_flags: parent.trace_flags
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def span_metadata(kind, options, error)
|
|
108
|
+
sanitized_metadata(read_option(options, :metadata)).tap do |metadata|
|
|
109
|
+
metadata["source"] = "#{kind}.operation"
|
|
110
|
+
add_option(metadata, "#{kind}.system", read_option(options, :system))
|
|
111
|
+
add_option(metadata, "#{kind}.operation", read_option(options, :operation))
|
|
112
|
+
add_option(metadata, "#{kind}.target", read_option(options, :target))
|
|
113
|
+
metadata["exceptionType"] = error.class.name if error
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def sanitized_metadata(metadata)
|
|
118
|
+
return {} if metadata.nil?
|
|
119
|
+
raise SdkError.new("validation_error", "operation metadata must be an object") unless metadata.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
metadata.each_with_object({}) do |(key, value), copied|
|
|
122
|
+
normalized_key = key.to_s
|
|
123
|
+
next if normalized_key.strip.empty?
|
|
124
|
+
next if unsafe_metadata_key?(normalized_key)
|
|
125
|
+
next unless primitive_metadata_value?(value)
|
|
126
|
+
|
|
127
|
+
copied[normalized_key] = value
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def unsafe_metadata_key?(key)
|
|
132
|
+
UNSAFE_METADATA_PATTERNS.any? { |pattern| key.match?(pattern) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def primitive_metadata_value?(value)
|
|
136
|
+
return true if value.nil? || value == true || value == false
|
|
137
|
+
return true if value.is_a?(String) || value.is_a?(Integer)
|
|
138
|
+
|
|
139
|
+
value.is_a?(Float) && value.finite?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def add_option(metadata, key, value)
|
|
143
|
+
metadata[key] = value if primitive_metadata_value?(value) && !(value.is_a?(String) && value.strip.empty?)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def read_option(options, key)
|
|
147
|
+
options[key] || options[key.to_s]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def event_id(kind, context, options)
|
|
151
|
+
read_option(options, :event_id) || "ruby_#{kind}_span_#{context.span_id}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def timestamp(options)
|
|
155
|
+
value = read_option(options, :timestamp)
|
|
156
|
+
return value unless value.nil?
|
|
157
|
+
|
|
158
|
+
Time.now.utc.iso8601
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def duration_ms(started_at, options)
|
|
162
|
+
configured = read_option(options, :duration_ms)
|
|
163
|
+
return configured unless configured.nil?
|
|
164
|
+
|
|
165
|
+
((monotonic_time - started_at) * 1000.0).round(3)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def monotonic_time
|
|
169
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def generate_span_id
|
|
173
|
+
loop do
|
|
174
|
+
value = SecureRandom.hex(8)
|
|
175
|
+
return value unless value.delete("0").empty?
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
# Builders for app-owned product and network timeline action events.
|
|
5
|
+
class ProductTimeline
|
|
6
|
+
private_class_method :new
|
|
7
|
+
|
|
8
|
+
def self.product_action(
|
|
9
|
+
name:,
|
|
10
|
+
status: "success",
|
|
11
|
+
route_template: nil,
|
|
12
|
+
session_id: nil,
|
|
13
|
+
trace_id: nil,
|
|
14
|
+
screen: nil,
|
|
15
|
+
funnel: nil,
|
|
16
|
+
step: nil,
|
|
17
|
+
metadata: nil
|
|
18
|
+
)
|
|
19
|
+
action_metadata = timeline_metadata("product_timeline", metadata)
|
|
20
|
+
put_if_present(action_metadata, "routeTemplate", sanitize_optional_route_template("product route_template", route_template))
|
|
21
|
+
put_if_present(action_metadata, "sessionId", optional_label("session_id", session_id))
|
|
22
|
+
put_if_present(action_metadata, "traceId", optional_label("trace_id", trace_id))
|
|
23
|
+
put_if_present(action_metadata, "screen", optional_label("screen", screen))
|
|
24
|
+
put_if_present(action_metadata, "funnel", optional_label("funnel", funnel))
|
|
25
|
+
put_if_present(action_metadata, "step", optional_label("step", step))
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
"name" => required_label("product action name", name),
|
|
29
|
+
"status" => normalize_status(status),
|
|
30
|
+
"metadata" => action_metadata
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.network_milestone(
|
|
35
|
+
route_template:,
|
|
36
|
+
method: "GET",
|
|
37
|
+
status_code: nil,
|
|
38
|
+
duration_ms: nil,
|
|
39
|
+
status: nil,
|
|
40
|
+
name: nil,
|
|
41
|
+
session_id: nil,
|
|
42
|
+
trace_id: nil,
|
|
43
|
+
metadata: nil
|
|
44
|
+
)
|
|
45
|
+
route = sanitize_route_template("network milestone route_template", route_template)
|
|
46
|
+
normalized_method = normalize_method(method)
|
|
47
|
+
validate_status_code(status_code)
|
|
48
|
+
validate_duration_ms(duration_ms)
|
|
49
|
+
normalized_status = normalize_status(status || (status_code && status_code >= 400 ? "failure" : "success"))
|
|
50
|
+
|
|
51
|
+
action_metadata = timeline_metadata("network_timeline", metadata)
|
|
52
|
+
action_metadata["routeTemplate"] = route
|
|
53
|
+
action_metadata["method"] = normalized_method
|
|
54
|
+
put_if_present(action_metadata, "statusCode", status_code)
|
|
55
|
+
put_if_present(action_metadata, "durationMs", duration_ms)
|
|
56
|
+
put_if_present(action_metadata, "sessionId", optional_label("session_id", session_id))
|
|
57
|
+
put_if_present(action_metadata, "traceId", optional_label("trace_id", trace_id))
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
"name" => name.nil? ? "network.#{normalized_method.downcase} #{route}" : required_label("network milestone name", name),
|
|
61
|
+
"status" => normalized_status,
|
|
62
|
+
"metadata" => action_metadata
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.timeline_metadata(source, metadata)
|
|
67
|
+
copied = { "source" => source }
|
|
68
|
+
validated = Validation.require_metadata(metadata)
|
|
69
|
+
return copied if validated.nil?
|
|
70
|
+
|
|
71
|
+
validated.each do |key, value|
|
|
72
|
+
copied[key] = value unless key == "source"
|
|
73
|
+
end
|
|
74
|
+
copied
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.required_label(label, value)
|
|
78
|
+
Validation.require_non_empty(label, value)
|
|
79
|
+
value.to_s.strip
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.optional_label(label, value)
|
|
83
|
+
return nil if value.nil?
|
|
84
|
+
|
|
85
|
+
required_label(label, value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.normalize_status(status)
|
|
89
|
+
Validation.require_allowed_value("action status", status, LogBrew::ACTION_STATUSES)
|
|
90
|
+
status
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.sanitize_optional_route_template(label, route_template)
|
|
94
|
+
return nil if route_template.nil?
|
|
95
|
+
|
|
96
|
+
sanitize_route_template(label, route_template)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.sanitize_route_template(label, route_template)
|
|
100
|
+
Validation.require_non_empty(label, route_template)
|
|
101
|
+
trimmed = route_template.to_s.strip
|
|
102
|
+
if trimmed.match?(/\Ahttps?:\/\//i)
|
|
103
|
+
uri = URI.parse(trimmed)
|
|
104
|
+
return uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
cutoff = first_present_index(trimmed.index("?"), trimmed.index("#"))
|
|
108
|
+
cutoff.nil? ? trimmed : (trimmed[0...cutoff].rstrip.empty? ? "/" : trimmed[0...cutoff].rstrip)
|
|
109
|
+
rescue URI::InvalidURIError => error
|
|
110
|
+
raise SdkError.new("validation_error", "route_template must be a valid route or URL: #{error.message}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.normalize_method(method)
|
|
114
|
+
normalized = method.to_s.strip.upcase
|
|
115
|
+
unless normalized.match?(/\A[A-Z0-9_-]+\z/)
|
|
116
|
+
raise SdkError.new("validation_error", "network milestone method must be a valid HTTP method")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
normalized
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.validate_status_code(status_code)
|
|
123
|
+
return if status_code.nil?
|
|
124
|
+
|
|
125
|
+
unless status_code.is_a?(Integer) && status_code >= 100 && status_code <= 599
|
|
126
|
+
raise SdkError.new("validation_error", "network milestone status_code must be between 100 and 599")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.validate_duration_ms(duration_ms)
|
|
131
|
+
return if duration_ms.nil?
|
|
132
|
+
unless duration_ms.is_a?(Numeric) && duration_ms.finite?
|
|
133
|
+
raise SdkError.new("validation_error", "network milestone duration_ms must be a finite number")
|
|
134
|
+
end
|
|
135
|
+
raise SdkError.new("validation_error", "network milestone duration_ms must be non-negative") if duration_ms.negative?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.put_if_present(metadata, key, value)
|
|
139
|
+
metadata[key] = value unless value.nil?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.first_present_index(first, second)
|
|
143
|
+
return second if first.nil?
|
|
144
|
+
return first if second.nil?
|
|
145
|
+
|
|
146
|
+
[first, second].min
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private_class_method :timeline_metadata, :required_label, :optional_label, :normalize_status,
|
|
150
|
+
:sanitize_optional_route_template, :sanitize_route_template, :normalize_method,
|
|
151
|
+
:validate_status_code, :validate_duration_ms, :put_if_present, :first_present_index
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
# Local-only support-ticket payload drafts for explicit user or agent handoff.
|
|
5
|
+
module SupportTicketDraft
|
|
6
|
+
SOURCES = %w[cli sdk website docs mobile].freeze
|
|
7
|
+
CATEGORIES = %w[
|
|
8
|
+
sdk_install_failure
|
|
9
|
+
ingest_failure
|
|
10
|
+
auth_failure
|
|
11
|
+
project_setup
|
|
12
|
+
dashboard_issue
|
|
13
|
+
docs_confusion
|
|
14
|
+
cli_issue
|
|
15
|
+
mobile_issue
|
|
16
|
+
billing_question
|
|
17
|
+
other
|
|
18
|
+
].freeze
|
|
19
|
+
SENSITIVE_KEY_MARKERS = %w[
|
|
20
|
+
apikey
|
|
21
|
+
auth
|
|
22
|
+
authorization
|
|
23
|
+
authtoken
|
|
24
|
+
bearer
|
|
25
|
+
clientsecret
|
|
26
|
+
connectionstring
|
|
27
|
+
cookie
|
|
28
|
+
credential
|
|
29
|
+
dsn
|
|
30
|
+
email
|
|
31
|
+
errormessage
|
|
32
|
+
exceptionmessage
|
|
33
|
+
password
|
|
34
|
+
passwd
|
|
35
|
+
privatekey
|
|
36
|
+
refreshtoken
|
|
37
|
+
secret
|
|
38
|
+
session
|
|
39
|
+
setcookie
|
|
40
|
+
stacktrace
|
|
41
|
+
token
|
|
42
|
+
traceback
|
|
43
|
+
].freeze
|
|
44
|
+
REDACTED = "[redacted]"
|
|
45
|
+
MAX_DEPTH = 5
|
|
46
|
+
MAX_ARRAY_LENGTH = 20
|
|
47
|
+
MAX_STRING_LENGTH = 500
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def create(source:, category:, title:, description:, **options)
|
|
52
|
+
Validation.require_allowed_value("support ticket source", source, SOURCES)
|
|
53
|
+
Validation.require_allowed_value("support ticket category", category, CATEGORIES)
|
|
54
|
+
|
|
55
|
+
draft = {
|
|
56
|
+
"source" => source,
|
|
57
|
+
"category" => category,
|
|
58
|
+
"title" => required_string("support ticket title", title),
|
|
59
|
+
"description" => required_string("support ticket description", description)
|
|
60
|
+
}
|
|
61
|
+
add_optional_string(draft, "project_id", options[:project_id])
|
|
62
|
+
add_optional_string(draft, "environment", options[:environment])
|
|
63
|
+
add_optional_string(draft, "runtime", options[:runtime])
|
|
64
|
+
add_optional_string(draft, "framework", options[:framework])
|
|
65
|
+
add_optional_string(draft, "sdk_package", options[:sdk_package])
|
|
66
|
+
add_optional_string(draft, "sdk_version", options[:sdk_version])
|
|
67
|
+
add_optional_string(draft, "release", options[:release])
|
|
68
|
+
add_trace_id(draft, options[:trace_id])
|
|
69
|
+
add_optional_string(draft, "event_id", options[:event_id])
|
|
70
|
+
add_diagnostics(draft, options[:diagnostics]) if options.key?(:diagnostics)
|
|
71
|
+
draft
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def required_string(label, value)
|
|
75
|
+
Validation.require_non_empty(label, value)
|
|
76
|
+
value.strip
|
|
77
|
+
end
|
|
78
|
+
private_class_method :required_string
|
|
79
|
+
|
|
80
|
+
def add_optional_string(draft, key, value)
|
|
81
|
+
return if value.nil?
|
|
82
|
+
|
|
83
|
+
draft[key] = required_string("support ticket #{key}", value)
|
|
84
|
+
end
|
|
85
|
+
private_class_method :add_optional_string
|
|
86
|
+
|
|
87
|
+
def add_trace_id(draft, value)
|
|
88
|
+
return if value.nil?
|
|
89
|
+
|
|
90
|
+
normalized = value.to_s.strip.downcase
|
|
91
|
+
unless normalized.match?(/\A[0-9a-f]{32}\z/) && !normalized.delete("0").empty?
|
|
92
|
+
raise SdkError.new("validation_error", "support ticket trace_id must be 32 non-zero hex characters")
|
|
93
|
+
end
|
|
94
|
+
draft["trace_id"] = normalized
|
|
95
|
+
end
|
|
96
|
+
private_class_method :add_trace_id
|
|
97
|
+
|
|
98
|
+
def add_diagnostics(draft, diagnostics)
|
|
99
|
+
unless diagnostics.is_a?(Hash)
|
|
100
|
+
raise SdkError.new("validation_error", "support ticket diagnostics must be an object")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sanitized = sanitize_diagnostic_hash(diagnostics, 0)
|
|
104
|
+
draft["diagnostics"] = sanitized unless sanitized.empty?
|
|
105
|
+
end
|
|
106
|
+
private_class_method :add_diagnostics
|
|
107
|
+
|
|
108
|
+
def sanitize_diagnostic_hash(value, depth)
|
|
109
|
+
value.each_with_object({}) do |(key, child), safe|
|
|
110
|
+
break safe if safe.length >= MAX_ARRAY_LENGTH
|
|
111
|
+
|
|
112
|
+
normalized_key = key.to_s
|
|
113
|
+
next if normalized_key.strip.empty?
|
|
114
|
+
|
|
115
|
+
sanitized = if sensitive_key?(normalized_key)
|
|
116
|
+
REDACTED
|
|
117
|
+
else
|
|
118
|
+
sanitize_diagnostic_value(child, depth + 1)
|
|
119
|
+
end
|
|
120
|
+
safe[normalized_key] = sanitized unless sanitized.nil?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
private_class_method :sanitize_diagnostic_hash
|
|
124
|
+
|
|
125
|
+
def sanitize_diagnostic_value(value, depth)
|
|
126
|
+
return { "type" => value.class.name } if value.is_a?(Exception)
|
|
127
|
+
return nil if depth > MAX_DEPTH
|
|
128
|
+
|
|
129
|
+
case value
|
|
130
|
+
when nil, true, false, String, Integer
|
|
131
|
+
sanitize_primitive(value)
|
|
132
|
+
when Float
|
|
133
|
+
value.finite? ? value : nil
|
|
134
|
+
when Hash
|
|
135
|
+
sanitize_diagnostic_hash(value, depth)
|
|
136
|
+
when Array
|
|
137
|
+
value.first(MAX_ARRAY_LENGTH).map { |item| sanitize_diagnostic_value(item, depth + 1) }.compact
|
|
138
|
+
else
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_class_method :sanitize_diagnostic_value
|
|
143
|
+
|
|
144
|
+
def sanitize_primitive(value)
|
|
145
|
+
return value unless value.is_a?(String)
|
|
146
|
+
|
|
147
|
+
text = value.strip
|
|
148
|
+
return "" if text.empty?
|
|
149
|
+
return REDACTED if sensitive_string?(text)
|
|
150
|
+
|
|
151
|
+
redacted = redact_url(redact_local_path(text))
|
|
152
|
+
redacted.length > MAX_STRING_LENGTH ? "#{redacted[0, MAX_STRING_LENGTH]}..." : redacted
|
|
153
|
+
end
|
|
154
|
+
private_class_method :sanitize_primitive
|
|
155
|
+
|
|
156
|
+
def sensitive_key?(key)
|
|
157
|
+
normalized = key.downcase.gsub(/[^a-z0-9]/, "")
|
|
158
|
+
SENSITIVE_KEY_MARKERS.any? { |marker| normalized.include?(marker) }
|
|
159
|
+
end
|
|
160
|
+
private_class_method :sensitive_key?
|
|
161
|
+
|
|
162
|
+
def sensitive_string?(value)
|
|
163
|
+
value.match?(/(?:authorization|api[_-]?key|token|secret|password|passwd|cookie)\s*[:=]/i) ||
|
|
164
|
+
value.match?(/\bBearer\s+[A-Za-z0-9._~+\/=-]+/i) ||
|
|
165
|
+
value.match?(/\blbw_(?:ingest|client|api)_[A-Za-z0-9._-]+/i) ||
|
|
166
|
+
value.match?(/\b(?:github_pat|ghp|gho|npm|pypi|sk_live|sk_test|xox[baprs]|AKIA)[A-Za-z0-9._-]+/)
|
|
167
|
+
end
|
|
168
|
+
private_class_method :sensitive_string?
|
|
169
|
+
|
|
170
|
+
def redact_local_path(value)
|
|
171
|
+
return "[redacted-path]" if value.match?(/\A(?:\/Users\/|\/home\/|\/var\/folders\/|[A-Za-z]:\\)/)
|
|
172
|
+
|
|
173
|
+
value
|
|
174
|
+
end
|
|
175
|
+
private_class_method :redact_local_path
|
|
176
|
+
|
|
177
|
+
def redact_url(value)
|
|
178
|
+
uri = URI.parse(value)
|
|
179
|
+
return "[redacted-url]#{uri.path.empty? ? '/' : uri.path}" if uri.is_a?(URI::HTTP) && uri.host
|
|
180
|
+
|
|
181
|
+
value.split(/[?#]/, 2)[0]
|
|
182
|
+
rescue URI::InvalidURIError
|
|
183
|
+
value.split(/[?#]/, 2)[0]
|
|
184
|
+
end
|
|
185
|
+
private_class_method :redact_url
|
|
186
|
+
end
|
|
187
|
+
end
|