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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
# Configuration for the TraceKit SDK
|
|
5
|
+
# Follows the builder pattern with sensible defaults
|
|
6
|
+
class Config
|
|
7
|
+
attr_reader :api_key, :service_name, :endpoint, :use_ssl, :environment,
|
|
8
|
+
:service_version, :enable_code_monitoring,
|
|
9
|
+
:code_monitoring_poll_interval, :local_ui_port, :sampling_rate
|
|
10
|
+
|
|
11
|
+
def initialize(builder)
|
|
12
|
+
@api_key = builder.api_key
|
|
13
|
+
@service_name = builder.service_name
|
|
14
|
+
@endpoint = builder.endpoint || "app.tracekit.dev"
|
|
15
|
+
@use_ssl = builder.use_ssl.nil? ? true : builder.use_ssl
|
|
16
|
+
@environment = builder.environment || "production"
|
|
17
|
+
@service_version = builder.service_version || "1.0.0"
|
|
18
|
+
@enable_code_monitoring = builder.enable_code_monitoring.nil? ? true : builder.enable_code_monitoring
|
|
19
|
+
@code_monitoring_poll_interval = builder.code_monitoring_poll_interval || 30
|
|
20
|
+
@local_ui_port = builder.local_ui_port || 9999
|
|
21
|
+
@sampling_rate = builder.sampling_rate || 1.0
|
|
22
|
+
|
|
23
|
+
validate!
|
|
24
|
+
freeze # Make configuration immutable
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Builder pattern for fluent API
|
|
28
|
+
def self.build
|
|
29
|
+
builder = Builder.new
|
|
30
|
+
yield(builder) if block_given?
|
|
31
|
+
new(builder)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builder class for constructing Config instances
|
|
35
|
+
class Builder
|
|
36
|
+
attr_accessor :api_key, :service_name, :endpoint, :use_ssl, :environment,
|
|
37
|
+
:service_version, :enable_code_monitoring,
|
|
38
|
+
:code_monitoring_poll_interval, :local_ui_port, :sampling_rate
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
# Set defaults in builder
|
|
42
|
+
@endpoint = "app.tracekit.dev"
|
|
43
|
+
@use_ssl = true
|
|
44
|
+
@environment = "production"
|
|
45
|
+
@service_version = "1.0.0"
|
|
46
|
+
@enable_code_monitoring = true
|
|
47
|
+
@code_monitoring_poll_interval = 30
|
|
48
|
+
@local_ui_port = 9999
|
|
49
|
+
@sampling_rate = 1.0
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate!
|
|
56
|
+
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.empty?
|
|
57
|
+
raise ArgumentError, "service_name is required" if service_name.nil? || service_name.to_s.empty?
|
|
58
|
+
raise ArgumentError, "sampling_rate must be between 0.0 and 1.0" unless (0.0..1.0).cover?(sampling_rate)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
# Resolves endpoint URLs for different TraceKit services (traces, metrics, snapshots)
|
|
5
|
+
# Implements the same logic as .NET, Go, and Java SDKs for consistency
|
|
6
|
+
#
|
|
7
|
+
# CRITICAL: This must match the exact behavior across all SDK implementations
|
|
8
|
+
class EndpointResolver
|
|
9
|
+
class << self
|
|
10
|
+
# Resolves a full endpoint URL from a base endpoint and path
|
|
11
|
+
#
|
|
12
|
+
# @param endpoint [String] The base endpoint (can be host, host with scheme, or full URL)
|
|
13
|
+
# @param path [String] The path to append (e.g., "/v1/traces", "/v1/metrics", or "")
|
|
14
|
+
# @param use_ssl [Boolean] Whether to use HTTPS (ignored if endpoint already has a scheme)
|
|
15
|
+
# @return [String] The resolved endpoint URL
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# resolve("app.tracekit.dev", "/v1/traces", true)
|
|
19
|
+
# # => "https://app.tracekit.dev/v1/traces"
|
|
20
|
+
#
|
|
21
|
+
# resolve("http://localhost:8081", "/v1/traces", true)
|
|
22
|
+
# # => "http://localhost:8081/v1/traces"
|
|
23
|
+
#
|
|
24
|
+
# resolve("https://app.tracekit.dev/v1/traces", "/v1/metrics", true)
|
|
25
|
+
# # => "https://app.tracekit.dev/v1/metrics"
|
|
26
|
+
def resolve(endpoint, path, use_ssl)
|
|
27
|
+
# Case 1: Endpoint has a scheme (http:// or https://)
|
|
28
|
+
if endpoint.start_with?("http://", "https://")
|
|
29
|
+
# Remove trailing slash
|
|
30
|
+
endpoint = endpoint.chomp("/")
|
|
31
|
+
|
|
32
|
+
# Check if endpoint has a path component (anything after the host)
|
|
33
|
+
without_scheme = endpoint.sub(%r{^https?://}i, "")
|
|
34
|
+
|
|
35
|
+
if without_scheme.include?("/")
|
|
36
|
+
# Endpoint has a path component - extract base and append correct path
|
|
37
|
+
base_url = extract_base_url(endpoint)
|
|
38
|
+
return path.empty? ? base_url : "#{base_url}#{path}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Just host with scheme, add the path
|
|
42
|
+
return "#{endpoint}#{path}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Case 2: No scheme provided - build URL with scheme
|
|
46
|
+
scheme = use_ssl ? "https://" : "http://"
|
|
47
|
+
endpoint = endpoint.chomp("/")
|
|
48
|
+
"#{scheme}#{endpoint}#{path}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Extracts base URL (scheme + host + port) from full URL
|
|
52
|
+
# Always strips any path component, regardless of what it is
|
|
53
|
+
#
|
|
54
|
+
# @param full_url [String] The full URL to extract base from
|
|
55
|
+
# @return [String] The base URL (scheme + host + port)
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# extract_base_url("https://app.tracekit.dev/v1/traces")
|
|
59
|
+
# # => "https://app.tracekit.dev"
|
|
60
|
+
#
|
|
61
|
+
# extract_base_url("http://localhost:8081/custom/path")
|
|
62
|
+
# # => "http://localhost:8081"
|
|
63
|
+
def extract_base_url(full_url)
|
|
64
|
+
match = full_url.match(%r{^(https?://[^/]+)})
|
|
65
|
+
match ? match[1] : full_url
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module Tracekit
|
|
6
|
+
module LocalUI
|
|
7
|
+
# Detects if TraceKit Local UI is running and provides the endpoint
|
|
8
|
+
class Detector
|
|
9
|
+
def initialize(port = 9999)
|
|
10
|
+
@port = port
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Checks if Local UI is running by attempting a health check
|
|
14
|
+
# @return [Boolean] true if Local UI is running
|
|
15
|
+
def running?
|
|
16
|
+
uri = URI("http://localhost:#{@port}/api/health")
|
|
17
|
+
response = Net::HTTP.get_response(uri)
|
|
18
|
+
response.is_a?(Net::HTTPSuccess)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Gets the Local UI endpoint if it's running, otherwise nil
|
|
24
|
+
# @return [String, nil] The endpoint or nil
|
|
25
|
+
def endpoint
|
|
26
|
+
running? ? "http://localhost:#{@port}" : nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Tracekit
|
|
7
|
+
# Detects if TraceKit Local UI is running on the developer's machine
|
|
8
|
+
class LocalUIDetector
|
|
9
|
+
attr_reader :port
|
|
10
|
+
|
|
11
|
+
def initialize(port = 9999)
|
|
12
|
+
@port = port
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Check if local UI is running by hitting the health endpoint
|
|
16
|
+
# @return [Boolean] true if local UI is accessible
|
|
17
|
+
def local_ui_running?
|
|
18
|
+
Timeout.timeout(0.5) do
|
|
19
|
+
uri = URI("http://localhost:#{@port}/api/health")
|
|
20
|
+
response = Net::HTTP.get_response(uri)
|
|
21
|
+
response.is_a?(Net::HTTPSuccess)
|
|
22
|
+
end
|
|
23
|
+
rescue StandardError
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get the local UI endpoint if running
|
|
28
|
+
# @return [String, nil] the endpoint URL or nil if not running
|
|
29
|
+
def local_ui_endpoint
|
|
30
|
+
local_ui_running? ? "http://localhost:#{@port}" : nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
module Metrics
|
|
5
|
+
# Counter metric - monotonically increasing value
|
|
6
|
+
# Used for tracking totals like request counts, error counts, etc.
|
|
7
|
+
class Counter
|
|
8
|
+
def initialize(name, tags, registry)
|
|
9
|
+
@name = name
|
|
10
|
+
@tags = tags || {}
|
|
11
|
+
@registry = registry
|
|
12
|
+
@value = 0.0
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Increments the counter by 1
|
|
17
|
+
def inc
|
|
18
|
+
add(1.0)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Adds a value to the counter
|
|
22
|
+
# @param value [Numeric] Value to add (must be non-negative)
|
|
23
|
+
def add(value)
|
|
24
|
+
raise ArgumentError, "Counter values must be non-negative" if value < 0
|
|
25
|
+
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
@value += value
|
|
28
|
+
@registry.record_metric(@name, "counter", @value, @tags)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Tracekit
|
|
7
|
+
module Metrics
|
|
8
|
+
# Exports metrics to TraceKit in OTLP (OpenTelemetry Protocol) format
|
|
9
|
+
class Exporter
|
|
10
|
+
def initialize(endpoint, api_key, service_name)
|
|
11
|
+
@endpoint = endpoint
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
@service_name = service_name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Exports a batch of metric data points
|
|
17
|
+
def export(data_points)
|
|
18
|
+
return if data_points.empty?
|
|
19
|
+
|
|
20
|
+
payload = to_otlp(data_points)
|
|
21
|
+
|
|
22
|
+
uri = URI(@endpoint)
|
|
23
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
24
|
+
http.use_ssl = uri.scheme == "https"
|
|
25
|
+
http.read_timeout = 10
|
|
26
|
+
|
|
27
|
+
request = Net::HTTP::Post.new(uri.path, {
|
|
28
|
+
"Content-Type" => "application/json",
|
|
29
|
+
"X-API-Key" => @api_key,
|
|
30
|
+
"User-Agent" => "tracekit-ruby-sdk/#{Tracekit::VERSION}"
|
|
31
|
+
})
|
|
32
|
+
request.body = JSON.generate(payload)
|
|
33
|
+
|
|
34
|
+
response = http.request(request)
|
|
35
|
+
|
|
36
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
37
|
+
warn "Metrics export failed: HTTP #{response.code}"
|
|
38
|
+
end
|
|
39
|
+
rescue => e
|
|
40
|
+
warn "Metrics export error: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Converts data points to OTLP format
|
|
46
|
+
def to_otlp(data_points)
|
|
47
|
+
# Group by name and type
|
|
48
|
+
grouped = data_points.group_by { |dp| "#{dp.name}:#{dp.type}" }
|
|
49
|
+
|
|
50
|
+
metrics = grouped.map do |key, points|
|
|
51
|
+
name, type = key.split(":")
|
|
52
|
+
|
|
53
|
+
otlp_data_points = points.map do |dp|
|
|
54
|
+
{
|
|
55
|
+
attributes: dp.tags.map { |k, v| { key: k, value: { stringValue: v.to_s } } },
|
|
56
|
+
timeUnixNano: dp.timestamp_nanos.to_s,
|
|
57
|
+
asDouble: dp.value.to_f
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if type == "counter"
|
|
62
|
+
{
|
|
63
|
+
name: name,
|
|
64
|
+
sum: {
|
|
65
|
+
dataPoints: otlp_data_points,
|
|
66
|
+
aggregationTemporality: 2, # DELTA
|
|
67
|
+
isMonotonic: true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else # gauge or histogram
|
|
71
|
+
{
|
|
72
|
+
name: name,
|
|
73
|
+
gauge: {
|
|
74
|
+
dataPoints: otlp_data_points
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
resourceMetrics: [
|
|
82
|
+
{
|
|
83
|
+
resource: {
|
|
84
|
+
attributes: [
|
|
85
|
+
{ key: "service.name", value: { stringValue: @service_name } }
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
scopeMetrics: [
|
|
89
|
+
{
|
|
90
|
+
scope: { name: "tracekit" },
|
|
91
|
+
metrics: metrics
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
module Metrics
|
|
5
|
+
# Gauge metric - point-in-time value that can increase or decrease
|
|
6
|
+
# Used for tracking current values like active connections, memory usage, etc.
|
|
7
|
+
class Gauge
|
|
8
|
+
def initialize(name, tags, registry)
|
|
9
|
+
@name = name
|
|
10
|
+
@tags = tags || {}
|
|
11
|
+
@registry = registry
|
|
12
|
+
@value = 0.0
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Sets the gauge to a specific value
|
|
17
|
+
# @param value [Numeric] Value to set
|
|
18
|
+
def set(value)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@value = value
|
|
21
|
+
@registry.record_metric(@name, "gauge", @value, @tags)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Increments the gauge by 1
|
|
26
|
+
def inc
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@value += 1
|
|
29
|
+
@registry.record_metric(@name, "gauge", @value, @tags)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Decrements the gauge by 1
|
|
34
|
+
def dec
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@value -= 1
|
|
37
|
+
@registry.record_metric(@name, "gauge", @value, @tags)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
module Metrics
|
|
5
|
+
# Histogram metric - records distribution of values
|
|
6
|
+
# Used for tracking request durations, payload sizes, etc.
|
|
7
|
+
class Histogram
|
|
8
|
+
def initialize(name, tags, registry)
|
|
9
|
+
@name = name
|
|
10
|
+
@tags = tags || {}
|
|
11
|
+
@registry = registry
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Records a value in the histogram
|
|
15
|
+
# @param value [Numeric] Value to record
|
|
16
|
+
def record(value)
|
|
17
|
+
@registry.record_metric(@name, "histogram", value, @tags)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
module Metrics
|
|
5
|
+
# Represents a single metric data point for export
|
|
6
|
+
class MetricDataPoint
|
|
7
|
+
attr_reader :name, :type, :value, :tags, :timestamp_nanos
|
|
8
|
+
|
|
9
|
+
def initialize(name:, type:, value:, tags:)
|
|
10
|
+
@name = name
|
|
11
|
+
@type = type # "counter", "gauge", "histogram"
|
|
12
|
+
@value = value
|
|
13
|
+
@tags = tags
|
|
14
|
+
@timestamp_nanos = (Time.now.to_f * 1_000_000_000).to_i
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/array"
|
|
4
|
+
require "concurrent/timer_task"
|
|
5
|
+
|
|
6
|
+
module Tracekit
|
|
7
|
+
module Metrics
|
|
8
|
+
# Registry for managing metrics and exporting them to TraceKit
|
|
9
|
+
# Implements automatic buffering and periodic export (100 metrics or 10 seconds)
|
|
10
|
+
class Registry
|
|
11
|
+
MAX_BUFFER_SIZE = 100
|
|
12
|
+
FLUSH_INTERVAL_SECONDS = 10
|
|
13
|
+
|
|
14
|
+
def initialize(endpoint, api_key, service_name)
|
|
15
|
+
@endpoint = endpoint
|
|
16
|
+
@api_key = api_key
|
|
17
|
+
@service_name = service_name
|
|
18
|
+
@buffer = Concurrent::Array.new
|
|
19
|
+
@exporter = Exporter.new(endpoint, api_key, service_name)
|
|
20
|
+
@flush_mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
# Start periodic flush timer
|
|
23
|
+
@flush_task = Concurrent::TimerTask.new(execution_interval: FLUSH_INTERVAL_SECONDS) do
|
|
24
|
+
flush
|
|
25
|
+
end
|
|
26
|
+
@flush_task.execute
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Records a metric data point
|
|
30
|
+
def record_metric(name, type, value, tags)
|
|
31
|
+
data_point = MetricDataPoint.new(
|
|
32
|
+
name: name,
|
|
33
|
+
type: type,
|
|
34
|
+
value: value,
|
|
35
|
+
tags: tags.dup
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@buffer << data_point
|
|
39
|
+
|
|
40
|
+
# Auto-flush if buffer is full
|
|
41
|
+
flush if @buffer.size >= MAX_BUFFER_SIZE
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Flushes all buffered metrics
|
|
45
|
+
def flush
|
|
46
|
+
return if @buffer.empty?
|
|
47
|
+
|
|
48
|
+
@flush_mutex.synchronize do
|
|
49
|
+
return if @buffer.empty?
|
|
50
|
+
|
|
51
|
+
data_points = @buffer.dup
|
|
52
|
+
@buffer.clear
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
@exporter.export(data_points)
|
|
56
|
+
rescue => e
|
|
57
|
+
warn "Failed to export metrics: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Shuts down the registry and flushes remaining metrics
|
|
63
|
+
def shutdown
|
|
64
|
+
@flush_task&.shutdown
|
|
65
|
+
flush
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
# Rack middleware for automatic TraceKit instrumentation of HTTP requests
|
|
5
|
+
# Creates server spans with kind: :server for all incoming HTTP requests
|
|
6
|
+
class Middleware
|
|
7
|
+
def initialize(app)
|
|
8
|
+
@app = app
|
|
9
|
+
@tracer = OpenTelemetry.tracer_provider.tracer('tracekit-ruby', Tracekit::VERSION)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
sdk = SDK.current
|
|
14
|
+
return @app.call(env) unless sdk
|
|
15
|
+
|
|
16
|
+
request = Rack::Request.new(env)
|
|
17
|
+
path = request.path
|
|
18
|
+
method = request.request_method
|
|
19
|
+
|
|
20
|
+
# Create server span for incoming HTTP request
|
|
21
|
+
@tracer.in_span(
|
|
22
|
+
span_name(request),
|
|
23
|
+
attributes: span_attributes(request),
|
|
24
|
+
kind: :server
|
|
25
|
+
) do |span|
|
|
26
|
+
# Track metrics
|
|
27
|
+
request_counter = sdk.counter("http.server.requests", {
|
|
28
|
+
"http.method" => method,
|
|
29
|
+
"http.route" => path
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
active_gauge = sdk.gauge("http.server.active_requests", {
|
|
33
|
+
"http.method" => method
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
duration_histogram = sdk.histogram("http.server.request.duration", {
|
|
37
|
+
"unit" => "ms"
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
active_gauge.inc
|
|
41
|
+
start_time = Time.now
|
|
42
|
+
|
|
43
|
+
# Add client IP to span
|
|
44
|
+
if client_ip = extract_client_ip(request)
|
|
45
|
+
span.set_attribute("http.client_ip", client_ip)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
status, headers, body = @app.call(env)
|
|
50
|
+
|
|
51
|
+
# Set response attributes
|
|
52
|
+
span.set_attribute("http.status_code", status)
|
|
53
|
+
|
|
54
|
+
# Set span status based on HTTP status code
|
|
55
|
+
if status >= 500
|
|
56
|
+
span.status = OpenTelemetry::Trace::Status.error("HTTP #{status}")
|
|
57
|
+
elsif status >= 400
|
|
58
|
+
span.status = OpenTelemetry::Trace::Status.error("HTTP #{status}")
|
|
59
|
+
else
|
|
60
|
+
span.status = OpenTelemetry::Trace::Status.ok
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Record successful request
|
|
64
|
+
request_counter.inc
|
|
65
|
+
|
|
66
|
+
# Record errors
|
|
67
|
+
if status >= 400
|
|
68
|
+
error_counter = sdk.counter("http.server.errors", {
|
|
69
|
+
"http.method" => method,
|
|
70
|
+
"http.status_code" => status.to_s
|
|
71
|
+
})
|
|
72
|
+
error_counter.inc
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
[status, headers, body]
|
|
76
|
+
rescue => e
|
|
77
|
+
# Record exception on span
|
|
78
|
+
span.record_exception(e)
|
|
79
|
+
span.status = OpenTelemetry::Trace::Status.error("Exception: #{e.class.name}")
|
|
80
|
+
|
|
81
|
+
# Record exception metric
|
|
82
|
+
error_counter = sdk.counter("http.server.errors", {
|
|
83
|
+
"http.method" => method,
|
|
84
|
+
"error.type" => e.class.name
|
|
85
|
+
})
|
|
86
|
+
error_counter.inc
|
|
87
|
+
|
|
88
|
+
raise
|
|
89
|
+
ensure
|
|
90
|
+
active_gauge.dec
|
|
91
|
+
duration = ((Time.now - start_time) * 1000).round(2)
|
|
92
|
+
duration_histogram.record(duration)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def span_name(request)
|
|
100
|
+
# Use route if available (Rails), otherwise method + path
|
|
101
|
+
route = request.env['action_dispatch.request.path_parameters']&.[](:controller)
|
|
102
|
+
if route
|
|
103
|
+
action = request.env['action_dispatch.request.path_parameters']&.[](:action)
|
|
104
|
+
"#{request.request_method} #{route}##{action}"
|
|
105
|
+
else
|
|
106
|
+
"#{request.request_method} #{request.path}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def span_attributes(request)
|
|
111
|
+
{
|
|
112
|
+
'http.method' => request.request_method,
|
|
113
|
+
'http.url' => request.url,
|
|
114
|
+
'http.target' => request.path,
|
|
115
|
+
'http.host' => request.host,
|
|
116
|
+
'http.scheme' => request.scheme,
|
|
117
|
+
'http.user_agent' => request.user_agent,
|
|
118
|
+
'net.host.name' => request.host,
|
|
119
|
+
'net.host.port' => request.port
|
|
120
|
+
}.compact
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extract_client_ip(request)
|
|
124
|
+
# Try X-Forwarded-For first
|
|
125
|
+
forwarded = request.env["HTTP_X_FORWARDED_FOR"]
|
|
126
|
+
return forwarded.split(",").first.strip if forwarded
|
|
127
|
+
|
|
128
|
+
# Try X-Real-IP
|
|
129
|
+
real_ip = request.env["HTTP_X_REAL_IP"]
|
|
130
|
+
return real_ip if real_ip
|
|
131
|
+
|
|
132
|
+
# Fall back to remote IP
|
|
133
|
+
request.ip
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
# Rails integration via Railtie
|
|
5
|
+
# Auto-loads when Rails is detected
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
config.tracekit = ActiveSupport::OrderedOptions.new
|
|
8
|
+
|
|
9
|
+
initializer "tracekit.configure" do |app|
|
|
10
|
+
# Load configuration from Rails config or ENV vars
|
|
11
|
+
tracekit_config = Config.build do |c|
|
|
12
|
+
c.api_key = app.config.tracekit.api_key || ENV["TRACEKIT_API_KEY"]
|
|
13
|
+
c.service_name = app.config.tracekit.service_name || ENV["TRACEKIT_SERVICE_NAME"] || Rails.application.class.module_parent_name.underscore
|
|
14
|
+
c.endpoint = app.config.tracekit.endpoint || ENV["TRACEKIT_ENDPOINT"] || "app.tracekit.dev"
|
|
15
|
+
c.use_ssl = app.config.tracekit.use_ssl.nil? ? (ENV["TRACEKIT_USE_SSL"] != "false") : app.config.tracekit.use_ssl
|
|
16
|
+
c.environment = app.config.tracekit.environment || ENV["TRACEKIT_ENVIRONMENT"] || Rails.env
|
|
17
|
+
c.service_version = app.config.tracekit.service_version || ENV["TRACEKIT_SERVICE_VERSION"] || "1.0.0"
|
|
18
|
+
c.enable_code_monitoring = app.config.tracekit.enable_code_monitoring.nil? ? (ENV["TRACEKIT_CODE_MONITORING"] != "false") : app.config.tracekit.enable_code_monitoring
|
|
19
|
+
c.code_monitoring_poll_interval = (app.config.tracekit.code_monitoring_poll_interval || ENV["TRACEKIT_POLL_INTERVAL"] || 30).to_i
|
|
20
|
+
c.local_ui_port = (app.config.tracekit.local_ui_port || ENV["TRACEKIT_LOCAL_UI_PORT"] || 9999).to_i
|
|
21
|
+
c.sampling_rate = (app.config.tracekit.sampling_rate || ENV["TRACEKIT_SAMPLING_RATE"] || 1.0).to_f
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Initialize SDK
|
|
25
|
+
SDK.configure(tracekit_config)
|
|
26
|
+
|
|
27
|
+
# Insert middleware
|
|
28
|
+
app.middleware.use Tracekit::Middleware
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Shutdown SDK when Rails shuts down
|
|
32
|
+
config.after_initialize do
|
|
33
|
+
at_exit { SDK.current&.shutdown }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|