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