miniapm 1.0.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/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +174 -0
- data/lib/generators/miniapm/install_generator.rb +27 -0
- data/lib/generators/miniapm/templates/README +19 -0
- data/lib/generators/miniapm/templates/initializer.rb +60 -0
- data/lib/miniapm/configuration.rb +176 -0
- data/lib/miniapm/context.rb +138 -0
- data/lib/miniapm/error_event.rb +130 -0
- data/lib/miniapm/exporters/errors.rb +67 -0
- data/lib/miniapm/exporters/otlp.rb +90 -0
- data/lib/miniapm/instrumentations/activejob.rb +271 -0
- data/lib/miniapm/instrumentations/activerecord.rb +123 -0
- data/lib/miniapm/instrumentations/base.rb +61 -0
- data/lib/miniapm/instrumentations/cache.rb +85 -0
- data/lib/miniapm/instrumentations/http/faraday.rb +112 -0
- data/lib/miniapm/instrumentations/http/httparty.rb +84 -0
- data/lib/miniapm/instrumentations/http/net_http.rb +99 -0
- data/lib/miniapm/instrumentations/rails/controller.rb +129 -0
- data/lib/miniapm/instrumentations/rails/railtie.rb +42 -0
- data/lib/miniapm/instrumentations/redis/redis.rb +135 -0
- data/lib/miniapm/instrumentations/redis/redis_client.rb +116 -0
- data/lib/miniapm/instrumentations/registry.rb +90 -0
- data/lib/miniapm/instrumentations/search/elasticsearch.rb +121 -0
- data/lib/miniapm/instrumentations/search/opensearch.rb +120 -0
- data/lib/miniapm/instrumentations/search/searchkick.rb +119 -0
- data/lib/miniapm/instrumentations/sidekiq.rb +185 -0
- data/lib/miniapm/middleware/error_handler.rb +120 -0
- data/lib/miniapm/middleware/rack.rb +103 -0
- data/lib/miniapm/span.rb +289 -0
- data/lib/miniapm/testing.rb +209 -0
- data/lib/miniapm/trace.rb +26 -0
- data/lib/miniapm/transport/batch_sender.rb +345 -0
- data/lib/miniapm/transport/http.rb +45 -0
- data/lib/miniapm/version.rb +5 -0
- data/lib/miniapm.rb +184 -0
- metadata +183 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MiniAPM
|
|
4
|
+
module Instrumentations
|
|
5
|
+
class Sidekiq
|
|
6
|
+
# Keys used to store trace context in job payload
|
|
7
|
+
TRACE_ID_KEY = "_miniapm_trace_id"
|
|
8
|
+
PARENT_SPAN_ID_KEY = "_miniapm_parent_span_id"
|
|
9
|
+
SAMPLED_KEY = "_miniapm_sampled"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def install!
|
|
13
|
+
return if @installed
|
|
14
|
+
return unless defined?(::Sidekiq)
|
|
15
|
+
|
|
16
|
+
@installed = true
|
|
17
|
+
|
|
18
|
+
::Sidekiq.configure_server do |config|
|
|
19
|
+
config.server_middleware do |chain|
|
|
20
|
+
chain.add ServerMiddleware
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Also configure client for enqueue tracking and trace propagation
|
|
25
|
+
::Sidekiq.configure_client do |config|
|
|
26
|
+
config.client_middleware do |chain|
|
|
27
|
+
chain.add ClientMiddleware
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# For Sidekiq server process, also add client middleware
|
|
32
|
+
# (for jobs that enqueue other jobs)
|
|
33
|
+
::Sidekiq.configure_server do |config|
|
|
34
|
+
config.client_middleware do |chain|
|
|
35
|
+
chain.add ClientMiddleware
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
MiniAPM.logger.debug { "MiniAPM: Sidekiq instrumentation installed" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def installed?
|
|
43
|
+
@installed || false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class ServerMiddleware
|
|
48
|
+
def call(worker, job, queue)
|
|
49
|
+
return yield unless MiniAPM.enabled?
|
|
50
|
+
|
|
51
|
+
# Extract trace context from job if present (propagated from enqueue)
|
|
52
|
+
trace_id = job[TRACE_ID_KEY]
|
|
53
|
+
parent_span_id = job[PARENT_SPAN_ID_KEY]
|
|
54
|
+
sampled = job.key?(SAMPLED_KEY) ? job[SAMPLED_KEY] : nil
|
|
55
|
+
|
|
56
|
+
# Create trace with propagated context or new trace
|
|
57
|
+
trace = Trace.new(
|
|
58
|
+
trace_id: trace_id,
|
|
59
|
+
sampled: sampled
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Skip if not sampled
|
|
63
|
+
return yield unless trace.sampled?
|
|
64
|
+
|
|
65
|
+
Context.current_trace = trace
|
|
66
|
+
|
|
67
|
+
worker_class = worker.class.name
|
|
68
|
+
job_id = job["jid"]
|
|
69
|
+
|
|
70
|
+
span = Span.new(
|
|
71
|
+
name: "#{worker_class}.perform",
|
|
72
|
+
category: :job,
|
|
73
|
+
trace_id: trace.trace_id,
|
|
74
|
+
parent_span_id: parent_span_id, # Link to parent if propagated
|
|
75
|
+
attributes: build_attributes(worker_class, job, queue)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Add enqueued_at if present
|
|
79
|
+
if job["enqueued_at"]
|
|
80
|
+
span.add_attribute("sidekiq.enqueued_at", job["enqueued_at"])
|
|
81
|
+
# Calculate queue latency
|
|
82
|
+
latency = Time.now.to_f - job["enqueued_at"]
|
|
83
|
+
span.add_attribute("sidekiq.queue_latency_ms", (latency * 1000).round(2))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Add wrapped class for ActiveJob
|
|
87
|
+
if job["wrapped"]
|
|
88
|
+
span.add_attribute("sidekiq.wrapped", job["wrapped"])
|
|
89
|
+
span.add_attribute("job.class", job["wrapped"])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Context.with_span(span) do
|
|
93
|
+
begin
|
|
94
|
+
yield
|
|
95
|
+
span.set_ok
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
span.record_exception(e)
|
|
98
|
+
MiniAPM.record_error(e, context: {
|
|
99
|
+
job_class: worker_class,
|
|
100
|
+
job_id: job_id,
|
|
101
|
+
queue: queue
|
|
102
|
+
})
|
|
103
|
+
raise
|
|
104
|
+
ensure
|
|
105
|
+
span.finish
|
|
106
|
+
MiniAPM.record_span(span)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
ensure
|
|
110
|
+
Context.clear!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def build_attributes(worker_class, job, queue)
|
|
116
|
+
{
|
|
117
|
+
"messaging.system" => "sidekiq",
|
|
118
|
+
"messaging.destination.name" => queue,
|
|
119
|
+
"messaging.operation" => "process",
|
|
120
|
+
"sidekiq.job_id" => job["jid"],
|
|
121
|
+
"sidekiq.queue" => queue,
|
|
122
|
+
"sidekiq.retry_count" => job["retry_count"] || 0,
|
|
123
|
+
"sidekiq.created_at" => job["created_at"],
|
|
124
|
+
"job.class" => worker_class
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class ClientMiddleware
|
|
130
|
+
def call(worker_class, job, queue, redis_pool)
|
|
131
|
+
# Always propagate trace context if available
|
|
132
|
+
inject_trace_context(job)
|
|
133
|
+
|
|
134
|
+
return yield unless MiniAPM.enabled?
|
|
135
|
+
return yield unless Context.current_trace
|
|
136
|
+
|
|
137
|
+
# Create span for enqueue operation
|
|
138
|
+
worker_name = worker_class.is_a?(Class) ? worker_class.name : worker_class.to_s
|
|
139
|
+
|
|
140
|
+
span = Span.new(
|
|
141
|
+
name: "#{worker_name}.enqueue",
|
|
142
|
+
category: :job,
|
|
143
|
+
trace_id: Context.current_trace_id,
|
|
144
|
+
parent_span_id: Context.current_span&.span_id,
|
|
145
|
+
attributes: {
|
|
146
|
+
"messaging.system" => "sidekiq",
|
|
147
|
+
"messaging.destination.name" => queue,
|
|
148
|
+
"messaging.operation" => "send",
|
|
149
|
+
"sidekiq.job_id" => job["jid"],
|
|
150
|
+
"job.class" => worker_name
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
Context.with_span(span) do
|
|
155
|
+
begin
|
|
156
|
+
result = yield
|
|
157
|
+
span.set_ok
|
|
158
|
+
result
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
span.record_exception(e)
|
|
161
|
+
raise
|
|
162
|
+
ensure
|
|
163
|
+
span.finish
|
|
164
|
+
MiniAPM.record_span(span)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def inject_trace_context(job)
|
|
172
|
+
return unless Context.current_trace
|
|
173
|
+
|
|
174
|
+
# Store trace context in job payload for propagation
|
|
175
|
+
job[TRACE_ID_KEY] = Context.current_trace_id
|
|
176
|
+
job[PARENT_SPAN_ID_KEY] = Context.current_span&.span_id
|
|
177
|
+
job[SAMPLED_KEY] = Context.current_trace.sampled?
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Auto-install when loaded
|
|
185
|
+
MiniAPM::Instrumentations::Sidekiq.install!
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MiniAPM
|
|
4
|
+
module Middleware
|
|
5
|
+
class ErrorHandler
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
@app.call(env)
|
|
12
|
+
rescue StandardError => e
|
|
13
|
+
report_error(e, env)
|
|
14
|
+
raise
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def report_error(exception, env)
|
|
20
|
+
return unless MiniAPM.enabled?
|
|
21
|
+
return if ignored_exception?(exception)
|
|
22
|
+
|
|
23
|
+
MiniAPM.record_error(exception, context: build_context(env))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ignored_exception?(exception)
|
|
27
|
+
MiniAPM.configuration.ignored_exceptions.include?(exception.class.name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_context(env)
|
|
31
|
+
request = ::Rack::Request.new(env) if defined?(::Rack::Request)
|
|
32
|
+
|
|
33
|
+
context = {
|
|
34
|
+
request_id: env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"],
|
|
35
|
+
user_id: extract_user_id(env),
|
|
36
|
+
url: build_url(env),
|
|
37
|
+
method: env["REQUEST_METHOD"]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Add filtered params
|
|
41
|
+
if request
|
|
42
|
+
context[:params] = filter_params(request.params)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_user_id(env)
|
|
49
|
+
# Try common patterns for user identification
|
|
50
|
+
# Warden (Devise)
|
|
51
|
+
if env["warden"]
|
|
52
|
+
user = env["warden"].user rescue nil
|
|
53
|
+
return user.id.to_s if user&.respond_to?(:id)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Session-based
|
|
57
|
+
if env["rack.session"]
|
|
58
|
+
session = env["rack.session"]
|
|
59
|
+
return session["user_id"].to_s if session["user_id"]
|
|
60
|
+
return session["current_user_id"].to_s if session["current_user_id"]
|
|
61
|
+
|
|
62
|
+
# Devise session format
|
|
63
|
+
warden_key = session["warden.user.user.key"]
|
|
64
|
+
if warden_key.is_a?(Array) && warden_key.first.is_a?(Array)
|
|
65
|
+
return warden_key.first.first.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
nil
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def filter_params(params)
|
|
75
|
+
return nil unless params.is_a?(Hash)
|
|
76
|
+
return nil if params.empty?
|
|
77
|
+
|
|
78
|
+
filter_keys = MiniAPM.configuration.filter_parameters
|
|
79
|
+
|
|
80
|
+
deep_filter(params, filter_keys)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def deep_filter(hash, filter_keys)
|
|
84
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
85
|
+
if filter_keys.any? { |f| key_matches?(key, f) }
|
|
86
|
+
result[key] = "[FILTERED]"
|
|
87
|
+
elsif value.is_a?(Hash)
|
|
88
|
+
result[key] = deep_filter(value, filter_keys)
|
|
89
|
+
elsif value.is_a?(Array)
|
|
90
|
+
result[key] = value.map { |v| v.is_a?(Hash) ? deep_filter(v, filter_keys) : v }
|
|
91
|
+
else
|
|
92
|
+
# Truncate long values
|
|
93
|
+
result[key] = truncate_value(value)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def key_matches?(key, filter)
|
|
99
|
+
case filter
|
|
100
|
+
when Regexp
|
|
101
|
+
key.to_s.match?(filter)
|
|
102
|
+
else
|
|
103
|
+
key.to_s.downcase.include?(filter.to_s.downcase)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def truncate_value(value)
|
|
108
|
+
str = value.to_s
|
|
109
|
+
str.length > 500 ? str[0, 500] + "..." : str
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_url(env)
|
|
113
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
114
|
+
host = env["HTTP_HOST"] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"
|
|
115
|
+
path = env["PATH_INFO"]
|
|
116
|
+
"#{scheme}://#{host}#{path}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MiniAPM
|
|
4
|
+
module Middleware
|
|
5
|
+
class Rack
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
return @app.call(env) unless MiniAPM.enabled?
|
|
12
|
+
|
|
13
|
+
# Extract incoming trace context from headers
|
|
14
|
+
incoming = Context.extract_from_headers(env)
|
|
15
|
+
|
|
16
|
+
# Create trace (with incoming context if present)
|
|
17
|
+
trace = Trace.new(
|
|
18
|
+
trace_id: incoming&.dig(:trace_id),
|
|
19
|
+
sampled: incoming&.dig(:sampled)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return @app.call(env) unless trace.sampled?
|
|
23
|
+
|
|
24
|
+
Context.with_trace(trace) do
|
|
25
|
+
request = ::Rack::Request.new(env) if defined?(::Rack::Request)
|
|
26
|
+
request_method = env["REQUEST_METHOD"]
|
|
27
|
+
request_path = env["PATH_INFO"]
|
|
28
|
+
request_url = build_url(env)
|
|
29
|
+
|
|
30
|
+
span = Span.new(
|
|
31
|
+
name: "#{request_method} #{request_path}",
|
|
32
|
+
category: :http_server,
|
|
33
|
+
trace_id: trace.trace_id,
|
|
34
|
+
attributes: build_attributes(env, request)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Context.with_span(span) do
|
|
38
|
+
begin
|
|
39
|
+
status, headers, body = @app.call(env)
|
|
40
|
+
|
|
41
|
+
span.add_attribute("http.status_code", status)
|
|
42
|
+
span.set_error("HTTP #{status}") if status >= 500
|
|
43
|
+
|
|
44
|
+
[status, headers, body]
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
span.record_exception(e)
|
|
47
|
+
raise
|
|
48
|
+
ensure
|
|
49
|
+
span.finish
|
|
50
|
+
MiniAPM.record_span(span)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_attributes(env, request)
|
|
59
|
+
attrs = {
|
|
60
|
+
"http.method" => env["REQUEST_METHOD"],
|
|
61
|
+
"http.url" => build_url(env),
|
|
62
|
+
"http.scheme" => env["rack.url_scheme"] || "http",
|
|
63
|
+
"http.host" => env["HTTP_HOST"] || env["SERVER_NAME"],
|
|
64
|
+
"http.target" => env["PATH_INFO"]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Add query string if present (without values for privacy)
|
|
68
|
+
if env["QUERY_STRING"] && !env["QUERY_STRING"].empty?
|
|
69
|
+
attrs["http.query_params"] = env["QUERY_STRING"].split("&").map { |p| p.split("=").first }.join(",")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add user agent if present
|
|
73
|
+
if env["HTTP_USER_AGENT"]
|
|
74
|
+
attrs["http.user_agent"] = env["HTTP_USER_AGENT"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Add request ID if present (Rails sets this)
|
|
78
|
+
request_id = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
|
|
79
|
+
if request_id
|
|
80
|
+
attrs["http.request_id"] = request_id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Add client IP
|
|
84
|
+
client_ip = env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip ||
|
|
85
|
+
env["HTTP_X_REAL_IP"] ||
|
|
86
|
+
env["REMOTE_ADDR"]
|
|
87
|
+
if client_ip
|
|
88
|
+
attrs["http.client_ip"] = client_ip
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
attrs
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_url(env)
|
|
95
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
96
|
+
host = env["HTTP_HOST"] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"
|
|
97
|
+
path = env["PATH_INFO"]
|
|
98
|
+
# Omit query string from URL for privacy
|
|
99
|
+
"#{scheme}://#{host}#{path}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/miniapm/span.rb
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module MiniAPM
|
|
6
|
+
class Span
|
|
7
|
+
# OTLP SpanKind values
|
|
8
|
+
KINDS = {
|
|
9
|
+
unspecified: 0,
|
|
10
|
+
internal: 1,
|
|
11
|
+
server: 2,
|
|
12
|
+
client: 3,
|
|
13
|
+
producer: 4,
|
|
14
|
+
consumer: 5
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# MiniAPM categories mapped to OTLP kinds
|
|
18
|
+
CATEGORY_KINDS = {
|
|
19
|
+
http_server: :server,
|
|
20
|
+
http_client: :client,
|
|
21
|
+
db: :client,
|
|
22
|
+
view: :internal,
|
|
23
|
+
search: :client,
|
|
24
|
+
job: :consumer,
|
|
25
|
+
rake: :internal,
|
|
26
|
+
cache: :internal,
|
|
27
|
+
internal: :internal
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Status codes
|
|
31
|
+
STATUS_UNSET = 0
|
|
32
|
+
STATUS_OK = 1
|
|
33
|
+
STATUS_ERROR = 2
|
|
34
|
+
|
|
35
|
+
# Limits to prevent memory issues
|
|
36
|
+
MAX_NAME_LENGTH = 256
|
|
37
|
+
MAX_ATTRIBUTE_KEY_LENGTH = 128
|
|
38
|
+
MAX_ATTRIBUTE_VALUE_LENGTH = 4096
|
|
39
|
+
MAX_ATTRIBUTES = 128
|
|
40
|
+
MAX_EVENTS = 128
|
|
41
|
+
MAX_EVENT_ATTRIBUTES = 32
|
|
42
|
+
TRACE_ID_LENGTH = 32
|
|
43
|
+
SPAN_ID_LENGTH = 16
|
|
44
|
+
|
|
45
|
+
attr_reader :trace_id, :span_id, :parent_span_id
|
|
46
|
+
attr_reader :name, :category, :kind
|
|
47
|
+
attr_reader :start_time, :end_time
|
|
48
|
+
attr_reader :attributes, :events
|
|
49
|
+
attr_accessor :status_code, :status_message
|
|
50
|
+
|
|
51
|
+
def initialize(
|
|
52
|
+
name:,
|
|
53
|
+
category: :internal,
|
|
54
|
+
trace_id: nil,
|
|
55
|
+
parent_span_id: nil,
|
|
56
|
+
attributes: {}
|
|
57
|
+
)
|
|
58
|
+
@name = validate_name(name)
|
|
59
|
+
@category = validate_category(category)
|
|
60
|
+
@kind = KINDS[CATEGORY_KINDS[@category] || :internal]
|
|
61
|
+
|
|
62
|
+
@trace_id = validate_trace_id(trace_id) || generate_trace_id
|
|
63
|
+
@span_id = generate_span_id
|
|
64
|
+
@parent_span_id = validate_span_id(parent_span_id)
|
|
65
|
+
|
|
66
|
+
@start_time = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
67
|
+
@end_time = nil
|
|
68
|
+
|
|
69
|
+
@attributes = {}
|
|
70
|
+
@events = []
|
|
71
|
+
|
|
72
|
+
@status_code = STATUS_UNSET
|
|
73
|
+
@status_message = nil
|
|
74
|
+
|
|
75
|
+
# Add initial attributes with validation
|
|
76
|
+
attributes.each { |k, v| add_attribute(k, v) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.new_root(name, category: :http_server, attributes: {})
|
|
80
|
+
trace = Trace.new
|
|
81
|
+
Context.current_trace = trace
|
|
82
|
+
|
|
83
|
+
new(
|
|
84
|
+
name: name,
|
|
85
|
+
category: category,
|
|
86
|
+
trace_id: trace.trace_id,
|
|
87
|
+
attributes: attributes
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def create_child(name, category: :internal, attributes: {})
|
|
92
|
+
self.class.new(
|
|
93
|
+
name: name,
|
|
94
|
+
category: category,
|
|
95
|
+
trace_id: @trace_id,
|
|
96
|
+
parent_span_id: @span_id,
|
|
97
|
+
attributes: attributes
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def finish
|
|
102
|
+
@end_time = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def duration_ns
|
|
106
|
+
(@end_time || Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)) - @start_time
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def duration_ms
|
|
110
|
+
duration_ns / 1_000_000.0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def add_attribute(key, value)
|
|
114
|
+
return if @attributes.size >= MAX_ATTRIBUTES
|
|
115
|
+
|
|
116
|
+
key = truncate(key.to_s, MAX_ATTRIBUTE_KEY_LENGTH)
|
|
117
|
+
value = sanitize_attribute_value(value)
|
|
118
|
+
|
|
119
|
+
@attributes[key] = value
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def add_event(name, attributes: {})
|
|
123
|
+
return if @events.size >= MAX_EVENTS
|
|
124
|
+
|
|
125
|
+
event_attrs = {}
|
|
126
|
+
attributes.first(MAX_EVENT_ATTRIBUTES).each do |k, v|
|
|
127
|
+
key = truncate(k.to_s, MAX_ATTRIBUTE_KEY_LENGTH)
|
|
128
|
+
event_attrs[key] = sanitize_attribute_value(v)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@events << {
|
|
132
|
+
name: truncate(name, MAX_NAME_LENGTH),
|
|
133
|
+
time_unix_nano: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond),
|
|
134
|
+
attributes: event_attrs
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def record_exception(exception)
|
|
139
|
+
@status_code = STATUS_ERROR
|
|
140
|
+
@status_message = truncate(exception.message, MAX_ATTRIBUTE_VALUE_LENGTH)
|
|
141
|
+
|
|
142
|
+
add_event("exception", attributes: {
|
|
143
|
+
"exception.type" => exception.class.name,
|
|
144
|
+
"exception.message" => exception.message,
|
|
145
|
+
"exception.stacktrace" => exception.backtrace&.first(30)&.join("\n")
|
|
146
|
+
})
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def set_error(message = nil)
|
|
150
|
+
@status_code = STATUS_ERROR
|
|
151
|
+
@status_message = message ? truncate(message, MAX_ATTRIBUTE_VALUE_LENGTH) : nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def set_ok
|
|
155
|
+
@status_code = STATUS_OK
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def root?
|
|
159
|
+
@parent_span_id.nil?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def error?
|
|
163
|
+
@status_code == STATUS_ERROR
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Convert to OTLP JSON format
|
|
167
|
+
def to_otlp
|
|
168
|
+
span_data = {
|
|
169
|
+
"traceId" => @trace_id,
|
|
170
|
+
"spanId" => @span_id,
|
|
171
|
+
"name" => @name,
|
|
172
|
+
"kind" => @kind,
|
|
173
|
+
"startTimeUnixNano" => @start_time.to_s,
|
|
174
|
+
"endTimeUnixNano" => (@end_time || @start_time).to_s,
|
|
175
|
+
"attributes" => attributes_to_otlp,
|
|
176
|
+
"status" => build_status
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
span_data["parentSpanId"] = @parent_span_id if @parent_span_id
|
|
180
|
+
span_data["events"] = events_to_otlp if @events.any?
|
|
181
|
+
|
|
182
|
+
span_data
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def validate_name(name)
|
|
188
|
+
truncate(name.to_s, MAX_NAME_LENGTH)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def validate_category(category)
|
|
192
|
+
cat = category.to_sym
|
|
193
|
+
CATEGORY_KINDS.key?(cat) ? cat : :internal
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def validate_trace_id(trace_id)
|
|
197
|
+
return nil if trace_id.nil?
|
|
198
|
+
|
|
199
|
+
str = trace_id.to_s.downcase
|
|
200
|
+
return nil unless str.match?(/\A[0-9a-f]{32}\z/)
|
|
201
|
+
|
|
202
|
+
str
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_span_id(span_id)
|
|
206
|
+
return nil if span_id.nil?
|
|
207
|
+
|
|
208
|
+
str = span_id.to_s.downcase
|
|
209
|
+
return nil unless str.match?(/\A[0-9a-f]{16}\z/)
|
|
210
|
+
|
|
211
|
+
str
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def generate_trace_id
|
|
215
|
+
SecureRandom.hex(16)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def generate_span_id
|
|
219
|
+
SecureRandom.hex(8)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def truncate(string, max_length)
|
|
223
|
+
return "" if string.nil?
|
|
224
|
+
|
|
225
|
+
str = string.to_s
|
|
226
|
+
str.length > max_length ? str[0, max_length] : str
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def sanitize_attribute_value(value)
|
|
230
|
+
case value
|
|
231
|
+
when String
|
|
232
|
+
truncate(value, MAX_ATTRIBUTE_VALUE_LENGTH)
|
|
233
|
+
when Integer, Float, TrueClass, FalseClass, NilClass
|
|
234
|
+
value
|
|
235
|
+
when Array
|
|
236
|
+
# Limit array size and sanitize each element
|
|
237
|
+
value.first(32).map { |v| sanitize_attribute_value(v) }
|
|
238
|
+
when Hash
|
|
239
|
+
# Convert hash to string representation
|
|
240
|
+
truncate(value.to_s, MAX_ATTRIBUTE_VALUE_LENGTH)
|
|
241
|
+
else
|
|
242
|
+
truncate(value.to_s, MAX_ATTRIBUTE_VALUE_LENGTH)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def attributes_to_otlp
|
|
247
|
+
@attributes.map do |key, value|
|
|
248
|
+
{ "key" => key, "value" => value_to_otlp(value) }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def events_to_otlp
|
|
253
|
+
@events.map do |event|
|
|
254
|
+
{
|
|
255
|
+
"name" => event[:name],
|
|
256
|
+
"timeUnixNano" => event[:time_unix_nano].to_s,
|
|
257
|
+
"attributes" => event[:attributes].map do |k, v|
|
|
258
|
+
{ "key" => k, "value" => value_to_otlp(v) }
|
|
259
|
+
end
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def value_to_otlp(value)
|
|
265
|
+
case value
|
|
266
|
+
when String
|
|
267
|
+
{ "stringValue" => value }
|
|
268
|
+
when Integer
|
|
269
|
+
{ "intValue" => value.to_s }
|
|
270
|
+
when Float
|
|
271
|
+
{ "doubleValue" => value }
|
|
272
|
+
when TrueClass, FalseClass
|
|
273
|
+
{ "boolValue" => value }
|
|
274
|
+
when Array
|
|
275
|
+
{ "arrayValue" => { "values" => value.map { |v| value_to_otlp(v) } } }
|
|
276
|
+
when nil
|
|
277
|
+
{ "stringValue" => "" }
|
|
278
|
+
else
|
|
279
|
+
{ "stringValue" => value.to_s }
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def build_status
|
|
284
|
+
status = { "code" => @status_code }
|
|
285
|
+
status["message"] = @status_message if @status_message
|
|
286
|
+
status
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|