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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7fb7497381f0737b13c5ac642233e584d8a6761305a761deb076c3097b182e60
4
+ data.tar.gz: 1ba4d29f18dd7add59d685c6e37cc8f94a23a9b4a9bea91e8777040d90e296f7
5
+ SHA512:
6
+ metadata.gz: 69f41c7257fd38b4b77d1a5920cabc2f23203da91934130f9c902f1713a5b9138302c44870da936b7318d69f45d2b79f86dbb5c1c52d306b23bedb2eba2310aa
7
+ data.tar.gz: a177161441f4ba169a54cb5af6c2b7b8ba4b5a60813d3432c7a6f71587db0381fcdbbc42f1fa03876d82cec3deb6ae90bbc72c9277146a52f9e088c51e57f5e2
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # JustAnalytics Ruby SDK
2
+
3
+ End-to-end observability for Ruby applications. Distributed tracing, error tracking, structured logging, and infrastructure metrics — all in one gem.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "justanalytics"
11
+ ```
12
+
13
+ Then run `bundle install`.
14
+
15
+ ## Quick Start
16
+
17
+ ```ruby
18
+ require "justanalytics"
19
+
20
+ JustAnalytics.init(
21
+ site_id: ENV["JA_SITE_ID"],
22
+ api_key: ENV["JA_API_KEY"],
23
+ service_name: "rails-api",
24
+ environment: "production",
25
+ release: "1.2.3"
26
+ )
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### Tracing
32
+
33
+ ```ruby
34
+ JustAnalytics.start_span("process-order", op: "task") do |span|
35
+ span.set_attribute("order.id", order.id)
36
+ span.add_event("payment.started")
37
+ process_payment(order)
38
+ end
39
+ ```
40
+
41
+ ### Error Tracking
42
+
43
+ ```ruby
44
+ begin
45
+ risky_operation
46
+ rescue => e
47
+ JustAnalytics.capture_exception(e, tags: { module: "payments" })
48
+ end
49
+
50
+ JustAnalytics.capture_message("Rate limit exceeded", level: "warning")
51
+ ```
52
+
53
+ ### Structured Logging
54
+
55
+ ```ruby
56
+ JustAnalytics.logger.info("User logged in", user_id: "u123")
57
+ JustAnalytics.logger.error("Payment failed", order_id: "o456")
58
+ ```
59
+
60
+ ### User Context
61
+
62
+ ```ruby
63
+ JustAnalytics.set_user(id: "user-123", email: "alice@example.com")
64
+ ```
65
+
66
+ ### Rails Integration
67
+
68
+ The SDK auto-configures Rails middleware via a Railtie. Just add the gem and initialize:
69
+
70
+ ```ruby
71
+ # config/initializers/justanalytics.rb
72
+ JustAnalytics.init(
73
+ site_id: ENV["JA_SITE_ID"],
74
+ api_key: ENV["JA_API_KEY"],
75
+ service_name: "rails-api"
76
+ )
77
+ ```
78
+
79
+ ### Sidekiq Integration
80
+
81
+ ```ruby
82
+ Sidekiq.configure_server do |config|
83
+ config.server_middleware do |chain|
84
+ chain.add JustAnalytics::SidekiqServerMiddleware
85
+ end
86
+ end
87
+
88
+ Sidekiq.configure_client do |config|
89
+ config.client_middleware do |chain|
90
+ chain.add JustAnalytics::SidekiqClientMiddleware
91
+ end
92
+ end
93
+ ```
94
+
95
+ ### Net::HTTP Auto-Instrumentation
96
+
97
+ ```ruby
98
+ JustAnalytics::NetHttpPatch.enable!
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "socket"
5
+
6
+ module JustAnalytics
7
+ # Core client class managing configuration, lifecycle, span creation,
8
+ # error capture, context propagation, and batched transport.
9
+ #
10
+ # Typically accessed through the module-level methods on {JustAnalytics}
11
+ # rather than instantiated directly.
12
+ class Client
13
+ # @return [Configuration] current configuration
14
+ attr_reader :config
15
+
16
+ # @return [Logger] structured logger instance
17
+ attr_reader :logger
18
+
19
+ # @return [Boolean] whether init has been called
20
+ attr_reader :initialized
21
+ alias_method :initialized?, :initialized
22
+
23
+ def initialize
24
+ @config = nil
25
+ @transport = nil
26
+ @logger = nil
27
+ @initialized = false
28
+ end
29
+
30
+ # Initialize the SDK with configuration.
31
+ #
32
+ # @param site_id [String] site ID (required)
33
+ # @param api_key [String] API key (required)
34
+ # @param service_name [String] service name (required)
35
+ # @param environment [String, nil] deployment environment
36
+ # @param release [String, nil] release/version string
37
+ # @param server_url [String, nil] JA server URL override
38
+ # @param debug [Boolean] enable debug logging
39
+ # @param flush_interval [Float] flush interval in seconds
40
+ # @param max_batch_size [Integer] max batch size
41
+ # @param enabled [Boolean] enable/disable SDK
42
+ # @return [void]
43
+ def init(site_id:, api_key:, service_name:, environment: nil, release: nil,
44
+ server_url: nil, debug: false, flush_interval: 2.0, max_batch_size: 100,
45
+ enabled: true)
46
+ if @initialized
47
+ $stderr.puts "[JustAnalytics] Warning: init() already called. Ignoring."
48
+ return
49
+ end
50
+
51
+ @config = Configuration.new
52
+ @config.site_id = site_id
53
+ @config.api_key = api_key
54
+ @config.service_name = service_name
55
+ @config.environment = environment
56
+ @config.release = release
57
+ @config.server_url = server_url if server_url
58
+ @config.debug = debug
59
+ @config.flush_interval = flush_interval
60
+ @config.max_batch_size = max_batch_size
61
+ @config.enabled = enabled
62
+
63
+ @config.validate!
64
+
65
+ if debug
66
+ key_prefix = api_key[0, 10]
67
+ $stderr.puts "[JustAnalytics] Initializing SDK: service=#{service_name}, " \
68
+ "server=#{@config.server_url}, apiKey=#{key_prefix}..., enabled=#{enabled}"
69
+ end
70
+
71
+ if enabled
72
+ @transport = Transport.new(
73
+ server_url: @config.server_url,
74
+ api_key: @config.api_key,
75
+ site_id: @config.site_id,
76
+ flush_interval: @config.flush_interval,
77
+ max_batch_size: @config.max_batch_size,
78
+ debug: @config.debug
79
+ )
80
+ @transport.start
81
+
82
+ @logger = JustAnalytics::Logger.new(
83
+ service_name: @config.service_name,
84
+ transport: @transport,
85
+ enabled: true,
86
+ environment: @config.environment,
87
+ release: @config.release,
88
+ debug: @config.debug
89
+ )
90
+ else
91
+ @logger = JustAnalytics::Logger.new(
92
+ service_name: service_name,
93
+ transport: nil,
94
+ enabled: false
95
+ )
96
+ end
97
+
98
+ @initialized = true
99
+ end
100
+
101
+ # Create and execute a span.
102
+ #
103
+ # The block runs within a context scope with the span as the active span.
104
+ # Nested +start_span+ calls automatically create parent-child relationships.
105
+ #
106
+ # @param name [String] operation name for the span
107
+ # @param op [String] span kind (default: "internal")
108
+ # @param attributes [Hash] initial span attributes
109
+ # @yield [Span] the created span
110
+ # @return the return value of the block
111
+ #
112
+ # @example
113
+ # result = JustAnalytics.start_span("process-order", op: "task") do |span|
114
+ # span.set_attribute("order.id", "12345")
115
+ # process_order
116
+ # end
117
+ def start_span(name, op: "internal", attributes: {})
118
+ # Determine parent and trace ID
119
+ parent_span = Context.current_span
120
+ if parent_span
121
+ trace_id = parent_span.trace_id
122
+ parent_span_id = parent_span.id
123
+ else
124
+ trace_id = TraceContext.generate_trace_id
125
+ parent_span_id = nil
126
+ end
127
+
128
+ should_enqueue = @initialized && @config&.enabled
129
+
130
+ # Merge context tags into attributes
131
+ str_attrs = {}
132
+ attributes.each { |k, v| str_attrs[k.to_s] = v }
133
+ merged_attrs = Context.current_tags.merge(str_attrs)
134
+ merged_attrs["environment"] = @config.environment if @config&.environment
135
+ merged_attrs["release"] = @config.release if @config&.release
136
+
137
+ span = Span.new(
138
+ operation_name: name,
139
+ service_name: @config&.service_name || "unknown",
140
+ kind: op,
141
+ trace_id: trace_id,
142
+ parent_span_id: parent_span_id,
143
+ attributes: merged_attrs
144
+ )
145
+
146
+ child_ctx = Context.create_child(active_span: span, trace_id: trace_id)
147
+
148
+ result = nil
149
+ Context.with_context(child_ctx) do
150
+ result = yield(span) if block_given?
151
+ rescue => e
152
+ span.set_status("error", e.message) unless span.ended?
153
+ span.end_span unless span.ended?
154
+ enqueue_span(span) if should_enqueue
155
+ raise
156
+ end
157
+
158
+ span.end_span unless span.ended?
159
+ enqueue_span(span) if should_enqueue
160
+ result
161
+ end
162
+
163
+ # Capture an exception and send it to JustAnalytics.
164
+ #
165
+ # @param error [Exception, String, Object] the error to capture
166
+ # @param tags [Hash] additional tags
167
+ # @param extra [Hash] arbitrary extra data
168
+ # @param user [Hash] user context override
169
+ # @param level [String] severity level (default: "error")
170
+ # @param fingerprint [Array<String>] custom fingerprint
171
+ # @return [String] unique event ID, or empty string if disabled
172
+ def capture_exception(error, tags: {}, extra: {}, user: nil, level: "error", fingerprint: nil)
173
+ return "" unless @initialized && @config&.enabled
174
+
175
+ payload = build_error_payload(
176
+ error,
177
+ mechanism_type: "manual",
178
+ handled: true,
179
+ level: level,
180
+ tags: tags,
181
+ extra: extra,
182
+ user: user,
183
+ fingerprint: fingerprint
184
+ )
185
+ @transport&.enqueue_error(payload)
186
+ payload["eventId"]
187
+ rescue => e
188
+ $stderr.puts "[JustAnalytics] captureException() internal error: #{e.message}" if @config&.debug
189
+ ""
190
+ end
191
+
192
+ # Capture a message and send it to JustAnalytics.
193
+ #
194
+ # @param message [String] the message string
195
+ # @param level [String] severity level (default: "info")
196
+ # @param tags [Hash] additional tags
197
+ # @return [String] unique event ID, or empty string if disabled
198
+ def capture_message(message, level: "info", tags: {})
199
+ return "" unless @initialized && @config&.enabled
200
+
201
+ payload = build_error_payload(
202
+ message,
203
+ mechanism_type: "manual",
204
+ handled: true,
205
+ level: level,
206
+ tags: tags,
207
+ extra: {},
208
+ user: nil,
209
+ fingerprint: nil
210
+ )
211
+ payload["error"]["type"] = "Message"
212
+ @transport&.enqueue_error(payload)
213
+ payload["eventId"]
214
+ rescue => e
215
+ $stderr.puts "[JustAnalytics] captureMessage() internal error: #{e.message}" if @config&.debug
216
+ ""
217
+ end
218
+
219
+ # Set user context for the current thread scope.
220
+ #
221
+ # @param id [String, nil] user ID
222
+ # @param email [String, nil] user email
223
+ # @param username [String, nil] username
224
+ # @return [void]
225
+ def set_user(id: nil, email: nil, username: nil)
226
+ return unless @initialized
227
+
228
+ user = {}
229
+ user["id"] = id if id
230
+ user["email"] = email if email
231
+ user["username"] = username if username
232
+ Context.set_user(user)
233
+ end
234
+
235
+ # Set a tag on the current thread scope.
236
+ #
237
+ # @param key [String] tag key
238
+ # @param value [String] tag value
239
+ # @return [void]
240
+ def set_tag(key, value)
241
+ return unless @initialized
242
+
243
+ Context.set_tag(key, value)
244
+ end
245
+
246
+ # Record a custom infrastructure metric.
247
+ #
248
+ # @param metric_name [String] metric name (dot notation)
249
+ # @param value [Numeric] metric value
250
+ # @param tags [Hash] optional tags
251
+ # @return [void]
252
+ def record_metric(metric_name, value, tags: {})
253
+ return unless @initialized && @config&.enabled
254
+
255
+ payload = {
256
+ "metricName" => metric_name,
257
+ "value" => value,
258
+ "serviceName" => @config.service_name,
259
+ "timestamp" => Time.now.utc.iso8601(3),
260
+ "tags" => {
261
+ "hostname" => Socket.gethostname,
262
+ "runtime" => "ruby",
263
+ "rubyVersion" => RUBY_VERSION
264
+ }.merge(@config.environment ? { "environment" => @config.environment } : {})
265
+ .merge(tags.transform_keys(&:to_s))
266
+ }
267
+ @transport&.enqueue_metric(payload)
268
+ rescue => e
269
+ $stderr.puts "[JustAnalytics] recordMetric() internal error: #{e.message}" if @config&.debug
270
+ end
271
+
272
+ # Flush all pending data to the server.
273
+ #
274
+ # @return [void]
275
+ def flush
276
+ @transport&.flush
277
+ end
278
+
279
+ # Shut down the SDK: flush remaining data, stop worker thread.
280
+ #
281
+ # @return [void]
282
+ def close
283
+ return unless @initialized
284
+
285
+ $stderr.puts "[JustAnalytics] Closing SDK..." if @config&.debug
286
+
287
+ @transport&.shutdown
288
+ @transport = nil
289
+ @logger = nil
290
+ @initialized = false
291
+
292
+ $stderr.puts "[JustAnalytics] SDK closed." if @config&.debug
293
+ end
294
+
295
+ private
296
+
297
+ # Enqueue a completed span for transport.
298
+ def enqueue_span(span)
299
+ @transport&.enqueue_span(span.to_h)
300
+ end
301
+
302
+ # Build an error payload matching the /api/ingest/errors schema.
303
+ #
304
+ # @param value [Exception, String, Object] the error or message
305
+ # @param mechanism_type [String] how the error was captured
306
+ # @param handled [Boolean] whether the error was handled
307
+ # @param level [String] severity level
308
+ # @param tags [Hash] additional tags
309
+ # @param extra [Hash] extra data
310
+ # @param user [Hash, nil] user context override
311
+ # @param fingerprint [Array<String>, nil] custom fingerprint
312
+ # @return [Hash] the serialized payload
313
+ def build_error_payload(value, mechanism_type:, handled:, level:, tags:, extra:, user:, fingerprint:)
314
+ error_data = serialize_error(value)
315
+
316
+ # Get trace context
317
+ active_span = Context.current_span
318
+ trace_id = active_span&.trace_id || Context.current_trace_id
319
+ span_id = active_span&.id
320
+
321
+ # Get user context (parameter overrides context)
322
+ context_user = Context.current_user
323
+ resolved_user = user || context_user
324
+
325
+ # Merge context tags with provided tags
326
+ context_tags = Context.current_tags
327
+ merged_tags = context_tags.merge((tags || {}).transform_keys(&:to_s).transform_values(&:to_s))
328
+
329
+ event_id = SecureRandom.hex(12)
330
+
331
+ {
332
+ "eventId" => event_id,
333
+ "timestamp" => Time.now.utc.iso8601(3),
334
+ "error" => error_data,
335
+ "level" => level,
336
+ "mechanism" => {
337
+ "type" => mechanism_type,
338
+ "handled" => handled
339
+ },
340
+ "context" => {
341
+ "serviceName" => @config.service_name,
342
+ "environment" => @config.environment,
343
+ "release" => @config.release,
344
+ "runtime" => "ruby",
345
+ "rubyVersion" => RUBY_VERSION
346
+ },
347
+ "trace" => trace_id ? { "traceId" => trace_id, "spanId" => span_id } : nil,
348
+ "user" => resolved_user && !resolved_user.empty? ? resolved_user : nil,
349
+ "tags" => merged_tags.empty? ? nil : merged_tags,
350
+ "extra" => (extra || {}).empty? ? nil : extra.transform_keys(&:to_s),
351
+ "fingerprint" => fingerprint
352
+ }
353
+ end
354
+
355
+ # Serialize an error value into the error payload shape.
356
+ #
357
+ # @param value [Exception, String, Object] the error to serialize
358
+ # @return [Hash] with "message", "type", "stack" keys
359
+ def serialize_error(value)
360
+ case value
361
+ when Exception
362
+ {
363
+ "message" => value.message.to_s.empty? ? value.class.name : value.message,
364
+ "type" => value.class.name,
365
+ "stack" => value.backtrace&.join("\n")
366
+ }
367
+ when String
368
+ { "message" => value, "type" => "Error", "stack" => nil }
369
+ else
370
+ { "message" => value.to_s, "type" => "Error", "stack" => nil }
371
+ end
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Configuration object holding all SDK settings.
5
+ #
6
+ # @example
7
+ # JustAnalytics.init(
8
+ # site_id: "site_abc123",
9
+ # api_key: "ja_sk_your_api_key_here",
10
+ # service_name: "rails-api",
11
+ # environment: "production",
12
+ # release: "1.2.3"
13
+ # )
14
+ class Configuration
15
+ # @return [String] Site ID from JustAnalytics dashboard (required).
16
+ attr_accessor :site_id
17
+
18
+ # @return [String] API key from JustAnalytics dashboard (required).
19
+ attr_accessor :api_key
20
+
21
+ # @return [String] Service name, e.g. "rails-api", "worker" (required).
22
+ attr_accessor :service_name
23
+
24
+ # @return [String, nil] Deployment environment, e.g. "production", "staging".
25
+ attr_accessor :environment
26
+
27
+ # @return [String, nil] Release/version string, e.g. "1.2.3", git SHA.
28
+ attr_accessor :release
29
+
30
+ # @return [String] Base URL of JustAnalytics server.
31
+ attr_accessor :server_url
32
+
33
+ # @return [Boolean] Enable debug logging to $stderr (default: false).
34
+ attr_accessor :debug
35
+
36
+ # @return [Float] Flush interval in seconds (default: 2.0).
37
+ attr_accessor :flush_interval
38
+
39
+ # @return [Integer] Maximum items per batch before immediate flush (default: 100).
40
+ attr_accessor :max_batch_size
41
+
42
+ # @return [Boolean] Enable/disable the SDK (default: true).
43
+ attr_accessor :enabled
44
+
45
+ # Default JustAnalytics server URL.
46
+ DEFAULT_SERVER_URL = "https://justanalytics.up.railway.app"
47
+
48
+ def initialize
49
+ @site_id = nil
50
+ @api_key = nil
51
+ @service_name = nil
52
+ @environment = nil
53
+ @release = nil
54
+ @server_url = ENV.fetch("JUSTANALYTICS_URL", DEFAULT_SERVER_URL)
55
+ @debug = false
56
+ @flush_interval = 2.0
57
+ @max_batch_size = 100
58
+ @enabled = true
59
+ end
60
+
61
+ # Validate that all required fields are present.
62
+ #
63
+ # @raise [ArgumentError] if any required field is missing
64
+ # @return [void]
65
+ def validate!
66
+ raise ArgumentError, "[JustAnalytics] init() requires :site_id. Get it from your JustAnalytics dashboard." if site_id.nil? || site_id.to_s.empty?
67
+ raise ArgumentError, "[JustAnalytics] init() requires :api_key. Create one in your JustAnalytics site settings." if api_key.nil? || api_key.to_s.empty?
68
+ raise ArgumentError, '[JustAnalytics] init() requires :service_name (e.g. "rails-api", "worker").' if service_name.nil? || service_name.to_s.empty?
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Thread-local context propagation for distributed tracing.
5
+ #
6
+ # Uses +Thread.current+ for fiber-safe storage. Context is automatically
7
+ # inherited when new fibers are created within a traced scope.
8
+ #
9
+ # @example
10
+ # JustAnalytics::Context.current_span #=> Span or nil
11
+ # JustAnalytics::Context.current_trace_id #=> String or nil
12
+ module Context
13
+ THREAD_KEY = :justanalytics_context
14
+
15
+ # Internal context data structure.
16
+ ContextData = Struct.new(:active_span, :trace_id, :user, :tags, keyword_init: true) do
17
+ def initialize(active_span: nil, trace_id: nil, user: nil, tags: {})
18
+ super
19
+ end
20
+ end
21
+
22
+ class << self
23
+ # Get the current context from Thread.current.
24
+ #
25
+ # @return [ContextData, nil]
26
+ def current
27
+ Thread.current[THREAD_KEY]
28
+ end
29
+
30
+ # Set the current context on Thread.current.
31
+ #
32
+ # @param ctx [ContextData, nil]
33
+ # @return [void]
34
+ def current=(ctx)
35
+ Thread.current[THREAD_KEY] = ctx
36
+ end
37
+
38
+ # Get the currently active span.
39
+ #
40
+ # @return [Span, nil]
41
+ def current_span
42
+ current&.active_span
43
+ end
44
+
45
+ # Get the current trace ID.
46
+ #
47
+ # @return [String, nil]
48
+ def current_trace_id
49
+ current&.trace_id
50
+ end
51
+
52
+ # Get the current user context.
53
+ #
54
+ # @return [Hash, nil]
55
+ def current_user
56
+ current&.user
57
+ end
58
+
59
+ # Get the current tags.
60
+ #
61
+ # @return [Hash]
62
+ def current_tags
63
+ current&.tags || {}
64
+ end
65
+
66
+ # Set user context on the current scope.
67
+ #
68
+ # @param user [Hash] user context with :id, :email, :username keys
69
+ # @return [void]
70
+ def set_user(user)
71
+ ctx = current
72
+ return unless ctx
73
+
74
+ ctx.user = user
75
+ end
76
+
77
+ # Set a tag on the current scope.
78
+ #
79
+ # @param key [String] tag key
80
+ # @param value [String] tag value
81
+ # @return [void]
82
+ def set_tag(key, value)
83
+ ctx = current
84
+ return unless ctx
85
+
86
+ # Copy-on-write semantics
87
+ ctx.tags = ctx.tags.merge(key.to_s => value.to_s)
88
+ end
89
+
90
+ # Run a block within a new context scope.
91
+ #
92
+ # Saves the previous context, sets the new one, yields, and
93
+ # restores the previous context afterward (even on exception).
94
+ #
95
+ # @param ctx [ContextData] the context to activate
96
+ # @yield the block to run within the context
97
+ # @return the return value of the block
98
+ def with_context(ctx)
99
+ previous = current
100
+ self.current = ctx
101
+ yield
102
+ ensure
103
+ self.current = previous
104
+ end
105
+
106
+ # Create a child context inheriting from the current one.
107
+ #
108
+ # @param active_span [Span, nil] the span to set as active
109
+ # @param trace_id [String, nil] trace ID override
110
+ # @return [ContextData]
111
+ def create_child(active_span: nil, trace_id: nil)
112
+ parent = current || ContextData.new
113
+ ContextData.new(
114
+ active_span: active_span || parent.active_span,
115
+ trace_id: trace_id || parent.trace_id,
116
+ user: parent.user,
117
+ tags: parent.tags.dup
118
+ )
119
+ end
120
+ end
121
+ end
122
+ end