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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tracekit
4
+ VERSION = "0.1.0"
5
+ end