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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +174 -0
  5. data/lib/generators/miniapm/install_generator.rb +27 -0
  6. data/lib/generators/miniapm/templates/README +19 -0
  7. data/lib/generators/miniapm/templates/initializer.rb +60 -0
  8. data/lib/miniapm/configuration.rb +176 -0
  9. data/lib/miniapm/context.rb +138 -0
  10. data/lib/miniapm/error_event.rb +130 -0
  11. data/lib/miniapm/exporters/errors.rb +67 -0
  12. data/lib/miniapm/exporters/otlp.rb +90 -0
  13. data/lib/miniapm/instrumentations/activejob.rb +271 -0
  14. data/lib/miniapm/instrumentations/activerecord.rb +123 -0
  15. data/lib/miniapm/instrumentations/base.rb +61 -0
  16. data/lib/miniapm/instrumentations/cache.rb +85 -0
  17. data/lib/miniapm/instrumentations/http/faraday.rb +112 -0
  18. data/lib/miniapm/instrumentations/http/httparty.rb +84 -0
  19. data/lib/miniapm/instrumentations/http/net_http.rb +99 -0
  20. data/lib/miniapm/instrumentations/rails/controller.rb +129 -0
  21. data/lib/miniapm/instrumentations/rails/railtie.rb +42 -0
  22. data/lib/miniapm/instrumentations/redis/redis.rb +135 -0
  23. data/lib/miniapm/instrumentations/redis/redis_client.rb +116 -0
  24. data/lib/miniapm/instrumentations/registry.rb +90 -0
  25. data/lib/miniapm/instrumentations/search/elasticsearch.rb +121 -0
  26. data/lib/miniapm/instrumentations/search/opensearch.rb +120 -0
  27. data/lib/miniapm/instrumentations/search/searchkick.rb +119 -0
  28. data/lib/miniapm/instrumentations/sidekiq.rb +185 -0
  29. data/lib/miniapm/middleware/error_handler.rb +120 -0
  30. data/lib/miniapm/middleware/rack.rb +103 -0
  31. data/lib/miniapm/span.rb +289 -0
  32. data/lib/miniapm/testing.rb +209 -0
  33. data/lib/miniapm/trace.rb +26 -0
  34. data/lib/miniapm/transport/batch_sender.rb +345 -0
  35. data/lib/miniapm/transport/http.rb +45 -0
  36. data/lib/miniapm/version.rb +5 -0
  37. data/lib/miniapm.rb +184 -0
  38. 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
@@ -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