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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Monkey-patch for +Net::HTTP+ that automatically creates client spans
5
+ # for outgoing HTTP requests and injects +traceparent+ headers.
6
+ #
7
+ # The patch wraps +Net::HTTP#request+ to:
8
+ # 1. Create a client span with HTTP method, URL, and host attributes
9
+ # 2. Inject the W3C +traceparent+ header for distributed tracing
10
+ # 3. Record the response status code on the span
11
+ # 4. Set span status to "error" for 5xx responses
12
+ #
13
+ # @example Enable the patch
14
+ # JustAnalytics::NetHttpPatch.enable!
15
+ #
16
+ # @example Disable the patch (restore original)
17
+ # JustAnalytics::NetHttpPatch.disable!
18
+ module NetHttpPatch
19
+ # URLs to ignore (JustAnalytics server endpoints, health checks)
20
+ IGNORE_PATHS = %w[/api/ingest/ /health /ready /live /favicon.ico].freeze
21
+
22
+ class << self
23
+ # Enable the Net::HTTP monkey-patch.
24
+ #
25
+ # @return [void]
26
+ def enable!
27
+ return if @enabled
28
+
29
+ @enabled = true
30
+ Net::HTTP.prepend(Instrumentation)
31
+ end
32
+
33
+ # Check if the patch is enabled.
34
+ #
35
+ # @return [Boolean]
36
+ def enabled?
37
+ @enabled || false
38
+ end
39
+
40
+ # Disable the Net::HTTP monkey-patch.
41
+ #
42
+ # Note: Ruby's +prepend+ cannot be truly undone. This flag prevents
43
+ # span creation but the method override remains.
44
+ #
45
+ # @return [void]
46
+ def disable!
47
+ @enabled = false
48
+ end
49
+
50
+ # Check if a URL path should be ignored.
51
+ #
52
+ # @param path [String] URL path
53
+ # @param host [String] hostname
54
+ # @return [Boolean]
55
+ def ignore?(path, host)
56
+ return true if JustAnalytics.initialized? &&
57
+ JustAnalytics.client.config.server_url.include?(host.to_s)
58
+
59
+ IGNORE_PATHS.any? { |p| path.to_s.start_with?(p) }
60
+ end
61
+ end
62
+
63
+ # Module prepended to Net::HTTP to instrument outgoing requests.
64
+ module Instrumentation
65
+ # Wrap Net::HTTP#request to create spans for outgoing HTTP calls.
66
+ #
67
+ # @param req [Net::HTTPRequest] the HTTP request
68
+ # @param body [String, nil] optional request body
69
+ # @return [Net::HTTPResponse] the response
70
+ def request(req, body = nil, &block)
71
+ return super unless NetHttpPatch.enabled? && JustAnalytics.initialized?
72
+
73
+ path = req.path || "/"
74
+ host = address.to_s
75
+
76
+ return super if NetHttpPatch.ignore?(path, host)
77
+
78
+ method = req.method || "GET"
79
+ span_name = "HTTP #{method} #{host}#{path.split('?').first}"
80
+
81
+ # Build attributes
82
+ attributes = {
83
+ "http.method" => method,
84
+ "http.url" => "#{use_ssl? ? 'https' : 'http'}://#{host}:#{port}#{path}",
85
+ "http.host" => host,
86
+ "net.peer.port" => port.to_s
87
+ }
88
+
89
+ span = JustAnalytics::Span.new(
90
+ operation_name: span_name,
91
+ service_name: JustAnalytics.client.config.service_name,
92
+ kind: "client",
93
+ trace_id: Context.current_trace_id || TraceContext.generate_trace_id,
94
+ parent_span_id: Context.current_span&.id,
95
+ attributes: attributes
96
+ )
97
+
98
+ child_ctx = Context.create_child(active_span: span, trace_id: span.trace_id)
99
+
100
+ # Inject traceparent header
101
+ req["traceparent"] = TraceContext.serialize_traceparent(span.trace_id, span.id)
102
+
103
+ response = nil
104
+ Context.with_context(child_ctx) do
105
+ response = super(req, body, &block)
106
+ rescue => e
107
+ span.set_status("error", e.message)
108
+ span.set_attribute("error.type", e.class.name)
109
+ raise
110
+ ensure
111
+ if response
112
+ status_code = response.code.to_i
113
+ span.set_attribute("http.status_code", status_code)
114
+ span.set_status(status_code >= 500 ? "error" : "ok")
115
+ end
116
+
117
+ span.end_span unless span.ended?
118
+ JustAnalytics.client.send(:enqueue_span, span)
119
+ end
120
+
121
+ response
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Rack middleware for Rails applications that creates server spans for
5
+ # incoming HTTP requests and captures unhandled exceptions.
6
+ #
7
+ # @example Manual Rack middleware insertion
8
+ # # config/application.rb
9
+ # config.middleware.use JustAnalytics::RailsMiddleware
10
+ #
11
+ # @example Automatic via Railtie
12
+ # # Just require 'justanalytics' in your Gemfile and the Railtie
13
+ # # auto-inserts the middleware.
14
+ class RailsMiddleware
15
+ # @param app [#call] the downstream Rack application
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ # Process an incoming request: parse traceparent, create server span,
21
+ # capture exceptions, and set response headers.
22
+ #
23
+ # @param env [Hash] Rack environment
24
+ # @return [Array] Rack response tuple [status, headers, body]
25
+ def call(env)
26
+ return @app.call(env) unless JustAnalytics.initialized?
27
+
28
+ request_method = env["REQUEST_METHOD"] || "GET"
29
+ request_path = env["PATH_INFO"] || "/"
30
+ span_name = "#{request_method} #{request_path}"
31
+
32
+ # Parse incoming traceparent header
33
+ traceparent_header = env["HTTP_TRACEPARENT"]
34
+ parent_data = nil
35
+ if traceparent_header
36
+ parent_data = TraceContext.parse_traceparent(traceparent_header)
37
+ end
38
+
39
+ # Determine trace ID and parent span ID
40
+ if parent_data
41
+ trace_id = parent_data.trace_id
42
+ parent_span_id = parent_data.parent_span_id
43
+ else
44
+ trace_id = TraceContext.generate_trace_id
45
+ parent_span_id = nil
46
+ end
47
+
48
+ # Build attributes from request
49
+ attributes = {
50
+ "http.method" => request_method,
51
+ "http.url" => request_path,
52
+ "http.host" => env["HTTP_HOST"] || env["SERVER_NAME"] || "unknown",
53
+ "http.user_agent" => env["HTTP_USER_AGENT"],
54
+ "http.scheme" => env["rack.url_scheme"] || "http"
55
+ }.compact
56
+
57
+ span = Span.new(
58
+ operation_name: span_name,
59
+ service_name: JustAnalytics.client.config.service_name,
60
+ kind: "server",
61
+ trace_id: trace_id,
62
+ parent_span_id: parent_span_id,
63
+ attributes: attributes
64
+ )
65
+
66
+ child_ctx = Context.create_child(active_span: span, trace_id: trace_id)
67
+
68
+ status = nil
69
+ headers = nil
70
+ body = nil
71
+
72
+ Context.with_context(child_ctx) do
73
+ status, headers, body = @app.call(env)
74
+ rescue => e
75
+ span.set_status("error", e.message)
76
+ span.set_attribute("error.type", e.class.name)
77
+ span.set_attribute("error.message", e.message)
78
+
79
+ # Capture the exception via SDK
80
+ JustAnalytics.capture_exception(e, tags: { "http.method" => request_method, "http.url" => request_path })
81
+
82
+ span.end_span
83
+ JustAnalytics.client.send(:enqueue_span, span)
84
+ raise
85
+ end
86
+
87
+ # Record response attributes
88
+ span.set_attribute("http.status_code", status.to_i)
89
+ span.set_status(status.to_i >= 500 ? "error" : "ok")
90
+
91
+ span.end_span
92
+ JustAnalytics.client.send(:enqueue_span, span)
93
+
94
+ # Inject traceparent into response headers for downstream correlation
95
+ if headers.is_a?(Hash)
96
+ headers["traceparent"] = TraceContext.serialize_traceparent(trace_id, span.id)
97
+ end
98
+
99
+ [status, headers, body]
100
+ end
101
+ end
102
+
103
+ # Rails Railtie that auto-configures the middleware in Rails applications.
104
+ #
105
+ # When the +justanalytics+ gem is loaded in a Rails app, this Railtie
106
+ # automatically inserts {RailsMiddleware} into the middleware stack.
107
+ #
108
+ # @example Gemfile
109
+ # gem "justanalytics"
110
+ #
111
+ # # Then in config/initializers/justanalytics.rb:
112
+ # JustAnalytics.init(
113
+ # site_id: ENV["JA_SITE_ID"],
114
+ # api_key: ENV["JA_API_KEY"],
115
+ # service_name: "rails-api"
116
+ # )
117
+ class Railtie < defined?(::Rails::Railtie) ? ::Rails::Railtie : Object
118
+ if defined?(::Rails::Railtie)
119
+ initializer "justanalytics.configure_rails_middleware" do |app|
120
+ app.middleware.insert_before(0, JustAnalytics::RailsMiddleware)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Sidekiq server middleware that traces job execution.
5
+ #
6
+ # Creates a span for each job, propagates trace context from the
7
+ # client middleware, and captures exceptions.
8
+ #
9
+ # @example Enable in Sidekiq config
10
+ # Sidekiq.configure_server do |config|
11
+ # config.server_middleware do |chain|
12
+ # chain.add JustAnalytics::SidekiqServerMiddleware
13
+ # end
14
+ # end
15
+ class SidekiqServerMiddleware
16
+ # @param worker [Object] the Sidekiq worker instance
17
+ # @param job [Hash] the job payload hash
18
+ # @param queue [String] the queue name
19
+ # @yield the job execution block
20
+ def call(worker, job, queue)
21
+ return yield unless JustAnalytics.initialized?
22
+
23
+ job_class = job["class"] || worker.class.name
24
+ span_name = "sidekiq.#{job_class}"
25
+
26
+ # Extract trace context from job payload (injected by client middleware)
27
+ trace_id = job["_ja_trace_id"] || TraceContext.generate_trace_id
28
+ parent_span_id = job["_ja_parent_span_id"]
29
+
30
+ attributes = {
31
+ "sidekiq.queue" => queue,
32
+ "sidekiq.job_class" => job_class,
33
+ "sidekiq.job_id" => job["jid"],
34
+ "sidekiq.retry_count" => job["retry_count"] || 0
35
+ }
36
+
37
+ span = Span.new(
38
+ operation_name: span_name,
39
+ service_name: JustAnalytics.client.config.service_name,
40
+ kind: "consumer",
41
+ trace_id: trace_id,
42
+ parent_span_id: parent_span_id,
43
+ attributes: attributes
44
+ )
45
+
46
+ child_ctx = Context.create_child(active_span: span, trace_id: trace_id)
47
+
48
+ Context.with_context(child_ctx) do
49
+ yield
50
+ rescue => e
51
+ span.set_status("error", e.message)
52
+ span.set_attribute("error.type", e.class.name)
53
+ span.set_attribute("error.message", e.message)
54
+
55
+ JustAnalytics.capture_exception(e, tags: {
56
+ "sidekiq.queue" => queue,
57
+ "sidekiq.job_class" => job_class
58
+ })
59
+
60
+ raise
61
+ ensure
62
+ span.end_span unless span.ended?
63
+ JustAnalytics.client.send(:enqueue_span, span)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Sidekiq client middleware that injects trace context into job payloads.
69
+ #
70
+ # When a job is enqueued from within a traced scope, the current trace ID
71
+ # and span ID are injected into the job hash so the server middleware
72
+ # can continue the trace.
73
+ #
74
+ # @example Enable in Sidekiq config
75
+ # Sidekiq.configure_client do |config|
76
+ # config.client_middleware do |chain|
77
+ # chain.add JustAnalytics::SidekiqClientMiddleware
78
+ # end
79
+ # end
80
+ class SidekiqClientMiddleware
81
+ # @param worker_class [String, Class] the worker class name
82
+ # @param job [Hash] the job payload hash
83
+ # @param queue [String] the queue name
84
+ # @param redis_pool [Object] Sidekiq Redis pool
85
+ # @yield continues the middleware chain
86
+ def call(worker_class, job, queue, redis_pool)
87
+ if JustAnalytics.initialized?
88
+ active_span = Context.current_span
89
+ if active_span
90
+ job["_ja_trace_id"] = active_span.trace_id
91
+ job["_ja_parent_span_id"] = active_span.id
92
+ end
93
+ end
94
+
95
+ yield
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Ruby Logger-compatible structured logger that ships log entries to JustAnalytics.
5
+ #
6
+ # Log entries are buffered in the Transport and flushed periodically to
7
+ # +POST /api/ingest/logs+. Trace ID and span ID are automatically attached
8
+ # from the thread-local context when logging inside a traced scope.
9
+ #
10
+ # Implements the same interface as Ruby's +::Logger+ for drop-in compatibility:
11
+ # +debug+, +info+, +warn+, +error+, +fatal+, +unknown+.
12
+ #
13
+ # @example
14
+ # logger = JustAnalytics.logger
15
+ # logger.info("User logged in", user_id: "u123")
16
+ # logger.error("Payment failed", order_id: "o456", reason: "declined")
17
+ class Logger
18
+ # Map Ruby Logger severity constants to JA log levels.
19
+ SEVERITY_MAP = {
20
+ 0 => "debug", # Logger::DEBUG
21
+ 1 => "info", # Logger::INFO
22
+ 2 => "warn", # Logger::WARN
23
+ 3 => "error", # Logger::ERROR
24
+ 4 => "fatal", # Logger::FATAL
25
+ 5 => "fatal" # Logger::UNKNOWN
26
+ }.freeze
27
+
28
+ VALID_LEVELS = %w[debug info warn error fatal].freeze
29
+
30
+ # @param service_name [String] service name from SDK config
31
+ # @param transport [Transport, nil] transport for enqueuing logs
32
+ # @param enabled [Boolean] whether logging is active
33
+ # @param environment [String, nil] deployment environment
34
+ # @param release [String, nil] release version
35
+ # @param debug [Boolean] whether to echo logs to stderr
36
+ def initialize(service_name:, transport:, enabled: true, environment: nil, release: nil, debug: false)
37
+ @service_name = service_name
38
+ @transport = transport
39
+ @enabled = enabled
40
+ @environment = environment
41
+ @release = release
42
+ @debug = debug
43
+ end
44
+
45
+ # Log a debug-level message.
46
+ #
47
+ # @param message [String] log message
48
+ # @param attributes [Hash] optional metadata
49
+ # @return [void]
50
+ def debug(message = nil, **attributes, &block)
51
+ log_entry("debug", message, attributes, &block)
52
+ end
53
+
54
+ # Log an info-level message.
55
+ #
56
+ # @param message [String] log message
57
+ # @param attributes [Hash] optional metadata
58
+ # @return [void]
59
+ def info(message = nil, **attributes, &block)
60
+ log_entry("info", message, attributes, &block)
61
+ end
62
+
63
+ # Log a warn-level message.
64
+ #
65
+ # @param message [String] log message
66
+ # @param attributes [Hash] optional metadata
67
+ # @return [void]
68
+ def warn(message = nil, **attributes, &block)
69
+ log_entry("warn", message, attributes, &block)
70
+ end
71
+
72
+ # Log an error-level message.
73
+ #
74
+ # @param message [String] log message
75
+ # @param attributes [Hash] optional metadata
76
+ # @return [void]
77
+ def error(message = nil, **attributes, &block)
78
+ log_entry("error", message, attributes, &block)
79
+ end
80
+
81
+ # Log a fatal-level message.
82
+ #
83
+ # @param message [String] log message
84
+ # @param attributes [Hash] optional metadata
85
+ # @return [void]
86
+ def fatal(message = nil, **attributes, &block)
87
+ log_entry("fatal", message, attributes, &block)
88
+ end
89
+
90
+ # Log at a specified level (Ruby Logger compatible).
91
+ #
92
+ # @param level [String, Integer] severity level
93
+ # @param message [String] log message
94
+ # @param attributes [Hash] optional metadata
95
+ # @return [void]
96
+ def log(level, message = nil, **attributes, &block)
97
+ resolved_level = resolve_level(level)
98
+ log_entry(resolved_level, message, attributes, &block)
99
+ end
100
+
101
+ # Alias for compatibility with Ruby Logger.
102
+ alias_method :add, :log
103
+
104
+ private
105
+
106
+ # Build and enqueue a log entry payload.
107
+ def log_entry(level, message, attributes, &block)
108
+ return unless @enabled && @transport
109
+
110
+ message = block.call if message.nil? && block
111
+ return if message.nil? || message.to_s.empty?
112
+
113
+ active_span = Context.current_span
114
+ trace_id = Context.current_trace_id
115
+ span_id = active_span&.id
116
+
117
+ enriched = attributes.transform_keys(&:to_s)
118
+ enriched["environment"] = @environment if @environment
119
+ enriched["release"] = @release if @release
120
+
121
+ payload = {
122
+ "level" => level,
123
+ "message" => message.to_s,
124
+ "serviceName" => @service_name,
125
+ "timestamp" => Time.now.utc.iso8601(3),
126
+ "traceId" => trace_id,
127
+ "spanId" => span_id,
128
+ "attributes" => enriched
129
+ }
130
+
131
+ $stderr.puts "[JustAnalytics Logger] #{level.upcase} \"#{message}\" traceId=#{trace_id || 'none'}" if @debug
132
+
133
+ @transport.enqueue_log(payload)
134
+ rescue => e
135
+ $stderr.puts "[JustAnalytics Logger] Internal error: #{e.message}" if @debug
136
+ end
137
+
138
+ def resolve_level(level)
139
+ case level
140
+ when Integer
141
+ SEVERITY_MAP.fetch(level, "info")
142
+ when String, Symbol
143
+ l = level.to_s.downcase
144
+ VALID_LEVELS.include?(l) ? l : "info"
145
+ else
146
+ "info"
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JustAnalytics
4
+ # Span represents a single timed operation within a distributed trace.
5
+ #
6
+ # Spans are created by {JustAnalytics.start_span} and automatically ended
7
+ # when the block completes. The {#to_h} method serializes the span to the
8
+ # exact format expected by +POST /api/ingest/spans+.
9
+ #
10
+ # @example
11
+ # JustAnalytics.start_span("process-order", op: "task") do |span|
12
+ # span.set_attribute("order.id", "12345")
13
+ # span.add_event("payment.processed")
14
+ # end
15
+ class Span
16
+ # Valid span kinds (OpenTelemetry-compatible).
17
+ VALID_KINDS = %w[client server producer consumer internal].freeze
18
+
19
+ # Valid span statuses.
20
+ VALID_STATUSES = %w[ok error unset].freeze
21
+
22
+ # @return [String] 16-character hex span ID
23
+ attr_reader :id
24
+
25
+ # @return [String] 32-character hex trace ID
26
+ attr_reader :trace_id
27
+
28
+ # @return [String, nil] parent span ID or nil for root spans
29
+ attr_reader :parent_span_id
30
+
31
+ # @return [String] operation name
32
+ attr_reader :operation_name
33
+
34
+ # @return [String] service name
35
+ attr_reader :service_name
36
+
37
+ # @return [String] span kind
38
+ attr_reader :kind
39
+
40
+ # @return [Time] wall-clock start time
41
+ attr_reader :start_time
42
+
43
+ # @return [Boolean] whether the span has ended
44
+ attr_reader :ended
45
+ alias_method :ended?, :ended
46
+
47
+ # Create a new Span.
48
+ #
49
+ # @param operation_name [String] operation/span name
50
+ # @param service_name [String] service that produced this span
51
+ # @param kind [String] span kind (default: "internal")
52
+ # @param trace_id [String] 32-char hex trace ID
53
+ # @param parent_span_id [String, nil] parent span ID or nil
54
+ # @param attributes [Hash] initial attributes
55
+ def initialize(operation_name:, service_name:, trace_id:, parent_span_id: nil, kind: "internal", attributes: {})
56
+ @id = TraceContext.generate_span_id
57
+ @trace_id = trace_id
58
+ @parent_span_id = parent_span_id
59
+ @operation_name = operation_name
60
+ @service_name = service_name
61
+ @kind = VALID_KINDS.include?(kind) ? kind : "internal"
62
+ @start_time = Time.now.utc
63
+ @start_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
64
+ @end_time = nil
65
+ @duration = nil
66
+ @status = "unset"
67
+ @status_message = nil
68
+ @attributes = attributes.dup
69
+ @events = []
70
+ @ended = false
71
+ end
72
+
73
+ # Set a single attribute on the span.
74
+ #
75
+ # @param key [String] attribute key
76
+ # @param value [Object] attribute value
77
+ # @return [self]
78
+ def set_attribute(key, value)
79
+ return self if @ended
80
+
81
+ @attributes[key.to_s] = value
82
+ self
83
+ end
84
+
85
+ # Set multiple attributes on the span.
86
+ #
87
+ # @param attrs [Hash] key-value pairs to merge
88
+ # @return [self]
89
+ def set_attributes(attrs)
90
+ return self if @ended
91
+
92
+ attrs.each { |k, v| @attributes[k.to_s] = v }
93
+ self
94
+ end
95
+
96
+ # Set the span status.
97
+ #
98
+ # @param status [String] one of "ok", "error", "unset"
99
+ # @param message [String, nil] optional status message
100
+ # @return [self]
101
+ def set_status(status, message = nil)
102
+ return self if @ended
103
+
104
+ @status = VALID_STATUSES.include?(status) ? status : "unset"
105
+ @status_message = message
106
+ self
107
+ end
108
+
109
+ # Add a timestamped event to the span.
110
+ #
111
+ # @param name [String] event name
112
+ # @param attributes [Hash] optional event attributes
113
+ # @return [self]
114
+ def add_event(name, attributes: {})
115
+ return self if @ended
116
+
117
+ event = { "name" => name, "timestamp" => Time.now.utc.iso8601(3) }
118
+ event["attributes"] = attributes unless attributes.empty?
119
+ @events << event
120
+ self
121
+ end
122
+
123
+ # Update the operation name after creation.
124
+ #
125
+ # @param name [String] new operation name
126
+ # @return [self]
127
+ def update_operation_name(name)
128
+ return self if @ended
129
+
130
+ @operation_name = name
131
+ self
132
+ end
133
+
134
+ # Mark the span as ended.
135
+ #
136
+ # Calculates duration using monotonic clock for precision.
137
+ # Calling +end_span+ multiple times is a no-op (idempotent).
138
+ #
139
+ # @return [void]
140
+ def end_span
141
+ return if @ended
142
+
143
+ @ended = true
144
+ @end_time = Time.now.utc
145
+ end_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
146
+ @duration = (end_monotonic - @start_monotonic).round
147
+ end
148
+
149
+ # Serialize the span to a Hash matching the /api/ingest/spans schema.
150
+ #
151
+ # @return [Hash]
152
+ def to_h
153
+ {
154
+ "id" => @id,
155
+ "traceId" => @trace_id,
156
+ "parentSpanId" => @parent_span_id,
157
+ "operationName" => @operation_name,
158
+ "serviceName" => @service_name,
159
+ "kind" => @kind,
160
+ "startTime" => @start_time.iso8601(3),
161
+ "endTime" => @end_time&.iso8601(3),
162
+ "duration" => @duration,
163
+ "status" => @status,
164
+ "statusMessage" => @status_message,
165
+ "attributes" => @attributes.dup,
166
+ "events" => @events.dup
167
+ }
168
+ end
169
+ end
170
+ end