justanalytics 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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module JustAnalytics
6
+ # W3C Trace Context traceparent header parsing and serialization.
7
+ #
8
+ # Format: "00-{traceId}-{spanId}-{flags}"
9
+ # - version: "00" (only supported version)
10
+ # - traceId: 32-char lowercase hex (128-bit, must not be all zeros)
11
+ # - spanId: 16-char lowercase hex (64-bit, must not be all zeros)
12
+ # - flags: 2-char hex ("01" = sampled, "00" = not sampled)
13
+ #
14
+ # @see https://www.w3.org/TR/trace-context/
15
+ module TraceContext
16
+ TRACE_ID_REGEX = /\A[0-9a-f]{32}\z/
17
+ SPAN_ID_REGEX = /\A[0-9a-f]{16}\z/
18
+ FLAGS_REGEX = /\A[0-9a-f]{2}\z/
19
+ ZERO_TRACE_ID = "0" * 32
20
+ ZERO_SPAN_ID = "0" * 16
21
+
22
+ # Parsed traceparent data.
23
+ TraceparentData = Struct.new(:version, :trace_id, :parent_span_id, :trace_flags, keyword_init: true)
24
+
25
+ class << self
26
+ # Generate a 32-character lowercase hex trace ID (128-bit).
27
+ #
28
+ # @return [String] 32-character hex string
29
+ def generate_trace_id
30
+ SecureRandom.hex(16)
31
+ end
32
+
33
+ # Generate a 16-character lowercase hex span ID (64-bit).
34
+ #
35
+ # @return [String] 16-character hex string
36
+ def generate_span_id
37
+ SecureRandom.hex(8)
38
+ end
39
+
40
+ # Parse a W3C traceparent header string.
41
+ #
42
+ # @param header [String] the traceparent header value
43
+ # @return [TraceparentData, nil] parsed data or nil if invalid
44
+ #
45
+ # @example
46
+ # data = TraceContext.parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
47
+ # data.trace_id #=> "4bf92f3577b34da6a3ce929d0e0e4736"
48
+ def parse_traceparent(header)
49
+ return nil unless header.is_a?(String)
50
+
51
+ parts = header.split("-")
52
+ return nil unless parts.length == 4
53
+
54
+ version, trace_id, parent_span_id, trace_flags = parts
55
+
56
+ return nil unless version == "00"
57
+ return nil unless TRACE_ID_REGEX.match?(trace_id) && trace_id != ZERO_TRACE_ID
58
+ return nil unless SPAN_ID_REGEX.match?(parent_span_id) && parent_span_id != ZERO_SPAN_ID
59
+ return nil unless FLAGS_REGEX.match?(trace_flags)
60
+
61
+ TraceparentData.new(
62
+ version: version,
63
+ trace_id: trace_id,
64
+ parent_span_id: parent_span_id,
65
+ trace_flags: trace_flags
66
+ )
67
+ end
68
+
69
+ # Serialize a W3C traceparent header string.
70
+ #
71
+ # @param trace_id [String] 32-character hex trace ID
72
+ # @param span_id [String] 16-character hex span ID
73
+ # @param sampled [Boolean] whether the trace is sampled (default: true)
74
+ # @return [String] formatted traceparent header
75
+ #
76
+ # @example
77
+ # TraceContext.serialize_traceparent("4bf92f...", "00f067...", true)
78
+ # #=> "00-4bf92f...-00f067...-01"
79
+ def serialize_traceparent(trace_id, span_id, sampled: true)
80
+ flags = sampled ? "01" : "00"
81
+ "00-#{trace_id}-#{span_id}-#{flags}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module JustAnalytics
8
+ # Threaded batch transport for sending data to JustAnalytics ingestion endpoints.
9
+ #
10
+ # Collects spans, errors, logs, and metrics in separate thread-safe queues.
11
+ # A background thread periodically flushes each queue to its respective endpoint:
12
+ # - Spans: +POST /api/ingest/spans+ (batched array)
13
+ # - Errors: +POST /api/ingest/errors+ (batched array)
14
+ # - Logs: +POST /api/ingest/logs+ (batched array)
15
+ # - Metrics: +POST /api/ingest/metrics+ (batched array)
16
+ #
17
+ # Uses Ruby's +Thread::Queue+ for thread-safe buffering and +at_exit+ for
18
+ # graceful shutdown.
19
+ class Transport
20
+ # @return [Integer] number of spans pending flush
21
+ attr_reader :pending_span_count
22
+
23
+ # @param server_url [String] base URL of JustAnalytics server
24
+ # @param api_key [String] API key for Authorization header
25
+ # @param site_id [String] site ID for X-Site-ID header
26
+ # @param flush_interval [Float] flush interval in seconds
27
+ # @param max_batch_size [Integer] max items per batch
28
+ # @param debug [Boolean] enable debug logging
29
+ def initialize(server_url:, api_key:, site_id:, flush_interval: 2.0, max_batch_size: 100, debug: false)
30
+ @server_url = server_url
31
+ @api_key = api_key
32
+ @site_id = site_id
33
+ @flush_interval = flush_interval
34
+ @max_batch_size = max_batch_size
35
+ @debug = debug
36
+
37
+ @span_queue = Thread::Queue.new
38
+ @error_queue = Thread::Queue.new
39
+ @log_queue = Thread::Queue.new
40
+ @metric_queue = Thread::Queue.new
41
+
42
+ @pending_span_count = 0
43
+ @mutex = Mutex.new
44
+ @running = false
45
+ @worker_thread = nil
46
+ end
47
+
48
+ # Start the background flush worker thread.
49
+ #
50
+ # @return [void]
51
+ def start
52
+ return if @running
53
+
54
+ @running = true
55
+ @worker_thread = Thread.new { flush_loop }
56
+ @worker_thread.abort_on_exception = false
57
+
58
+ # Register at_exit hook for graceful shutdown
59
+ at_exit { shutdown }
60
+ end
61
+
62
+ # Stop the background worker and flush remaining data.
63
+ #
64
+ # @return [void]
65
+ def shutdown
66
+ return unless @running
67
+
68
+ @running = false
69
+ @worker_thread&.wakeup rescue nil
70
+ @worker_thread&.join(5)
71
+ flush_all
72
+ end
73
+
74
+ # Enqueue a span payload for batched sending.
75
+ #
76
+ # @param span_hash [Hash] serialized span data
77
+ # @return [void]
78
+ def enqueue_span(span_hash)
79
+ @span_queue << span_hash
80
+ @mutex.synchronize { @pending_span_count += 1 }
81
+ end
82
+
83
+ # Enqueue an error payload for batched sending.
84
+ #
85
+ # @param error_hash [Hash] serialized error data
86
+ # @return [void]
87
+ def enqueue_error(error_hash)
88
+ @error_queue << error_hash
89
+ end
90
+
91
+ # Enqueue a log entry for batched sending.
92
+ #
93
+ # @param log_hash [Hash] serialized log entry data
94
+ # @return [void]
95
+ def enqueue_log(log_hash)
96
+ @log_queue << log_hash
97
+ end
98
+
99
+ # Enqueue a metric data point for batched sending.
100
+ #
101
+ # @param metric_hash [Hash] serialized metric data
102
+ # @return [void]
103
+ def enqueue_metric(metric_hash)
104
+ @metric_queue << metric_hash
105
+ end
106
+
107
+ # Flush all pending data to the server immediately.
108
+ #
109
+ # @return [void]
110
+ def flush
111
+ flush_all
112
+ end
113
+
114
+ private
115
+
116
+ # Background loop that periodically flushes all queues.
117
+ def flush_loop
118
+ while @running
119
+ sleep(@flush_interval)
120
+ flush_all
121
+ end
122
+ rescue => e
123
+ debug_log("Flush loop error: #{e.message}")
124
+ end
125
+
126
+ # Drain all queues and send batched requests.
127
+ def flush_all
128
+ flush_queue(@span_queue, "/api/ingest/spans", "spans") do |batch|
129
+ @mutex.synchronize { @pending_span_count -= batch.size }
130
+ { "spans" => batch }
131
+ end
132
+
133
+ flush_queue(@error_queue, "/api/ingest/errors", "errors") do |batch|
134
+ { "errors" => batch }
135
+ end
136
+
137
+ flush_queue(@log_queue, "/api/ingest/logs", "logs") do |batch|
138
+ { "logs" => batch }
139
+ end
140
+
141
+ flush_queue(@metric_queue, "/api/ingest/metrics", "metrics") do |batch|
142
+ { "metrics" => batch }
143
+ end
144
+ end
145
+
146
+ # Drain items from a queue and send in batches.
147
+ #
148
+ # @param queue [Thread::Queue] the queue to drain
149
+ # @param path [String] API endpoint path
150
+ # @param label [String] human-readable label for debug logs
151
+ # @yield [Array<Hash>] batch of items to wrap in request body
152
+ def flush_queue(queue, path, label)
153
+ return if queue.empty?
154
+
155
+ batch = []
156
+ batch << queue.pop(true) until queue.empty? || batch.size >= @max_batch_size rescue nil
157
+
158
+ return if batch.empty?
159
+
160
+ body = yield(batch)
161
+ send_request(path, body, label, batch.size)
162
+ end
163
+
164
+ # Send an HTTP POST request to the given endpoint.
165
+ #
166
+ # @param path [String] API path (e.g. "/api/ingest/spans")
167
+ # @param body [Hash] request body to JSON-encode
168
+ # @param label [String] label for debug messages
169
+ # @param count [Integer] number of items in the batch
170
+ def send_request(path, body, label, count)
171
+ uri = URI.join(@server_url, path)
172
+ json_body = JSON.generate(body)
173
+
174
+ http = Net::HTTP.new(uri.host, uri.port)
175
+ http.use_ssl = (uri.scheme == "https")
176
+ http.open_timeout = 5
177
+ http.read_timeout = 10
178
+
179
+ request = Net::HTTP::Post.new(uri.path)
180
+ request["Content-Type"] = "application/json"
181
+ request["Authorization"] = "Bearer #{@api_key}"
182
+ request["X-Site-ID"] = @site_id
183
+ request["User-Agent"] = "justanalytics-ruby/#{JustAnalytics::VERSION}"
184
+ request.body = json_body
185
+
186
+ response = http.request(request)
187
+
188
+ case response.code.to_i
189
+ when 200..299
190
+ debug_log("Flushed #{count} #{label} successfully")
191
+ when 429
192
+ debug_log("Rate limited (429). Dropped #{count} #{label}.")
193
+ else
194
+ debug_log("Flush failed with HTTP #{response.code}. Dropped #{count} #{label}. Response: #{response.body}")
195
+ end
196
+ rescue => e
197
+ debug_log("Network error flushing #{label}: #{e.message}. Dropped #{count} #{label}.")
198
+ end
199
+
200
+ def debug_log(message)
201
+ $stderr.puts "[JustAnalytics] #{message}" if @debug
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Current gem version.
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ # JustAnalytics Ruby SDK
4
+ #
5
+ # End-to-end observability for Ruby applications. Provides distributed tracing,
6
+ # error tracking, structured logging, and infrastructure metrics reporting
7
+ # to the JustAnalytics platform.
8
+ #
9
+ # @example Quick start
10
+ # require "justanalytics"
11
+ #
12
+ # JustAnalytics.init(
13
+ # site_id: "site_abc123",
14
+ # api_key: "ja_sk_your_key_here",
15
+ # service_name: "rails-api",
16
+ # environment: "production"
17
+ # )
18
+ #
19
+ # JustAnalytics.start_span("process-order") do |span|
20
+ # span.set_attribute("order.id", "12345")
21
+ # # ... your code ...
22
+ # end
23
+ #
24
+ # @see https://justanalytics.app/docs/ruby-sdk
25
+ module JustAnalytics
26
+ class Error < StandardError; end
27
+
28
+ autoload :VERSION, "justanalytics/version"
29
+ autoload :Configuration, "justanalytics/configuration"
30
+ autoload :Context, "justanalytics/context"
31
+ autoload :TraceContext, "justanalytics/trace_context"
32
+ autoload :Span, "justanalytics/span"
33
+ autoload :Transport, "justanalytics/transport"
34
+ autoload :Logger, "justanalytics/logger"
35
+ autoload :Client, "justanalytics/client"
36
+
37
+ # Integrations
38
+ autoload :RailsMiddleware, "justanalytics/integrations/rails"
39
+ autoload :Railtie, "justanalytics/integrations/rails"
40
+ autoload :SidekiqServerMiddleware, "justanalytics/integrations/sidekiq"
41
+ autoload :SidekiqClientMiddleware, "justanalytics/integrations/sidekiq"
42
+ autoload :NetHttpPatch, "justanalytics/integrations/net_http"
43
+
44
+ class << self
45
+ # Get the singleton client instance.
46
+ #
47
+ # @return [Client]
48
+ def client
49
+ @client ||= Client.new
50
+ end
51
+
52
+ # Initialize the JustAnalytics SDK.
53
+ #
54
+ # Must be called before any other SDK method. Can only be called once;
55
+ # subsequent calls log a warning and are ignored.
56
+ #
57
+ # @param site_id [String] site ID from JustAnalytics dashboard (required)
58
+ # @param api_key [String] API key (required)
59
+ # @param service_name [String] service name (required)
60
+ # @param environment [String, nil] deployment environment
61
+ # @param release [String, nil] release/version string
62
+ # @param server_url [String, nil] JA server URL override
63
+ # @param debug [Boolean] enable debug logging (default: false)
64
+ # @param flush_interval [Float] flush interval in seconds (default: 2.0)
65
+ # @param max_batch_size [Integer] max batch size (default: 100)
66
+ # @param enabled [Boolean] enable/disable SDK (default: true)
67
+ # @return [void]
68
+ #
69
+ # @example
70
+ # JustAnalytics.init(
71
+ # site_id: "site_abc123",
72
+ # api_key: "ja_sk_your_key_here",
73
+ # service_name: "rails-api",
74
+ # environment: "production",
75
+ # release: "1.2.3"
76
+ # )
77
+ def init(site_id:, api_key:, service_name:, **options)
78
+ client.init(site_id: site_id, api_key: api_key, service_name: service_name, **options)
79
+ end
80
+
81
+ # Whether the SDK has been initialized.
82
+ #
83
+ # @return [Boolean]
84
+ def initialized?
85
+ client.initialized?
86
+ end
87
+
88
+ # Create and execute a span.
89
+ #
90
+ # @param name [String] operation name
91
+ # @param op [String] span kind (default: "internal")
92
+ # @param attributes [Hash] initial span attributes
93
+ # @yield [Span] the created span
94
+ # @return the return value of the block
95
+ #
96
+ # @example
97
+ # result = JustAnalytics.start_span("fetch-user", op: "db") do |span|
98
+ # span.set_attribute("user.id", user_id)
99
+ # User.find(user_id)
100
+ # end
101
+ def start_span(name, op: "internal", attributes: {}, &block)
102
+ client.start_span(name, op: op, attributes: attributes, &block)
103
+ end
104
+
105
+ # Capture an exception and send it to JustAnalytics.
106
+ #
107
+ # @param error [Exception, String] the error to capture
108
+ # @param tags [Hash] additional tags
109
+ # @param extra [Hash] arbitrary extra data
110
+ # @param user [Hash, nil] user context override
111
+ # @return [String] unique event ID
112
+ #
113
+ # @example
114
+ # begin
115
+ # risky_operation
116
+ # rescue => e
117
+ # JustAnalytics.capture_exception(e, tags: { module: "payments" })
118
+ # end
119
+ def capture_exception(error, **options)
120
+ client.capture_exception(error, **options)
121
+ end
122
+
123
+ # Capture a message and send it to JustAnalytics.
124
+ #
125
+ # @param message [String] the message
126
+ # @param level [String] severity level (default: "info")
127
+ # @param tags [Hash] additional tags
128
+ # @return [String] unique event ID
129
+ #
130
+ # @example
131
+ # JustAnalytics.capture_message("Rate limit exceeded", level: "warning")
132
+ def capture_message(message, **options)
133
+ client.capture_message(message, **options)
134
+ end
135
+
136
+ # Set user context for the current thread scope.
137
+ #
138
+ # @param id [String, nil] user ID
139
+ # @param email [String, nil] user email
140
+ # @param username [String, nil] username
141
+ # @return [void]
142
+ #
143
+ # @example
144
+ # JustAnalytics.set_user(id: "user-123", email: "alice@example.com")
145
+ def set_user(id: nil, email: nil, username: nil)
146
+ client.set_user(id: id, email: email, username: username)
147
+ end
148
+
149
+ # Set a tag on the current thread scope.
150
+ #
151
+ # @param key [String] tag key
152
+ # @param value [String] tag value
153
+ # @return [void]
154
+ #
155
+ # @example
156
+ # JustAnalytics.set_tag("feature", "checkout")
157
+ def set_tag(key, value)
158
+ client.set_tag(key, value)
159
+ end
160
+
161
+ # Get the structured logger instance.
162
+ #
163
+ # @return [Logger] the logger (no-op logger if SDK not initialized)
164
+ def logger
165
+ client.logger || Logger.new(
166
+ service_name: "unknown",
167
+ transport: nil,
168
+ enabled: false
169
+ )
170
+ end
171
+
172
+ # Record a custom infrastructure metric.
173
+ #
174
+ # @param metric_name [String] metric name (dot notation)
175
+ # @param value [Numeric] metric value
176
+ # @param tags [Hash] optional tags
177
+ # @return [void]
178
+ def record_metric(metric_name, value, tags: {})
179
+ client.record_metric(metric_name, value, tags: tags)
180
+ end
181
+
182
+ # Flush all pending data to the server.
183
+ #
184
+ # @return [void]
185
+ def flush
186
+ client.flush
187
+ end
188
+
189
+ # Shut down the SDK.
190
+ #
191
+ # @return [void]
192
+ def close
193
+ client.close
194
+ end
195
+
196
+ # Reset the singleton client (primarily for testing).
197
+ #
198
+ # @return [void]
199
+ # @api private
200
+ def reset!
201
+ client.close if client.initialized?
202
+ @client = nil
203
+ end
204
+ end
205
+ end
206
+
207
+ # Auto-load Railtie if Rails is present
208
+ require "justanalytics/integrations/rails" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: justanalytics
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Velocity Digital Labs
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webmock
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.19'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.19'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ description: Distributed tracing, error tracking, structured logging, and infrastructure
70
+ metrics for Ruby applications. Ships data to the JustAnalytics platform via batched
71
+ HTTP transport with automatic trace context propagation.
72
+ email:
73
+ - support@velocitydigitallabs.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - README.md
79
+ - lib/justanalytics.rb
80
+ - lib/justanalytics/client.rb
81
+ - lib/justanalytics/configuration.rb
82
+ - lib/justanalytics/context.rb
83
+ - lib/justanalytics/integrations/net_http.rb
84
+ - lib/justanalytics/integrations/rails.rb
85
+ - lib/justanalytics/integrations/sidekiq.rb
86
+ - lib/justanalytics/logger.rb
87
+ - lib/justanalytics/span.rb
88
+ - lib/justanalytics/trace_context.rb
89
+ - lib/justanalytics/transport.rb
90
+ - lib/justanalytics/version.rb
91
+ homepage: https://justanalytics.app
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ homepage_uri: https://justanalytics.app
96
+ source_code_uri: https://github.com/velocitydigitallabs/justanalytics-ruby
97
+ changelog_uri: https://github.com/velocitydigitallabs/justanalytics-ruby/blob/main/CHANGELOG.md
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: 2.6.0
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.0.3.1
114
+ signing_key:
115
+ specification_version: 4
116
+ summary: JustAnalytics Ruby SDK - End-to-end observability for Ruby applications
117
+ test_files: []