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 +7 -0
- data/README.md +103 -0
- data/lib/justanalytics/client.rb +374 -0
- data/lib/justanalytics/configuration.rb +71 -0
- data/lib/justanalytics/context.rb +122 -0
- data/lib/justanalytics/integrations/net_http.rb +125 -0
- data/lib/justanalytics/integrations/rails.rb +124 -0
- data/lib/justanalytics/integrations/sidekiq.rb +98 -0
- data/lib/justanalytics/logger.rb +150 -0
- data/lib/justanalytics/span.rb +170 -0
- data/lib/justanalytics/trace_context.rb +85 -0
- data/lib/justanalytics/transport.rb +204 -0
- data/lib/justanalytics/version.rb +6 -0
- data/lib/justanalytics.rb +208 -0
- metadata +117 -0
|
@@ -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
|