tracekit 0.1.0
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 +138 -0
- data/LICENSE +21 -0
- data/README.md +509 -0
- data/lib/tracekit/config.rb +61 -0
- data/lib/tracekit/endpoint_resolver.rb +69 -0
- data/lib/tracekit/local_ui/detector.rb +30 -0
- data/lib/tracekit/local_ui_detector.rb +33 -0
- data/lib/tracekit/metrics/counter.rb +33 -0
- data/lib/tracekit/metrics/exporter.rb +100 -0
- data/lib/tracekit/metrics/gauge.rb +42 -0
- data/lib/tracekit/metrics/histogram.rb +21 -0
- data/lib/tracekit/metrics/metric_data_point.rb +18 -0
- data/lib/tracekit/metrics/registry.rb +69 -0
- data/lib/tracekit/middleware.rb +136 -0
- data/lib/tracekit/railtie.rb +36 -0
- data/lib/tracekit/sdk.rb +271 -0
- data/lib/tracekit/security/detector.rb +87 -0
- data/lib/tracekit/security/patterns.rb +22 -0
- data/lib/tracekit/snapshots/client.rb +202 -0
- data/lib/tracekit/snapshots/models.rb +26 -0
- data/lib/tracekit/version.rb +5 -0
- data/lib/tracekit.rb +89 -0
- metadata +195 -0
data/lib/tracekit/sdk.rb
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "opentelemetry/exporter/otlp"
|
|
5
|
+
|
|
6
|
+
# Optional instrumentation - load if available
|
|
7
|
+
begin
|
|
8
|
+
require "opentelemetry/instrumentation/http"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
# HTTP instrumentation not available
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
require "opentelemetry/instrumentation/net_http"
|
|
15
|
+
rescue LoadError
|
|
16
|
+
# Net::HTTP instrumentation not available
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
require "opentelemetry/instrumentation/rails"
|
|
21
|
+
rescue LoadError
|
|
22
|
+
# Rails instrumentation not available
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
require "opentelemetry/instrumentation/active_record"
|
|
27
|
+
rescue LoadError
|
|
28
|
+
# ActiveRecord instrumentation not available
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
require "opentelemetry/instrumentation/pg"
|
|
33
|
+
rescue LoadError
|
|
34
|
+
# PG instrumentation not available
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
require "opentelemetry/instrumentation/mysql2"
|
|
39
|
+
rescue LoadError
|
|
40
|
+
# MySQL2 instrumentation not available
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
require "opentelemetry/instrumentation/redis"
|
|
45
|
+
rescue LoadError
|
|
46
|
+
# Redis instrumentation not available
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
require "opentelemetry/instrumentation/sidekiq"
|
|
51
|
+
rescue LoadError
|
|
52
|
+
# Sidekiq instrumentation not available
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
require "opentelemetry/instrumentation/action_view"
|
|
57
|
+
rescue LoadError
|
|
58
|
+
# ActionView instrumentation not available
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
require "opentelemetry/instrumentation/action_pack"
|
|
63
|
+
rescue LoadError
|
|
64
|
+
# ActionPack instrumentation not available
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module Tracekit
|
|
68
|
+
# Main SDK class for TraceKit Ruby SDK with OpenTelemetry integration
|
|
69
|
+
# Provides distributed tracing, metrics collection, and code monitoring capabilities
|
|
70
|
+
class SDK
|
|
71
|
+
attr_reader :config, :service_name
|
|
72
|
+
|
|
73
|
+
def initialize(config)
|
|
74
|
+
@config = config
|
|
75
|
+
@service_name = config.service_name
|
|
76
|
+
|
|
77
|
+
puts "Initializing TraceKit SDK v#{Tracekit::VERSION} for service: #{config.service_name}, environment: #{config.environment}"
|
|
78
|
+
|
|
79
|
+
# Auto-detect local UI
|
|
80
|
+
local_ui_detector = LocalUIDetector.new(config.local_ui_port)
|
|
81
|
+
if local_endpoint = local_ui_detector.local_ui_endpoint
|
|
82
|
+
puts "Local UI detected at #{local_endpoint}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Resolve endpoints
|
|
86
|
+
traces_endpoint = EndpointResolver.resolve(config.endpoint, "/v1/traces", config.use_ssl)
|
|
87
|
+
metrics_endpoint = EndpointResolver.resolve(config.endpoint, "/v1/metrics", config.use_ssl)
|
|
88
|
+
snapshot_base_url = EndpointResolver.resolve(config.endpoint, "", config.use_ssl)
|
|
89
|
+
|
|
90
|
+
# Initialize OpenTelemetry tracer
|
|
91
|
+
setup_tracing(traces_endpoint)
|
|
92
|
+
|
|
93
|
+
# Initialize metrics registry
|
|
94
|
+
@metrics_registry = Metrics::Registry.new(metrics_endpoint, config.api_key, config.service_name)
|
|
95
|
+
|
|
96
|
+
# Initialize snapshot client if code monitoring is enabled
|
|
97
|
+
if config.enable_code_monitoring
|
|
98
|
+
@snapshot_client = Snapshots::Client.new(
|
|
99
|
+
config.api_key,
|
|
100
|
+
snapshot_base_url,
|
|
101
|
+
config.service_name,
|
|
102
|
+
config.code_monitoring_poll_interval
|
|
103
|
+
)
|
|
104
|
+
puts "Code monitoring enabled - Snapshot client started"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
puts "TraceKit SDK initialized successfully. Traces: #{traces_endpoint}, Metrics: #{metrics_endpoint}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Creates a Counter metric for tracking monotonically increasing values
|
|
111
|
+
# @param name [String] Metric name (e.g., "http.requests.total")
|
|
112
|
+
# @param tags [Hash] Optional tags for the metric
|
|
113
|
+
# @return [Metrics::Counter]
|
|
114
|
+
def counter(name, tags = {})
|
|
115
|
+
Metrics::Counter.new(name, tags, @metrics_registry)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Creates a Gauge metric for tracking point-in-time values
|
|
119
|
+
# @param name [String] Metric name (e.g., "http.requests.active")
|
|
120
|
+
# @param tags [Hash] Optional tags for the metric
|
|
121
|
+
# @return [Metrics::Gauge]
|
|
122
|
+
def gauge(name, tags = {})
|
|
123
|
+
Metrics::Gauge.new(name, tags, @metrics_registry)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Creates a Histogram metric for tracking value distributions
|
|
127
|
+
# @param name [String] Metric name (e.g., "http.request.duration")
|
|
128
|
+
# @param tags [Hash] Optional tags for the metric
|
|
129
|
+
# @return [Metrics::Histogram]
|
|
130
|
+
def histogram(name, tags = {})
|
|
131
|
+
Metrics::Histogram.new(name, tags, @metrics_registry)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Captures a snapshot of local variables at the current code location
|
|
135
|
+
# Only active if code monitoring is enabled and there's an active breakpoint
|
|
136
|
+
# @param label [String] Stable identifier for this snapshot location
|
|
137
|
+
# @param variables [Hash] Variables to capture in the snapshot
|
|
138
|
+
def capture_snapshot(label, variables)
|
|
139
|
+
return unless @snapshot_client
|
|
140
|
+
|
|
141
|
+
caller_location = caller_locations(1, 1).first
|
|
142
|
+
@snapshot_client.capture_snapshot(label, variables, caller_location)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Shuts down the SDK and flushes any pending data
|
|
146
|
+
def shutdown
|
|
147
|
+
@metrics_registry&.shutdown
|
|
148
|
+
@snapshot_client&.shutdown
|
|
149
|
+
OpenTelemetry.tracer_provider&.shutdown
|
|
150
|
+
puts "TraceKit SDK shutdown complete"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def setup_tracing(traces_endpoint)
|
|
156
|
+
OpenTelemetry::SDK.configure do |c|
|
|
157
|
+
c.service_name = @config.service_name
|
|
158
|
+
c.service_version = @config.service_version
|
|
159
|
+
|
|
160
|
+
c.resource = OpenTelemetry::SDK::Resources::Resource.create(
|
|
161
|
+
{
|
|
162
|
+
"service.name" => @config.service_name,
|
|
163
|
+
"service.version" => @config.service_version,
|
|
164
|
+
"deployment.environment" => @config.environment
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Auto-instrument libraries if available
|
|
169
|
+
# Rails framework
|
|
170
|
+
begin
|
|
171
|
+
c.use "OpenTelemetry::Instrumentation::Rails" if defined?(OpenTelemetry::Instrumentation::Rails)
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
puts "Rails instrumentation failed: #{e.message}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# HTTP clients
|
|
177
|
+
begin
|
|
178
|
+
c.use "OpenTelemetry::Instrumentation::HTTP" if defined?(OpenTelemetry::Instrumentation::HTTP)
|
|
179
|
+
rescue StandardError
|
|
180
|
+
# HTTP instrumentation not available or failed
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
begin
|
|
184
|
+
c.use "OpenTelemetry::Instrumentation::NetHTTP" if defined?(OpenTelemetry::Instrumentation::NetHTTP) && defined?(Net::HTTP)
|
|
185
|
+
rescue StandardError
|
|
186
|
+
# NetHTTP instrumentation not available or failed
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Database - ActiveRecord
|
|
190
|
+
begin
|
|
191
|
+
c.use "OpenTelemetry::Instrumentation::ActiveRecord" if defined?(OpenTelemetry::Instrumentation::ActiveRecord)
|
|
192
|
+
rescue StandardError
|
|
193
|
+
# ActiveRecord instrumentation not available or failed
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Database - PG (PostgreSQL)
|
|
197
|
+
begin
|
|
198
|
+
c.use "OpenTelemetry::Instrumentation::PG" if defined?(OpenTelemetry::Instrumentation::PG)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
# PG instrumentation not available or failed
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Database - MySQL2
|
|
204
|
+
begin
|
|
205
|
+
c.use "OpenTelemetry::Instrumentation::Mysql2" if defined?(OpenTelemetry::Instrumentation::Mysql2)
|
|
206
|
+
rescue StandardError
|
|
207
|
+
# MySQL2 instrumentation not available or failed
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Redis
|
|
211
|
+
begin
|
|
212
|
+
c.use "OpenTelemetry::Instrumentation::Redis" if defined?(OpenTelemetry::Instrumentation::Redis)
|
|
213
|
+
rescue StandardError
|
|
214
|
+
# Redis instrumentation not available or failed
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Sidekiq
|
|
218
|
+
begin
|
|
219
|
+
c.use "OpenTelemetry::Instrumentation::Sidekiq" if defined?(OpenTelemetry::Instrumentation::Sidekiq)
|
|
220
|
+
rescue StandardError
|
|
221
|
+
# Sidekiq instrumentation not available or failed
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Rails components
|
|
225
|
+
begin
|
|
226
|
+
c.use "OpenTelemetry::Instrumentation::ActionView" if defined?(OpenTelemetry::Instrumentation::ActionView)
|
|
227
|
+
rescue StandardError
|
|
228
|
+
# ActionView instrumentation not available or failed
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
begin
|
|
232
|
+
c.use "OpenTelemetry::Instrumentation::ActionPack" if defined?(OpenTelemetry::Instrumentation::ActionPack)
|
|
233
|
+
rescue StandardError
|
|
234
|
+
# ActionPack instrumentation not available or failed
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
c.add_span_processor(
|
|
238
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
239
|
+
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
240
|
+
endpoint: traces_endpoint,
|
|
241
|
+
headers: { "X-API-Key" => @config.api_key },
|
|
242
|
+
compression: "none" # Disable compression to match Go SDK default behavior
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Note: Sampler configuration API changed in OpenTelemetry 1.x
|
|
248
|
+
# For now, using default sampler (ALWAYS_ON)
|
|
249
|
+
# TODO: Implement custom sampler if needed via span processor
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
class << self
|
|
254
|
+
# Global SDK instance
|
|
255
|
+
attr_accessor :instance
|
|
256
|
+
|
|
257
|
+
# Creates and configures the SDK singleton
|
|
258
|
+
# @param config [Config] SDK configuration
|
|
259
|
+
# @return [SDK]
|
|
260
|
+
def configure(config)
|
|
261
|
+
@instance = new(config)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Returns the global SDK instance
|
|
265
|
+
# @return [SDK, nil]
|
|
266
|
+
def current
|
|
267
|
+
@instance
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
module Security
|
|
5
|
+
# Detects and redacts sensitive data (PII, credentials) from variable snapshots
|
|
6
|
+
class Detector
|
|
7
|
+
SecurityFlag = Struct.new(:type, :category, :severity, :variable, :redacted, keyword_init: true)
|
|
8
|
+
ScanResult = Struct.new(:sanitized_variables, :security_flags, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def scan(variables)
|
|
11
|
+
sanitized = {}
|
|
12
|
+
flags = []
|
|
13
|
+
|
|
14
|
+
variables.each do |key, value|
|
|
15
|
+
sanitized_value, detected_flags = scan_value(key, value)
|
|
16
|
+
sanitized[key] = sanitized_value
|
|
17
|
+
flags.concat(detected_flags)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ScanResult.new(sanitized_variables: sanitized, security_flags: flags)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def scan_value(key, value)
|
|
26
|
+
return ["[NULL]", []] if value.nil?
|
|
27
|
+
|
|
28
|
+
flags = []
|
|
29
|
+
value_str = value.to_s
|
|
30
|
+
|
|
31
|
+
# Check PII
|
|
32
|
+
if Patterns::EMAIL.match?(value_str)
|
|
33
|
+
flags << SecurityFlag.new(type: "pii", category: "email", severity: "medium", variable: key, redacted: true)
|
|
34
|
+
return ["[REDACTED]", flags]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if Patterns::SSN.match?(value_str)
|
|
38
|
+
flags << SecurityFlag.new(type: "pii", category: "ssn", severity: "critical", variable: key, redacted: true)
|
|
39
|
+
return ["[REDACTED]", flags]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if Patterns::CREDIT_CARD.match?(value_str)
|
|
43
|
+
flags << SecurityFlag.new(type: "pii", category: "credit_card", severity: "critical", variable: key, redacted: true)
|
|
44
|
+
return ["[REDACTED]", flags]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if Patterns::PHONE.match?(value_str)
|
|
48
|
+
flags << SecurityFlag.new(type: "pii", category: "phone", severity: "medium", variable: key, redacted: true)
|
|
49
|
+
return ["[REDACTED]", flags]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check Credentials
|
|
53
|
+
if Patterns::API_KEY.match?(value_str)
|
|
54
|
+
flags << SecurityFlag.new(type: "credential", category: "api_key", severity: "critical", variable: key, redacted: true)
|
|
55
|
+
return ["[REDACTED]", flags]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if Patterns::AWS_KEY.match?(value_str)
|
|
59
|
+
flags << SecurityFlag.new(type: "credential", category: "aws_key", severity: "critical", variable: key, redacted: true)
|
|
60
|
+
return ["[REDACTED]", flags]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if Patterns::STRIPE_KEY.match?(value_str)
|
|
64
|
+
flags << SecurityFlag.new(type: "credential", category: "stripe_key", severity: "critical", variable: key, redacted: true)
|
|
65
|
+
return ["[REDACTED]", flags]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if Patterns::PASSWORD.match?(value_str)
|
|
69
|
+
flags << SecurityFlag.new(type: "credential", category: "password", severity: "critical", variable: key, redacted: true)
|
|
70
|
+
return ["[REDACTED]", flags]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if Patterns::JWT.match?(value_str)
|
|
74
|
+
flags << SecurityFlag.new(type: "credential", category: "jwt", severity: "high", variable: key, redacted: true)
|
|
75
|
+
return ["[REDACTED]", flags]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if Patterns::PRIVATE_KEY.match?(value_str)
|
|
79
|
+
flags << SecurityFlag.new(type: "credential", category: "private_key", severity: "critical", variable: key, redacted: true)
|
|
80
|
+
return ["[REDACTED]", flags]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[value, flags]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
module Security
|
|
5
|
+
# Regex patterns for detecting sensitive data in snapshots
|
|
6
|
+
module Patterns
|
|
7
|
+
# PII Patterns
|
|
8
|
+
EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/
|
|
9
|
+
SSN = /\b\d{3}-\d{2}-\d{4}\b/
|
|
10
|
+
CREDIT_CARD = /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/
|
|
11
|
+
PHONE = /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/
|
|
12
|
+
|
|
13
|
+
# Credential Patterns
|
|
14
|
+
API_KEY = /(api[_-]?key|apikey|access[_-]?key)[\s:=]+['" ]?([a-zA-Z0-9_-]{20,})['"]?/i
|
|
15
|
+
AWS_KEY = /AKIA[0-9A-Z]{16}/
|
|
16
|
+
STRIPE_KEY = /sk_live_[0-9a-zA-Z]{24}/
|
|
17
|
+
PASSWORD = /(password|pwd|pass)[\s:=]+['" ]?([^\s'" ]{6,})['"]?/i
|
|
18
|
+
JWT = /eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/
|
|
19
|
+
PRIVATE_KEY = /-----BEGIN (RSA |EC )?PRIVATE KEY-----/
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "concurrent/hash"
|
|
6
|
+
require "concurrent/timer_task"
|
|
7
|
+
|
|
8
|
+
module Tracekit
|
|
9
|
+
module Snapshots
|
|
10
|
+
# Client for code monitoring - polls breakpoints and captures snapshots
|
|
11
|
+
class Client
|
|
12
|
+
def initialize(api_key, base_url, service_name, poll_interval_seconds = 30)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@base_url = base_url
|
|
15
|
+
@service_name = service_name
|
|
16
|
+
@security_detector = Security::Detector.new
|
|
17
|
+
@breakpoints_cache = Concurrent::Hash.new
|
|
18
|
+
@registration_cache = Concurrent::Hash.new
|
|
19
|
+
|
|
20
|
+
# Start polling timer
|
|
21
|
+
@poll_task = Concurrent::TimerTask.new(execution_interval: poll_interval_seconds) do
|
|
22
|
+
fetch_active_breakpoints
|
|
23
|
+
end
|
|
24
|
+
@poll_task.execute
|
|
25
|
+
|
|
26
|
+
# Initial fetch
|
|
27
|
+
fetch_active_breakpoints
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Captures a snapshot at the caller's location
|
|
31
|
+
def capture_snapshot(label, variables, caller_location = nil)
|
|
32
|
+
# Extract caller information
|
|
33
|
+
caller_location ||= caller_locations(1, 1).first
|
|
34
|
+
file_path = caller_location.path
|
|
35
|
+
line_number = caller_location.lineno
|
|
36
|
+
function_name = caller_location.label
|
|
37
|
+
|
|
38
|
+
# Auto-register breakpoint
|
|
39
|
+
auto_register_breakpoint(file_path, line_number, function_name, label)
|
|
40
|
+
|
|
41
|
+
# Check if breakpoint is active
|
|
42
|
+
location_key = "#{function_name}:#{label}"
|
|
43
|
+
breakpoint = @breakpoints_cache[location_key] || @breakpoints_cache["#{file_path}:#{line_number}"]
|
|
44
|
+
|
|
45
|
+
return unless breakpoint
|
|
46
|
+
return unless breakpoint.enabled
|
|
47
|
+
return if breakpoint.expire_at && Time.now > breakpoint.expire_at
|
|
48
|
+
return if breakpoint.max_captures > 0 && breakpoint.capture_count >= breakpoint.max_captures
|
|
49
|
+
|
|
50
|
+
# Scan for security issues
|
|
51
|
+
scan_result = @security_detector.scan(variables)
|
|
52
|
+
|
|
53
|
+
# Get trace context from OpenTelemetry
|
|
54
|
+
trace_id = nil
|
|
55
|
+
span_id = nil
|
|
56
|
+
if defined?(OpenTelemetry::Trace)
|
|
57
|
+
span = OpenTelemetry::Trace.current_span
|
|
58
|
+
if span && span.context.valid?
|
|
59
|
+
trace_id = span.context.hex_trace_id
|
|
60
|
+
span_id = span.context.hex_span_id
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get stack trace
|
|
65
|
+
stack_trace = caller.join("\n")
|
|
66
|
+
|
|
67
|
+
snapshot = Snapshot.new(
|
|
68
|
+
breakpoint_id: breakpoint.id,
|
|
69
|
+
service_name: @service_name,
|
|
70
|
+
file_path: file_path,
|
|
71
|
+
function_name: function_name,
|
|
72
|
+
label: label,
|
|
73
|
+
line_number: line_number,
|
|
74
|
+
variables: scan_result.sanitized_variables,
|
|
75
|
+
security_flags: scan_result.security_flags.map(&:to_h),
|
|
76
|
+
stack_trace: stack_trace,
|
|
77
|
+
trace_id: trace_id,
|
|
78
|
+
span_id: span_id,
|
|
79
|
+
captured_at: Time.now.utc.iso8601
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Submit asynchronously
|
|
83
|
+
Thread.new { submit_snapshot(snapshot) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Shuts down the client
|
|
87
|
+
def shutdown
|
|
88
|
+
@poll_task&.shutdown
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def fetch_active_breakpoints
|
|
94
|
+
url = "#{@base_url}/sdk/snapshots/active/#{@service_name}"
|
|
95
|
+
uri = URI(url)
|
|
96
|
+
|
|
97
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
98
|
+
http.use_ssl = uri.scheme == "https"
|
|
99
|
+
http.read_timeout = 10
|
|
100
|
+
|
|
101
|
+
request = Net::HTTP::Get.new(uri.path)
|
|
102
|
+
request["X-API-Key"] = @api_key
|
|
103
|
+
|
|
104
|
+
response = http.request(request)
|
|
105
|
+
return unless response.is_a?(Net::HTTPSuccess)
|
|
106
|
+
|
|
107
|
+
data = JSON.parse(response.body, symbolize_names: true)
|
|
108
|
+
update_breakpoint_cache(data[:breakpoints]) if data[:breakpoints]
|
|
109
|
+
rescue => e
|
|
110
|
+
# Silently ignore errors fetching breakpoints
|
|
111
|
+
warn "Error fetching breakpoints: #{e.message}" if ENV["DEBUG"]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def update_breakpoint_cache(breakpoints)
|
|
115
|
+
@breakpoints_cache.clear
|
|
116
|
+
|
|
117
|
+
breakpoints.each do |bp_data|
|
|
118
|
+
bp = BreakpointConfig.new(
|
|
119
|
+
id: bp_data[:id],
|
|
120
|
+
file_path: bp_data[:file_path],
|
|
121
|
+
line_number: bp_data[:line_number],
|
|
122
|
+
function_name: bp_data[:function_name],
|
|
123
|
+
label: bp_data[:label],
|
|
124
|
+
enabled: bp_data[:enabled],
|
|
125
|
+
max_captures: bp_data[:max_captures] || 0,
|
|
126
|
+
capture_count: bp_data[:capture_count] || 0,
|
|
127
|
+
expire_at: bp_data[:expire_at] ? Time.parse(bp_data[:expire_at]) : nil
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Key by function + label
|
|
131
|
+
if bp.label && bp.function_name
|
|
132
|
+
label_key = "#{bp.function_name}:#{bp.label}"
|
|
133
|
+
@breakpoints_cache[label_key] = bp
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Key by file + line
|
|
137
|
+
line_key = "#{bp.file_path}:#{bp.line_number}"
|
|
138
|
+
@breakpoints_cache[line_key] = bp
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def auto_register_breakpoint(file_path, line_number, function_name, label)
|
|
143
|
+
reg_key = "#{function_name}:#{label}"
|
|
144
|
+
return if @registration_cache[reg_key]
|
|
145
|
+
|
|
146
|
+
@registration_cache[reg_key] = true
|
|
147
|
+
|
|
148
|
+
Thread.new do
|
|
149
|
+
begin
|
|
150
|
+
registration = {
|
|
151
|
+
service_name: @service_name,
|
|
152
|
+
file_path: file_path,
|
|
153
|
+
line_number: line_number,
|
|
154
|
+
function_name: function_name,
|
|
155
|
+
label: label
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
uri = URI("#{@base_url}/sdk/snapshots/auto-register")
|
|
159
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
160
|
+
http.use_ssl = uri.scheme == "https"
|
|
161
|
+
http.read_timeout = 10
|
|
162
|
+
|
|
163
|
+
request = Net::HTTP::Post.new(uri.path, {
|
|
164
|
+
"Content-Type" => "application/json",
|
|
165
|
+
"X-API-Key" => @api_key
|
|
166
|
+
})
|
|
167
|
+
request.body = JSON.generate(registration)
|
|
168
|
+
|
|
169
|
+
response = http.request(request)
|
|
170
|
+
|
|
171
|
+
# Refresh breakpoints cache after successful registration
|
|
172
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
173
|
+
sleep 0.5 # Small delay for backend processing
|
|
174
|
+
fetch_active_breakpoints
|
|
175
|
+
end
|
|
176
|
+
rescue => e
|
|
177
|
+
# Silently ignore auto-registration errors
|
|
178
|
+
warn "Error auto-registering breakpoint: #{e.message}" if ENV["DEBUG"]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def submit_snapshot(snapshot)
|
|
184
|
+
uri = URI("#{@base_url}/sdk/snapshots/capture")
|
|
185
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
186
|
+
http.use_ssl = uri.scheme == "https"
|
|
187
|
+
http.read_timeout = 10
|
|
188
|
+
|
|
189
|
+
request = Net::HTTP::Post.new(uri.path, {
|
|
190
|
+
"Content-Type" => "application/json",
|
|
191
|
+
"X-API-Key" => @api_key
|
|
192
|
+
})
|
|
193
|
+
request.body = JSON.generate(snapshot.to_h)
|
|
194
|
+
|
|
195
|
+
http.request(request)
|
|
196
|
+
rescue => e
|
|
197
|
+
# Silently ignore snapshot submission errors
|
|
198
|
+
warn "Error submitting snapshot: #{e.message}" if ENV["DEBUG"]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
module Snapshots
|
|
5
|
+
# Represents a breakpoint configuration from the backend
|
|
6
|
+
BreakpointConfig = Struct.new(
|
|
7
|
+
:id, :file_path, :line_number, :function_name, :label,
|
|
8
|
+
:enabled, :max_captures, :capture_count, :expire_at,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# Represents a snapshot capture
|
|
13
|
+
Snapshot = Struct.new(
|
|
14
|
+
:breakpoint_id, :service_name, :file_path, :function_name, :label,
|
|
15
|
+
:line_number, :variables, :security_flags, :stack_trace,
|
|
16
|
+
:trace_id, :span_id, :captured_at,
|
|
17
|
+
keyword_init: true
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Represents a breakpoint registration request
|
|
21
|
+
BreakpointRegistration = Struct.new(
|
|
22
|
+
:service_name, :file_path, :line_number, :function_name, :label,
|
|
23
|
+
keyword_init: true
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|