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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Instrumentation
5
+ module NetHttp
6
+ @installed = false
7
+
8
+ class << self
9
+ def install!
10
+ return if @installed
11
+
12
+ ::Net::HTTP.prepend(Patch)
13
+ @installed = true
14
+ end
15
+
16
+ def installed?
17
+ @installed
18
+ end
19
+
20
+ # For testing purposes
21
+ def reset!
22
+ @installed = false
23
+ end
24
+ end
25
+
26
+ module Patch
27
+ def request(req, body = nil, &block)
28
+ return super unless should_track?
29
+
30
+ # Inject distributed tracing context into outgoing request headers
31
+ inject_trace_context(req)
32
+
33
+ url = build_url(req)
34
+ method = req.method
35
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+
37
+ begin
38
+ response = super
39
+ track_request(method, url, response.code.to_i, started_at)
40
+ response
41
+ rescue => e
42
+ track_request(method, url, nil, started_at, e.class.name)
43
+ raise
44
+ end
45
+ end
46
+
47
+ def inject_trace_context(req)
48
+ return unless BrainzLab.configuration.pulse_enabled
49
+
50
+ # Build headers hash and inject trace context
51
+ headers = {}
52
+ BrainzLab::Pulse.inject(headers, format: :all)
53
+
54
+ # Apply headers to request
55
+ headers.each do |key, value|
56
+ req[key] = value
57
+ end
58
+ rescue StandardError => e
59
+ BrainzLab.debug_log("Failed to inject trace context: #{e.message}")
60
+ end
61
+
62
+ private
63
+
64
+ def should_track?
65
+ return false unless BrainzLab.configuration.instrument_http
66
+
67
+ ignore_hosts = BrainzLab.configuration.http_ignore_hosts || []
68
+ !ignore_hosts.include?(address)
69
+ end
70
+
71
+ def build_url(req)
72
+ scheme = use_ssl? ? "https" : "http"
73
+ port_str = if (use_ssl? && port == 443) || (!use_ssl? && port == 80)
74
+ ""
75
+ else
76
+ ":#{port}"
77
+ end
78
+ "#{scheme}://#{address}#{port_str}#{req.path}"
79
+ end
80
+
81
+ def track_request(method, url, status, started_at, error = nil)
82
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
83
+ level = error || (status && status >= 400) ? :error : :info
84
+
85
+ # Add breadcrumb for Reflex
86
+ if BrainzLab.configuration.reflex_enabled
87
+ BrainzLab::Reflex.add_breadcrumb(
88
+ "#{method} #{url}",
89
+ category: "http",
90
+ level: level,
91
+ data: { method: method, url: url, status_code: status, duration_ms: duration_ms, error: error }.compact
92
+ )
93
+ end
94
+
95
+ # Log to Recall at debug level (avoid noise)
96
+ if BrainzLab.configuration.recall_enabled
97
+ BrainzLab::Recall.debug(
98
+ "HTTP #{method} #{url} -> #{status || 'ERROR'}",
99
+ method: method, url: url, status_code: status, duration_ms: duration_ms, error: error
100
+ )
101
+ end
102
+ rescue => e
103
+ # Don't let instrumentation errors crash the app
104
+ BrainzLab.configuration.logger&.error("[BrainzLab] HTTP instrumentation error: #{e.message}")
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Instrumentation
5
+ module RedisInstrumentation
6
+ @installed = false
7
+
8
+ class << self
9
+ def install!
10
+ return unless defined?(::Redis)
11
+ return if @installed
12
+
13
+ # Redis 5+ uses middleware, older versions need patching
14
+ if redis_5_or_newer?
15
+ install_middleware!
16
+ else
17
+ install_patch!
18
+ end
19
+
20
+ @installed = true
21
+ BrainzLab.debug_log("Redis instrumentation installed")
22
+ end
23
+
24
+ def installed?
25
+ @installed
26
+ end
27
+
28
+ def reset!
29
+ @installed = false
30
+ end
31
+
32
+ private
33
+
34
+ def redis_5_or_newer?
35
+ defined?(::Redis::VERSION) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("5.0")
36
+ end
37
+
38
+ def install_middleware!
39
+ # Redis 5+ uses RedisClient with middleware support
40
+ return unless defined?(::RedisClient)
41
+
42
+ ::RedisClient.register(Middleware)
43
+ end
44
+
45
+ def install_patch!
46
+ # Redis < 5 - patch the client
47
+ ::Redis::Client.prepend(LegacyPatch)
48
+ end
49
+ end
50
+
51
+ # Middleware for Redis 5+ (RedisClient)
52
+ module Middleware
53
+ def connect(redis_config)
54
+ super
55
+ end
56
+
57
+ def call(command, redis_config)
58
+ return super unless should_track?
59
+
60
+ track_command(command) { super }
61
+ end
62
+
63
+ def call_pipelined(commands, redis_config)
64
+ return super unless should_track?
65
+
66
+ track_pipeline(commands) { super }
67
+ end
68
+
69
+ private
70
+
71
+ def should_track?
72
+ BrainzLab.configuration.instrument_redis
73
+ end
74
+
75
+ def should_skip_command?(command)
76
+ cmd_name = command.first.to_s.downcase
77
+ ignore = BrainzLab.configuration.redis_ignore_commands || []
78
+ ignore.map(&:downcase).include?(cmd_name)
79
+ end
80
+
81
+ def track_command(command, &block)
82
+ return yield if should_skip_command?(command)
83
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ error_info = nil
85
+
86
+ begin
87
+ result = yield
88
+ record_command(command, started_at)
89
+ result
90
+ rescue StandardError => e
91
+ error_info = e.class.name
92
+ record_command(command, started_at, error_info)
93
+ raise
94
+ end
95
+ end
96
+
97
+ def track_pipeline(commands, &block)
98
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
+ error_info = nil
100
+
101
+ begin
102
+ result = yield
103
+ record_pipeline(commands, started_at)
104
+ result
105
+ rescue StandardError => e
106
+ error_info = e.class.name
107
+ record_pipeline(commands, started_at, error_info)
108
+ raise
109
+ end
110
+ end
111
+
112
+ def record_command(command, started_at, error = nil)
113
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
114
+ cmd_name = command.first.to_s.upcase
115
+ key = extract_key(command)
116
+ level = error ? :error : :info
117
+
118
+ # Add breadcrumb for Reflex
119
+ if BrainzLab.configuration.reflex_enabled
120
+ BrainzLab::Reflex.add_breadcrumb(
121
+ "Redis #{cmd_name}",
122
+ category: "redis",
123
+ level: level,
124
+ data: {
125
+ command: cmd_name,
126
+ key: truncate_key(key),
127
+ duration_ms: duration_ms,
128
+ error: error
129
+ }.compact
130
+ )
131
+ end
132
+
133
+ # Record span for Pulse APM
134
+ record_pulse_span(cmd_name, key, duration_ms, error)
135
+ rescue StandardError => e
136
+ BrainzLab.debug_log("Redis instrumentation error: #{e.message}")
137
+ end
138
+
139
+ def record_pipeline(commands, started_at, error = nil)
140
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
141
+ cmd_names = commands.map { |c| c.first.to_s.upcase }.uniq.join(", ")
142
+ level = error ? :error : :info
143
+
144
+ # Add breadcrumb for Reflex
145
+ if BrainzLab.configuration.reflex_enabled
146
+ BrainzLab::Reflex.add_breadcrumb(
147
+ "Redis PIPELINE (#{commands.size} commands)",
148
+ category: "redis",
149
+ level: level,
150
+ data: {
151
+ commands: cmd_names,
152
+ count: commands.size,
153
+ duration_ms: duration_ms,
154
+ error: error
155
+ }.compact
156
+ )
157
+ end
158
+
159
+ # Record span for Pulse APM
160
+ record_pulse_span("PIPELINE", nil, duration_ms, error, commands.size)
161
+ rescue StandardError => e
162
+ BrainzLab.debug_log("Redis instrumentation error: #{e.message}")
163
+ end
164
+
165
+ def record_pulse_span(command, key, duration_ms, error, pipeline_count = nil)
166
+ spans = Thread.current[:brainzlab_pulse_spans]
167
+ return unless spans
168
+
169
+ span = {
170
+ span_id: SecureRandom.uuid,
171
+ name: "Redis #{command}",
172
+ kind: "redis",
173
+ started_at: Time.now.utc - (duration_ms / 1000.0),
174
+ ended_at: Time.now.utc,
175
+ duration_ms: duration_ms,
176
+ data: {
177
+ command: command,
178
+ key: truncate_key(key),
179
+ pipeline_count: pipeline_count
180
+ }.compact
181
+ }
182
+
183
+ if error
184
+ span[:error] = true
185
+ span[:error_class] = error
186
+ end
187
+
188
+ spans << span
189
+ end
190
+
191
+ def extract_key(command)
192
+ return nil if command.size < 2
193
+
194
+ # Most Redis commands have the key as the second argument
195
+ key = command[1]
196
+ key.is_a?(String) ? key : key.to_s
197
+ end
198
+
199
+ def truncate_key(key)
200
+ return nil unless key
201
+
202
+ key.to_s[0, 100]
203
+ end
204
+ end
205
+
206
+ # Patch for Redis < 5
207
+ module LegacyPatch
208
+ def call(command)
209
+ return super unless should_track?
210
+ return super if should_skip_command?(command)
211
+
212
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
213
+ error_info = nil
214
+
215
+ begin
216
+ result = super
217
+ record_command(command, started_at)
218
+ result
219
+ rescue StandardError => e
220
+ error_info = e.class.name
221
+ record_command(command, started_at, error_info)
222
+ raise
223
+ end
224
+ end
225
+
226
+ def call_pipeline(pipeline)
227
+ return super unless should_track?
228
+
229
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
+ error_info = nil
231
+ commands = pipeline.commands
232
+
233
+ begin
234
+ result = super
235
+ record_pipeline(commands, started_at)
236
+ result
237
+ rescue StandardError => e
238
+ error_info = e.class.name
239
+ record_pipeline(commands, started_at, error_info)
240
+ raise
241
+ end
242
+ end
243
+
244
+ private
245
+
246
+ def should_track?
247
+ BrainzLab.configuration.instrument_redis
248
+ end
249
+
250
+ def should_skip_command?(command)
251
+ cmd_name = command.first.to_s.downcase
252
+ ignore = BrainzLab.configuration.redis_ignore_commands || []
253
+ ignore.map(&:downcase).include?(cmd_name)
254
+ end
255
+
256
+ def record_command(command, started_at, error = nil)
257
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
258
+ cmd_name = command.first.to_s.upcase
259
+ key = command[1]&.to_s
260
+ level = error ? :error : :info
261
+
262
+ if BrainzLab.configuration.reflex_enabled
263
+ BrainzLab::Reflex.add_breadcrumb(
264
+ "Redis #{cmd_name}",
265
+ category: "redis",
266
+ level: level,
267
+ data: {
268
+ command: cmd_name,
269
+ key: key&.slice(0, 100),
270
+ duration_ms: duration_ms,
271
+ error: error
272
+ }.compact
273
+ )
274
+ end
275
+
276
+ record_pulse_span(cmd_name, key, duration_ms, error)
277
+ rescue StandardError => e
278
+ BrainzLab.debug_log("Redis instrumentation error: #{e.message}")
279
+ end
280
+
281
+ def record_pipeline(commands, started_at, error = nil)
282
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
283
+ level = error ? :error : :info
284
+
285
+ if BrainzLab.configuration.reflex_enabled
286
+ BrainzLab::Reflex.add_breadcrumb(
287
+ "Redis PIPELINE (#{commands.size} commands)",
288
+ category: "redis",
289
+ level: level,
290
+ data: {
291
+ count: commands.size,
292
+ duration_ms: duration_ms,
293
+ error: error
294
+ }.compact
295
+ )
296
+ end
297
+
298
+ record_pulse_span("PIPELINE", nil, duration_ms, error, commands.size)
299
+ rescue StandardError => e
300
+ BrainzLab.debug_log("Redis instrumentation error: #{e.message}")
301
+ end
302
+
303
+ def record_pulse_span(command, key, duration_ms, error, pipeline_count = nil)
304
+ spans = Thread.current[:brainzlab_pulse_spans]
305
+ return unless spans
306
+
307
+ span = {
308
+ span_id: SecureRandom.uuid,
309
+ name: "Redis #{command}",
310
+ kind: "redis",
311
+ started_at: Time.now.utc - (duration_ms / 1000.0),
312
+ ended_at: Time.now.utc,
313
+ duration_ms: duration_ms,
314
+ data: {
315
+ command: command,
316
+ key: key&.slice(0, 100),
317
+ pipeline_count: pipeline_count
318
+ }.compact
319
+ }
320
+
321
+ if error
322
+ span[:error] = true
323
+ span[:error_class] = error
324
+ end
325
+
326
+ spans << span
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Instrumentation
5
+ module SidekiqInstrumentation
6
+ @installed = false
7
+
8
+ class << self
9
+ def install!
10
+ return unless defined?(::Sidekiq)
11
+ return if @installed
12
+
13
+ ::Sidekiq.configure_server do |config|
14
+ config.server_middleware do |chain|
15
+ chain.add ServerMiddleware
16
+ end
17
+
18
+ # Also add client middleware for distributed tracing
19
+ config.client_middleware do |chain|
20
+ chain.add ClientMiddleware
21
+ end
22
+ end
23
+
24
+ # Client-side middleware for when jobs are enqueued
25
+ ::Sidekiq.configure_client do |config|
26
+ config.client_middleware do |chain|
27
+ chain.add ClientMiddleware
28
+ end
29
+ end
30
+
31
+ @installed = true
32
+ BrainzLab.debug_log("Sidekiq instrumentation installed")
33
+ end
34
+
35
+ def installed?
36
+ @installed
37
+ end
38
+
39
+ def reset!
40
+ @installed = false
41
+ end
42
+ end
43
+
44
+ # Server middleware - runs when jobs are processed
45
+ class ServerMiddleware
46
+ def call(worker, job, queue)
47
+ return yield unless should_trace?
48
+
49
+ started_at = Time.now.utc
50
+ job_class = job["class"] || worker.class.name
51
+ job_id = job["jid"]
52
+
53
+ # Calculate queue wait time
54
+ enqueued_at = job["enqueued_at"] ? Time.at(job["enqueued_at"]) : nil
55
+ queue_wait_ms = enqueued_at ? ((started_at - enqueued_at) * 1000).round(2) : nil
56
+
57
+ # Extract parent trace context if present (distributed tracing)
58
+ parent_context = extract_trace_context(job)
59
+
60
+ # Set up context
61
+ setup_context(job, queue)
62
+
63
+ # Add breadcrumb
64
+ BrainzLab::Reflex.add_breadcrumb(
65
+ "Sidekiq #{job_class}",
66
+ category: "job.sidekiq",
67
+ level: :info,
68
+ data: { job_id: job_id, queue: queue, retry_count: job["retry_count"] }
69
+ )
70
+
71
+ # Initialize Pulse tracing
72
+ Thread.current[:brainzlab_pulse_spans] = []
73
+ Thread.current[:brainzlab_pulse_breakdown] = nil
74
+
75
+ error_occurred = nil
76
+ begin
77
+ yield
78
+ rescue StandardError => e
79
+ error_occurred = e
80
+ raise
81
+ ensure
82
+ record_trace(
83
+ job_class: job_class,
84
+ job_id: job_id,
85
+ queue: queue,
86
+ started_at: started_at,
87
+ queue_wait_ms: queue_wait_ms,
88
+ retry_count: job["retry_count"] || 0,
89
+ parent_context: parent_context,
90
+ error: error_occurred
91
+ )
92
+
93
+ cleanup_context
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def should_trace?
100
+ BrainzLab.configuration.pulse_enabled
101
+ end
102
+
103
+ def setup_context(job, queue)
104
+ BrainzLab::Context.current.set_context(
105
+ job_class: job["class"],
106
+ job_id: job["jid"],
107
+ queue_name: queue,
108
+ retry_count: job["retry_count"],
109
+ arguments: job["args"]&.map(&:to_s)&.first(5)
110
+ )
111
+ end
112
+
113
+ def cleanup_context
114
+ Thread.current[:brainzlab_pulse_spans] = nil
115
+ Thread.current[:brainzlab_pulse_breakdown] = nil
116
+ BrainzLab::Context.clear!
117
+ BrainzLab::Pulse::Propagation.clear!
118
+ end
119
+
120
+ def extract_trace_context(job)
121
+ return nil unless job["_brainzlab_trace"]
122
+
123
+ trace_data = job["_brainzlab_trace"]
124
+ BrainzLab::Pulse::Propagation::Context.new(
125
+ trace_id: trace_data["trace_id"],
126
+ span_id: trace_data["span_id"],
127
+ sampled: trace_data["sampled"] != false
128
+ )
129
+ rescue StandardError
130
+ nil
131
+ end
132
+
133
+ def record_trace(job_class:, job_id:, queue:, started_at:, queue_wait_ms:, retry_count:, parent_context:, error:)
134
+ ended_at = Time.now.utc
135
+ duration_ms = ((ended_at - started_at) * 1000).round(2)
136
+
137
+ # Collect spans
138
+ spans = Thread.current[:brainzlab_pulse_spans] || []
139
+ breakdown = Thread.current[:brainzlab_pulse_breakdown] || {}
140
+
141
+ formatted_spans = spans.map do |span|
142
+ {
143
+ span_id: span[:span_id],
144
+ name: span[:name],
145
+ kind: span[:kind],
146
+ started_at: format_timestamp(span[:started_at]),
147
+ ended_at: format_timestamp(span[:ended_at]),
148
+ duration_ms: span[:duration_ms],
149
+ data: span[:data],
150
+ error: span[:error],
151
+ error_class: span[:error_class],
152
+ error_message: span[:error_message]
153
+ }.compact
154
+ end
155
+
156
+ payload = {
157
+ trace_id: SecureRandom.uuid,
158
+ name: job_class,
159
+ kind: "job",
160
+ started_at: started_at.utc.iso8601(3),
161
+ ended_at: ended_at.utc.iso8601(3),
162
+ duration_ms: duration_ms,
163
+ job_class: job_class,
164
+ job_id: job_id,
165
+ queue: queue,
166
+ queue_wait_ms: queue_wait_ms,
167
+ executions: retry_count + 1,
168
+ db_ms: breakdown[:db_ms],
169
+ error: error.present?,
170
+ error_class: error&.class&.name,
171
+ error_message: error&.message&.slice(0, 1000),
172
+ spans: formatted_spans,
173
+ environment: BrainzLab.configuration.environment,
174
+ commit: BrainzLab.configuration.commit,
175
+ host: BrainzLab.configuration.host
176
+ }
177
+
178
+ # Add parent trace info for distributed tracing
179
+ if parent_context&.valid?
180
+ payload[:parent_trace_id] = parent_context.trace_id
181
+ payload[:parent_span_id] = parent_context.span_id
182
+ end
183
+
184
+ BrainzLab::Pulse.client.send_trace(payload.compact)
185
+ rescue StandardError => e
186
+ BrainzLab.debug_log("Sidekiq trace recording failed: #{e.message}")
187
+ end
188
+
189
+ def format_timestamp(ts)
190
+ return nil unless ts
191
+
192
+ case ts
193
+ when Time, DateTime then ts.utc.iso8601(3)
194
+ when Float, Integer then Time.at(ts).utc.iso8601(3)
195
+ when String then ts
196
+ else ts.to_s
197
+ end
198
+ end
199
+ end
200
+
201
+ # Client middleware - runs when jobs are enqueued
202
+ class ClientMiddleware
203
+ def call(worker_class, job, queue, redis_pool)
204
+ # Inject trace context for distributed tracing
205
+ inject_trace_context(job)
206
+
207
+ # Add breadcrumb for job enqueue
208
+ if BrainzLab.configuration.reflex_enabled
209
+ BrainzLab::Reflex.add_breadcrumb(
210
+ "Enqueue #{job['class']}",
211
+ category: "job.sidekiq.enqueue",
212
+ level: :info,
213
+ data: { queue: queue, job_id: job["jid"] }
214
+ )
215
+ end
216
+
217
+ # Record span for Pulse
218
+ record_enqueue_span(job, queue)
219
+
220
+ yield
221
+ end
222
+
223
+ private
224
+
225
+ def inject_trace_context(job)
226
+ return unless BrainzLab.configuration.pulse_enabled
227
+
228
+ # Get or create propagation context
229
+ ctx = BrainzLab::Pulse::Propagation.current
230
+ ctx ||= BrainzLab::Pulse.send(:create_propagation_context)
231
+
232
+ return unless ctx&.valid?
233
+
234
+ job["_brainzlab_trace"] = {
235
+ "trace_id" => ctx.trace_id,
236
+ "span_id" => ctx.span_id,
237
+ "sampled" => ctx.sampled
238
+ }
239
+ rescue StandardError => e
240
+ BrainzLab.debug_log("Failed to inject Sidekiq trace context: #{e.message}")
241
+ end
242
+
243
+ def record_enqueue_span(job, queue)
244
+ spans = Thread.current[:brainzlab_pulse_spans]
245
+ return unless spans
246
+
247
+ spans << {
248
+ span_id: SecureRandom.uuid,
249
+ name: "Enqueue #{job['class']}",
250
+ kind: "job",
251
+ started_at: Time.now.utc,
252
+ ended_at: Time.now.utc,
253
+ duration_ms: 0,
254
+ data: {
255
+ job_class: job["class"],
256
+ job_id: job["jid"],
257
+ queue: queue
258
+ }
259
+ }
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end