brainzlab 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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE +26 -0
  4. data/README.md +311 -0
  5. data/lib/brainzlab/configuration.rb +215 -0
  6. data/lib/brainzlab/context.rb +91 -0
  7. data/lib/brainzlab/instrumentation/action_mailer.rb +181 -0
  8. data/lib/brainzlab/instrumentation/active_record.rb +111 -0
  9. data/lib/brainzlab/instrumentation/delayed_job.rb +236 -0
  10. data/lib/brainzlab/instrumentation/elasticsearch.rb +210 -0
  11. data/lib/brainzlab/instrumentation/faraday.rb +182 -0
  12. data/lib/brainzlab/instrumentation/grape.rb +293 -0
  13. data/lib/brainzlab/instrumentation/graphql.rb +251 -0
  14. data/lib/brainzlab/instrumentation/httparty.rb +194 -0
  15. data/lib/brainzlab/instrumentation/mongodb.rb +187 -0
  16. data/lib/brainzlab/instrumentation/net_http.rb +109 -0
  17. data/lib/brainzlab/instrumentation/redis.rb +331 -0
  18. data/lib/brainzlab/instrumentation/sidekiq.rb +264 -0
  19. data/lib/brainzlab/instrumentation.rb +132 -0
  20. data/lib/brainzlab/pulse/client.rb +132 -0
  21. data/lib/brainzlab/pulse/instrumentation.rb +364 -0
  22. data/lib/brainzlab/pulse/propagation.rb +241 -0
  23. data/lib/brainzlab/pulse/provisioner.rb +114 -0
  24. data/lib/brainzlab/pulse/tracer.rb +111 -0
  25. data/lib/brainzlab/pulse.rb +224 -0
  26. data/lib/brainzlab/rails/log_formatter.rb +801 -0
  27. data/lib/brainzlab/rails/log_subscriber.rb +341 -0
  28. data/lib/brainzlab/rails/railtie.rb +590 -0
  29. data/lib/brainzlab/recall/buffer.rb +64 -0
  30. data/lib/brainzlab/recall/client.rb +86 -0
  31. data/lib/brainzlab/recall/logger.rb +118 -0
  32. data/lib/brainzlab/recall/provisioner.rb +113 -0
  33. data/lib/brainzlab/recall.rb +155 -0
  34. data/lib/brainzlab/reflex/breadcrumbs.rb +55 -0
  35. data/lib/brainzlab/reflex/client.rb +85 -0
  36. data/lib/brainzlab/reflex/provisioner.rb +116 -0
  37. data/lib/brainzlab/reflex.rb +374 -0
  38. data/lib/brainzlab/version.rb +5 -0
  39. data/lib/brainzlab-sdk.rb +3 -0
  40. data/lib/brainzlab.rb +140 -0
  41. data/lib/generators/brainzlab/install/install_generator.rb +61 -0
  42. data/lib/generators/brainzlab/install/templates/brainzlab.rb.tt +77 -0
  43. metadata +159 -0
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Instrumentation
5
+ module ElasticsearchInstrumentation
6
+ @installed = false
7
+
8
+ class << self
9
+ def install!
10
+ return if @installed
11
+
12
+ installed_any = false
13
+
14
+ # Elasticsearch gem (elasticsearch-ruby)
15
+ if defined?(::Elasticsearch::Transport::Client)
16
+ install_elasticsearch_transport!
17
+ installed_any = true
18
+ end
19
+
20
+ # OpenSearch gem
21
+ if defined?(::OpenSearch::Client)
22
+ install_opensearch!
23
+ installed_any = true
24
+ end
25
+
26
+ # Elasticsearch 8.x with new client
27
+ if defined?(::Elastic::Transport::Client)
28
+ install_elastic_transport!
29
+ installed_any = true
30
+ end
31
+
32
+ return unless installed_any
33
+
34
+ @installed = true
35
+ BrainzLab.debug_log("Elasticsearch instrumentation installed")
36
+ end
37
+
38
+ def installed?
39
+ @installed
40
+ end
41
+
42
+ def reset!
43
+ @installed = false
44
+ end
45
+
46
+ private
47
+
48
+ def install_elasticsearch_transport!
49
+ ::Elasticsearch::Transport::Client.prepend(ClientPatch)
50
+ end
51
+
52
+ def install_opensearch!
53
+ ::OpenSearch::Client.prepend(ClientPatch)
54
+ end
55
+
56
+ def install_elastic_transport!
57
+ ::Elastic::Transport::Client.prepend(ClientPatch)
58
+ end
59
+ end
60
+
61
+ # Patch for Elasticsearch/OpenSearch clients
62
+ module ClientPatch
63
+ def perform_request(method, path, params = {}, body = nil, headers = nil)
64
+ return super unless should_track?
65
+
66
+ started_at = Time.now.utc
67
+ error_info = nil
68
+
69
+ begin
70
+ response = super
71
+ record_request(method, path, params, started_at, response.status)
72
+ response
73
+ rescue StandardError => e
74
+ error_info = e
75
+ record_request(method, path, params, started_at, nil, e)
76
+ raise
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def should_track?
83
+ BrainzLab.configuration.instrument_elasticsearch
84
+ end
85
+
86
+ def record_request(method, path, params, started_at, status, error = nil)
87
+ duration_ms = ((Time.now.utc - started_at) * 1000).round(2)
88
+ operation = extract_operation(method, path)
89
+ index = extract_index(path)
90
+ level = error || (status && status >= 400) ? :error : :info
91
+
92
+ # Add breadcrumb for Reflex
93
+ if BrainzLab.configuration.reflex_enabled
94
+ BrainzLab::Reflex.add_breadcrumb(
95
+ "ES #{operation}",
96
+ category: "elasticsearch",
97
+ level: level,
98
+ data: {
99
+ method: method.to_s.upcase,
100
+ path: truncate_path(path),
101
+ index: index,
102
+ status: status,
103
+ duration_ms: duration_ms,
104
+ error: error&.class&.name
105
+ }.compact
106
+ )
107
+ end
108
+
109
+ # Record span for Pulse
110
+ record_span(
111
+ operation: operation,
112
+ method: method,
113
+ path: path,
114
+ index: index,
115
+ started_at: started_at,
116
+ duration_ms: duration_ms,
117
+ status: status,
118
+ error: error
119
+ )
120
+
121
+ # Log to Recall
122
+ if BrainzLab.configuration.recall_enabled
123
+ log_method = error ? :warn : :debug
124
+ BrainzLab::Recall.send(
125
+ log_method,
126
+ "ES #{method.to_s.upcase} #{path} -> #{status || 'ERROR'} (#{duration_ms}ms)",
127
+ method: method.to_s.upcase,
128
+ path: path,
129
+ index: index,
130
+ status: status,
131
+ duration_ms: duration_ms,
132
+ error: error&.message
133
+ )
134
+ end
135
+ rescue StandardError => e
136
+ BrainzLab.debug_log("Elasticsearch recording failed: #{e.message}")
137
+ end
138
+
139
+ def record_span(operation:, method:, path:, index:, started_at:, duration_ms:, status:, error:)
140
+ spans = Thread.current[:brainzlab_pulse_spans]
141
+ return unless spans
142
+
143
+ span = {
144
+ span_id: SecureRandom.uuid,
145
+ name: "ES #{operation}",
146
+ kind: "elasticsearch",
147
+ started_at: started_at,
148
+ ended_at: Time.now.utc,
149
+ duration_ms: duration_ms,
150
+ data: {
151
+ method: method.to_s.upcase,
152
+ path: truncate_path(path),
153
+ index: index,
154
+ status: status
155
+ }.compact
156
+ }
157
+
158
+ if error
159
+ span[:error] = true
160
+ span[:error_class] = error.class.name
161
+ span[:error_message] = error.message&.slice(0, 500)
162
+ end
163
+
164
+ spans << span
165
+ end
166
+
167
+ def extract_operation(method, path)
168
+ method_str = method.to_s.upcase
169
+
170
+ case path
171
+ when %r{/_search} then "search"
172
+ when %r{/_bulk} then "bulk"
173
+ when %r{/_count} then "count"
174
+ when %r{/_mget} then "mget"
175
+ when %r{/_msearch} then "msearch"
176
+ when %r{/_update_by_query} then "update_by_query"
177
+ when %r{/_delete_by_query} then "delete_by_query"
178
+ when %r{/_refresh} then "refresh"
179
+ when %r{/_mapping} then "mapping"
180
+ when %r{/_settings} then "settings"
181
+ when %r{/_alias} then "alias"
182
+ when %r{/_analyze} then "analyze"
183
+ else
184
+ case method_str
185
+ when "GET" then "get"
186
+ when "POST" then "index"
187
+ when "PUT" then "update"
188
+ when "DELETE" then "delete"
189
+ when "HEAD" then "exists"
190
+ else method_str.downcase
191
+ end
192
+ end
193
+ end
194
+
195
+ def extract_index(path)
196
+ # Extract index name from path like /my-index/_search
197
+ match = path.match(%r{^/([^/_]+)})
198
+ match[1] if match && !match[1].start_with?("_")
199
+ rescue StandardError
200
+ nil
201
+ end
202
+
203
+ def truncate_path(path)
204
+ return nil unless path
205
+ path.to_s[0, 200]
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Instrumentation
5
+ module FaradayMiddleware
6
+ @installed = false
7
+
8
+ class << self
9
+ def install!
10
+ return unless defined?(::Faraday)
11
+ return if @installed
12
+
13
+ # Register the middleware with Faraday
14
+ ::Faraday::Middleware.register_middleware(brainzlab: Middleware)
15
+
16
+ @installed = true
17
+ BrainzLab.debug_log("Faraday instrumentation installed")
18
+ end
19
+
20
+ def installed?
21
+ @installed
22
+ end
23
+
24
+ def reset!
25
+ @installed = false
26
+ end
27
+ end
28
+
29
+ # Faraday middleware for HTTP request instrumentation
30
+ # Usage:
31
+ # conn = Faraday.new do |f|
32
+ # f.use :brainzlab
33
+ # # or
34
+ # f.use BrainzLab::Instrumentation::FaradayMiddleware::Middleware
35
+ # end
36
+ class Middleware < ::Faraday::Middleware
37
+ def initialize(app, options = {})
38
+ super(app)
39
+ @options = options
40
+ end
41
+
42
+ def call(env)
43
+ return @app.call(env) unless should_track?(env)
44
+
45
+ # Inject distributed tracing context
46
+ inject_trace_context(env)
47
+
48
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ error_info = nil
50
+
51
+ begin
52
+ response = @app.call(env)
53
+ track_request(env, response.status, started_at)
54
+ response
55
+ rescue ::Faraday::Error => e
56
+ error_info = e.class.name
57
+ track_request(env, e.response&.dig(:status), started_at, error_info)
58
+ raise
59
+ rescue StandardError => e
60
+ error_info = e.class.name
61
+ track_request(env, nil, started_at, error_info)
62
+ raise
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def should_track?(env)
69
+ return false unless BrainzLab.configuration.instrument_http
70
+
71
+ ignore_hosts = BrainzLab.configuration.http_ignore_hosts || []
72
+ host = env.url.host
73
+ !ignore_hosts.include?(host)
74
+ end
75
+
76
+ def inject_trace_context(env)
77
+ return unless BrainzLab.configuration.pulse_enabled
78
+
79
+ headers = {}
80
+ BrainzLab::Pulse.inject(headers, format: :all)
81
+
82
+ headers.each do |key, value|
83
+ env.request_headers[key] = value
84
+ end
85
+ rescue StandardError => e
86
+ BrainzLab.debug_log("Failed to inject trace context: #{e.message}")
87
+ end
88
+
89
+ def track_request(env, status, started_at, error = nil)
90
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
91
+ method = env.method.to_s.upcase
92
+ url = sanitize_url(env.url)
93
+ host = env.url.host
94
+ path = env.url.path
95
+ level = error || (status && status >= 400) ? :error : :info
96
+
97
+ # Add breadcrumb for Reflex
98
+ if BrainzLab.configuration.reflex_enabled
99
+ BrainzLab::Reflex.add_breadcrumb(
100
+ "#{method} #{url}",
101
+ category: "http.faraday",
102
+ level: level,
103
+ data: {
104
+ method: method,
105
+ url: url,
106
+ host: host,
107
+ path: path,
108
+ status_code: status,
109
+ duration_ms: duration_ms,
110
+ error: error
111
+ }.compact
112
+ )
113
+ end
114
+
115
+ # Record span for Pulse APM
116
+ record_pulse_span(method, host, path, status, duration_ms, error)
117
+
118
+ # Log to Recall at debug level
119
+ if BrainzLab.configuration.recall_enabled
120
+ BrainzLab::Recall.debug(
121
+ "HTTP #{method} #{url} -> #{status || 'ERROR'}",
122
+ method: method,
123
+ url: url,
124
+ host: host,
125
+ status_code: status,
126
+ duration_ms: duration_ms,
127
+ error: error
128
+ )
129
+ end
130
+ rescue StandardError => e
131
+ BrainzLab.debug_log("Faraday instrumentation error: #{e.message}")
132
+ end
133
+
134
+ def record_pulse_span(method, host, path, status, duration_ms, error)
135
+ spans = Thread.current[:brainzlab_pulse_spans]
136
+ return unless spans
137
+
138
+ span = {
139
+ span_id: SecureRandom.uuid,
140
+ name: "HTTP #{method} #{host}",
141
+ kind: "http",
142
+ started_at: Time.now.utc - (duration_ms / 1000.0),
143
+ ended_at: Time.now.utc,
144
+ duration_ms: duration_ms,
145
+ data: {
146
+ method: method,
147
+ host: host,
148
+ path: path,
149
+ status: status
150
+ }.compact
151
+ }
152
+
153
+ if error
154
+ span[:error] = true
155
+ span[:error_class] = error
156
+ end
157
+
158
+ spans << span
159
+ end
160
+
161
+ def sanitize_url(url)
162
+ # Remove sensitive query parameters
163
+ uri = url.dup
164
+ if uri.query
165
+ params = URI.decode_www_form(uri.query).reject do |key, _|
166
+ sensitive_param?(key)
167
+ end
168
+ uri.query = params.empty? ? nil : URI.encode_www_form(params)
169
+ end
170
+ uri.to_s
171
+ rescue StandardError
172
+ url.to_s
173
+ end
174
+
175
+ def sensitive_param?(key)
176
+ key = key.to_s.downcase
177
+ %w[token api_key apikey secret password auth key].any? { |s| key.include?(s) }
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Instrumentation
5
+ module GrapeInstrumentation
6
+ @installed = false
7
+
8
+ class << self
9
+ def install!
10
+ return unless defined?(::Grape::API)
11
+ return if @installed
12
+
13
+ # Subscribe to Grape's ActiveSupport notifications
14
+ install_notifications!
15
+
16
+ @installed = true
17
+ BrainzLab.debug_log("Grape instrumentation installed")
18
+ end
19
+
20
+ def installed?
21
+ @installed
22
+ end
23
+
24
+ def reset!
25
+ @installed = false
26
+ end
27
+
28
+ private
29
+
30
+ def install_notifications!
31
+ # Grape emits these notifications
32
+ ActiveSupport::Notifications.subscribe("endpoint_run.grape") do |*args|
33
+ event = ActiveSupport::Notifications::Event.new(*args)
34
+ record_endpoint(event)
35
+ end
36
+
37
+ ActiveSupport::Notifications.subscribe("endpoint_render.grape") do |*args|
38
+ event = ActiveSupport::Notifications::Event.new(*args)
39
+ record_render(event)
40
+ end
41
+
42
+ ActiveSupport::Notifications.subscribe("endpoint_run_filters.grape") do |*args|
43
+ event = ActiveSupport::Notifications::Event.new(*args)
44
+ record_filters(event)
45
+ end
46
+
47
+ # Format validation
48
+ ActiveSupport::Notifications.subscribe("format_response.grape") do |*args|
49
+ event = ActiveSupport::Notifications::Event.new(*args)
50
+ record_format(event)
51
+ end
52
+ rescue StandardError => e
53
+ BrainzLab.debug_log("Grape notifications setup failed: #{e.message}")
54
+ end
55
+
56
+ def record_endpoint(event)
57
+ payload = event.payload
58
+ endpoint = payload[:endpoint]
59
+ env = payload[:env] || {}
60
+
61
+ method = env["REQUEST_METHOD"] || "GET"
62
+ path = endpoint&.options&.dig(:path)&.first || env["PATH_INFO"] || "/"
63
+ route_pattern = extract_route_pattern(endpoint)
64
+ duration_ms = event.duration.round(2)
65
+
66
+ status = env["api.endpoint"]&.status || 200
67
+ level = status >= 400 ? :error : :info
68
+
69
+ # Add breadcrumb for Reflex
70
+ if BrainzLab.configuration.reflex_enabled
71
+ BrainzLab::Reflex.add_breadcrumb(
72
+ "Grape #{method} #{route_pattern}",
73
+ category: "grape.endpoint",
74
+ level: level,
75
+ data: {
76
+ method: method,
77
+ path: path,
78
+ route: route_pattern,
79
+ status: status,
80
+ duration_ms: duration_ms
81
+ }.compact
82
+ )
83
+ end
84
+
85
+ # Record span for Pulse
86
+ record_span(
87
+ name: "Grape #{method} #{route_pattern}",
88
+ kind: "grape",
89
+ started_at: event.time,
90
+ ended_at: event.end,
91
+ duration_ms: duration_ms,
92
+ data: {
93
+ method: method,
94
+ path: path,
95
+ route: route_pattern,
96
+ status: status
97
+ }.compact,
98
+ error: status >= 500
99
+ )
100
+
101
+ # Log to Recall
102
+ if BrainzLab.configuration.recall_enabled
103
+ BrainzLab::Recall.info(
104
+ "Grape #{method} #{path} -> #{status} (#{duration_ms}ms)",
105
+ method: method,
106
+ path: path,
107
+ route: route_pattern,
108
+ status: status,
109
+ duration_ms: duration_ms
110
+ )
111
+ end
112
+ rescue StandardError => e
113
+ BrainzLab.debug_log("Grape endpoint recording failed: #{e.message}")
114
+ end
115
+
116
+ def record_render(event)
117
+ duration_ms = event.duration.round(2)
118
+
119
+ record_span(
120
+ name: "Grape render",
121
+ kind: "grape.render",
122
+ started_at: event.time,
123
+ ended_at: event.end,
124
+ duration_ms: duration_ms,
125
+ data: { phase: "render" }
126
+ )
127
+ rescue StandardError => e
128
+ BrainzLab.debug_log("Grape render recording failed: #{e.message}")
129
+ end
130
+
131
+ def record_filters(event)
132
+ payload = event.payload
133
+ duration_ms = event.duration.round(2)
134
+ filter_type = payload[:type] || "filter"
135
+
136
+ record_span(
137
+ name: "Grape #{filter_type} filters",
138
+ kind: "grape.filter",
139
+ started_at: event.time,
140
+ ended_at: event.end,
141
+ duration_ms: duration_ms,
142
+ data: { type: filter_type }
143
+ )
144
+ rescue StandardError => e
145
+ BrainzLab.debug_log("Grape filters recording failed: #{e.message}")
146
+ end
147
+
148
+ def record_format(event)
149
+ duration_ms = event.duration.round(2)
150
+
151
+ record_span(
152
+ name: "Grape format response",
153
+ kind: "grape.format",
154
+ started_at: event.time,
155
+ ended_at: event.end,
156
+ duration_ms: duration_ms,
157
+ data: { phase: "format" }
158
+ )
159
+ rescue StandardError => e
160
+ BrainzLab.debug_log("Grape format recording failed: #{e.message}")
161
+ end
162
+
163
+ def record_span(name:, kind:, started_at:, ended_at:, duration_ms:, data:, error: false)
164
+ spans = Thread.current[:brainzlab_pulse_spans]
165
+ return unless spans
166
+
167
+ spans << {
168
+ span_id: SecureRandom.uuid,
169
+ name: name,
170
+ kind: kind,
171
+ started_at: started_at,
172
+ ended_at: ended_at,
173
+ duration_ms: duration_ms,
174
+ data: data,
175
+ error: error
176
+ }
177
+ end
178
+
179
+ def extract_route_pattern(endpoint)
180
+ return "/" unless endpoint
181
+
182
+ route = endpoint.route
183
+ return "/" unless route
184
+
185
+ route.pattern&.path || route.path || "/"
186
+ rescue StandardError
187
+ "/"
188
+ end
189
+ end
190
+
191
+ # Middleware for Grape (alternative installation)
192
+ # Usage: use BrainzLab::Instrumentation::GrapeInstrumentation::Middleware
193
+ class Middleware
194
+ def initialize(app)
195
+ @app = app
196
+ end
197
+
198
+ def call(env)
199
+ return @app.call(env) unless should_trace?
200
+
201
+ started_at = Time.now.utc
202
+ request = Rack::Request.new(env)
203
+
204
+ # Initialize Pulse tracing
205
+ Thread.current[:brainzlab_pulse_spans] = []
206
+ Thread.current[:brainzlab_pulse_breakdown] = nil
207
+
208
+ # Extract parent trace context
209
+ parent_context = BrainzLab::Pulse.extract!(env)
210
+
211
+ begin
212
+ status, headers, response = @app.call(env)
213
+
214
+ record_trace(request, env, started_at, status, parent_context)
215
+
216
+ [status, headers, response]
217
+ rescue StandardError => e
218
+ record_trace(request, env, started_at, 500, parent_context, e)
219
+ raise
220
+ ensure
221
+ cleanup_context
222
+ end
223
+ end
224
+
225
+ private
226
+
227
+ def should_trace?
228
+ BrainzLab.configuration.pulse_enabled
229
+ end
230
+
231
+ def cleanup_context
232
+ Thread.current[:brainzlab_pulse_spans] = nil
233
+ Thread.current[:brainzlab_pulse_breakdown] = nil
234
+ BrainzLab::Context.clear!
235
+ BrainzLab::Pulse::Propagation.clear!
236
+ end
237
+
238
+ def record_trace(request, env, started_at, status, parent_context, error = nil)
239
+ ended_at = Time.now.utc
240
+ duration_ms = ((ended_at - started_at) * 1000).round(2)
241
+
242
+ method = request.request_method
243
+ path = request.path
244
+
245
+ # Get route pattern from Grape if available
246
+ route_pattern = env["grape.routing_args"]&.dig(:route_info)&.pattern&.path || path
247
+
248
+ spans = Thread.current[:brainzlab_pulse_spans] || []
249
+
250
+ payload = {
251
+ trace_id: SecureRandom.uuid,
252
+ name: "#{method} #{route_pattern}",
253
+ kind: "request",
254
+ started_at: started_at.utc.iso8601(3),
255
+ ended_at: ended_at.utc.iso8601(3),
256
+ duration_ms: duration_ms,
257
+ request_method: method,
258
+ request_path: path,
259
+ status: status,
260
+ error: error.present? || status >= 500,
261
+ error_class: error&.class&.name,
262
+ error_message: error&.message&.slice(0, 1000),
263
+ spans: spans.map { |s| format_span(s) },
264
+ environment: BrainzLab.configuration.environment,
265
+ commit: BrainzLab.configuration.commit,
266
+ host: BrainzLab.configuration.host
267
+ }
268
+
269
+ if parent_context&.valid?
270
+ payload[:parent_trace_id] = parent_context.trace_id
271
+ payload[:parent_span_id] = parent_context.span_id
272
+ end
273
+
274
+ BrainzLab::Pulse.client.send_trace(payload.compact)
275
+ rescue StandardError => e
276
+ BrainzLab.debug_log("Grape trace recording failed: #{e.message}")
277
+ end
278
+
279
+ def format_span(span)
280
+ {
281
+ span_id: span[:span_id],
282
+ name: span[:name],
283
+ kind: span[:kind],
284
+ started_at: span[:started_at]&.utc&.iso8601(3),
285
+ ended_at: span[:ended_at]&.utc&.iso8601(3),
286
+ duration_ms: span[:duration_ms],
287
+ data: span[:data]
288
+ }.compact
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end