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,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Pulse
5
+ # Distributed tracing context propagation using W3C Trace Context format
6
+ # https://www.w3.org/TR/trace-context/
7
+ module Propagation
8
+ # W3C Trace Context header names
9
+ TRACEPARENT_HEADER = "traceparent"
10
+ TRACESTATE_HEADER = "tracestate"
11
+
12
+ # HTTP header versions (with HTTP_ prefix for Rack env)
13
+ HTTP_TRACEPARENT = "HTTP_TRACEPARENT"
14
+ HTTP_TRACESTATE = "HTTP_TRACESTATE"
15
+
16
+ # Also support B3 format for compatibility
17
+ B3_TRACE_ID = "X-B3-TraceId"
18
+ B3_SPAN_ID = "X-B3-SpanId"
19
+ B3_SAMPLED = "X-B3-Sampled"
20
+ B3_PARENT_SPAN_ID = "X-B3-ParentSpanId"
21
+
22
+ class Context
23
+ attr_accessor :trace_id, :span_id, :parent_span_id, :sampled, :tracestate
24
+
25
+ def initialize(trace_id: nil, span_id: nil, parent_span_id: nil, sampled: true, tracestate: nil)
26
+ @trace_id = trace_id || generate_trace_id
27
+ @span_id = span_id || generate_span_id
28
+ @parent_span_id = parent_span_id
29
+ @sampled = sampled
30
+ @tracestate = tracestate
31
+ end
32
+
33
+ def valid?
34
+ @trace_id && @span_id
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ trace_id: @trace_id,
40
+ span_id: @span_id,
41
+ parent_span_id: @parent_span_id,
42
+ sampled: @sampled,
43
+ tracestate: @tracestate
44
+ }.compact
45
+ end
46
+
47
+ private
48
+
49
+ def generate_trace_id
50
+ SecureRandom.hex(16) # 32 hex chars = 128 bits
51
+ end
52
+
53
+ def generate_span_id
54
+ SecureRandom.hex(8) # 16 hex chars = 64 bits
55
+ end
56
+ end
57
+
58
+ class << self
59
+ # Get current propagation context from thread local
60
+ def current
61
+ Thread.current[:brainzlab_propagation_context]
62
+ end
63
+
64
+ # Set current propagation context
65
+ def current=(context)
66
+ Thread.current[:brainzlab_propagation_context] = context
67
+ end
68
+
69
+ # Create new context and set as current
70
+ def start(trace_id: nil, parent_span_id: nil)
71
+ self.current = Context.new(
72
+ trace_id: trace_id,
73
+ parent_span_id: parent_span_id
74
+ )
75
+ end
76
+
77
+ # Clear current context
78
+ def clear!
79
+ Thread.current[:brainzlab_propagation_context] = nil
80
+ end
81
+
82
+ # Inject trace context into outgoing HTTP headers
83
+ # @param headers [Hash] the headers hash to inject into
84
+ # @param context [Context] optional context (defaults to current)
85
+ # @param format [Symbol] :w3c (default), :b3, or :all
86
+ def inject(headers, context: nil, format: :w3c)
87
+ ctx = context || current
88
+ return headers unless ctx&.valid?
89
+
90
+ case format
91
+ when :w3c
92
+ inject_w3c(headers, ctx)
93
+ when :b3
94
+ inject_b3(headers, ctx)
95
+ when :all
96
+ inject_w3c(headers, ctx)
97
+ inject_b3(headers, ctx)
98
+ end
99
+
100
+ headers
101
+ end
102
+
103
+ # Extract trace context from incoming HTTP headers (Rack env or plain headers)
104
+ # @param headers [Hash] the headers to extract from
105
+ # @return [Context, nil] the extracted context or nil
106
+ def extract(headers)
107
+ # Try W3C format first
108
+ ctx = extract_w3c(headers)
109
+ return ctx if ctx
110
+
111
+ # Fall back to B3 format
112
+ extract_b3(headers)
113
+ end
114
+
115
+ # Extract and set as current context
116
+ # Returns the context for chaining
117
+ def extract!(headers)
118
+ self.current = extract(headers)
119
+ end
120
+
121
+ # Create a child context for a new span
122
+ def child_context(parent: nil)
123
+ parent ||= current
124
+ return Context.new unless parent&.valid?
125
+
126
+ Context.new(
127
+ trace_id: parent.trace_id,
128
+ parent_span_id: parent.span_id,
129
+ sampled: parent.sampled,
130
+ tracestate: parent.tracestate
131
+ )
132
+ end
133
+
134
+ private
135
+
136
+ # W3C Trace Context format injection
137
+ # traceparent: version-traceid-spanid-flags
138
+ # Example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
139
+ def inject_w3c(headers, ctx)
140
+ version = "00"
141
+ flags = ctx.sampled ? "01" : "00"
142
+ trace_id = normalize_trace_id(ctx.trace_id, 32)
143
+ span_id = normalize_trace_id(ctx.span_id, 16)
144
+
145
+ headers[TRACEPARENT_HEADER] = "#{version}-#{trace_id}-#{span_id}-#{flags}"
146
+ headers[TRACESTATE_HEADER] = ctx.tracestate if ctx.tracestate
147
+
148
+ headers
149
+ end
150
+
151
+ # W3C Trace Context format extraction
152
+ def extract_w3c(headers)
153
+ traceparent = headers[TRACEPARENT_HEADER] ||
154
+ headers[HTTP_TRACEPARENT] ||
155
+ headers["Traceparent"]
156
+ return nil unless traceparent
157
+
158
+ # Parse: version-traceid-spanid-flags
159
+ parts = traceparent.to_s.split("-")
160
+ return nil if parts.length < 4
161
+
162
+ version, trace_id, span_id, flags = parts
163
+
164
+ # Validate version
165
+ return nil unless version == "00"
166
+
167
+ # Validate trace_id (32 hex chars, not all zeros)
168
+ return nil unless trace_id&.match?(/\A[a-f0-9]{32}\z/i)
169
+ return nil if trace_id == "0" * 32
170
+
171
+ # Validate span_id (16 hex chars, not all zeros)
172
+ return nil unless span_id&.match?(/\A[a-f0-9]{16}\z/i)
173
+ return nil if span_id == "0" * 16
174
+
175
+ sampled = flags.to_i(16) & 0x01 == 1
176
+
177
+ tracestate = headers[TRACESTATE_HEADER] ||
178
+ headers[HTTP_TRACESTATE] ||
179
+ headers["Tracestate"]
180
+
181
+ Context.new(
182
+ trace_id: trace_id,
183
+ span_id: span_id,
184
+ sampled: sampled,
185
+ tracestate: tracestate
186
+ )
187
+ rescue StandardError
188
+ nil
189
+ end
190
+
191
+ # B3 format injection (Zipkin compatibility)
192
+ def inject_b3(headers, ctx)
193
+ headers[B3_TRACE_ID] = normalize_trace_id(ctx.trace_id, 32)
194
+ headers[B3_SPAN_ID] = normalize_trace_id(ctx.span_id, 16)
195
+ headers[B3_SAMPLED] = ctx.sampled ? "1" : "0"
196
+ headers[B3_PARENT_SPAN_ID] = ctx.parent_span_id if ctx.parent_span_id
197
+
198
+ headers
199
+ end
200
+
201
+ # B3 format extraction
202
+ def extract_b3(headers)
203
+ trace_id = headers[B3_TRACE_ID] ||
204
+ headers["HTTP_X_B3_TRACEID"] ||
205
+ headers["x-b3-traceid"]
206
+ return nil unless trace_id
207
+
208
+ span_id = headers[B3_SPAN_ID] ||
209
+ headers["HTTP_X_B3_SPANID"] ||
210
+ headers["x-b3-spanid"]
211
+ return nil unless span_id
212
+
213
+ sampled_header = headers[B3_SAMPLED] ||
214
+ headers["HTTP_X_B3_SAMPLED"] ||
215
+ headers["x-b3-sampled"]
216
+ sampled = sampled_header != "0"
217
+
218
+ parent_span_id = headers[B3_PARENT_SPAN_ID] ||
219
+ headers["HTTP_X_B3_PARENTSPANID"] ||
220
+ headers["x-b3-parentspanid"]
221
+
222
+ Context.new(
223
+ trace_id: trace_id,
224
+ span_id: span_id,
225
+ parent_span_id: parent_span_id,
226
+ sampled: sampled
227
+ )
228
+ rescue StandardError
229
+ nil
230
+ end
231
+
232
+ def normalize_trace_id(id, length)
233
+ return nil unless id
234
+
235
+ hex = id.to_s.gsub("-", "").downcase
236
+ hex.rjust(length, "0").slice(0, length)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "fileutils"
7
+
8
+ module BrainzLab
9
+ module Pulse
10
+ class Provisioner
11
+ CACHE_DIR = ENV.fetch("BRAINZLAB_CACHE_DIR") { File.join(Dir.home, ".brainzlab") }
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def ensure_project!
18
+ return unless should_provision?
19
+
20
+ # Try cached credentials first
21
+ if (cached = load_cached_credentials)
22
+ apply_credentials(cached)
23
+ return cached
24
+ end
25
+
26
+ # Provision new project
27
+ project = provision_project
28
+ return unless project
29
+
30
+ # Cache and apply credentials
31
+ cache_credentials(project)
32
+ apply_credentials(project)
33
+
34
+ project
35
+ end
36
+
37
+ private
38
+
39
+ def should_provision?
40
+ return false unless @config.pulse_auto_provision
41
+ return false unless @config.app_name.to_s.strip.length > 0
42
+ # Only skip if pulse_api_key is already set
43
+ return false if @config.pulse_api_key.to_s.strip.length > 0
44
+ return false unless @config.pulse_master_key.to_s.strip.length > 0
45
+
46
+ true
47
+ end
48
+
49
+ def provision_project
50
+ uri = URI.parse("#{@config.pulse_url}/api/v1/projects/provision")
51
+ request = Net::HTTP::Post.new(uri)
52
+ request["Content-Type"] = "application/json"
53
+ request["X-Master-Key"] = @config.pulse_master_key
54
+ request["User-Agent"] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
55
+ request.body = JSON.generate({ name: @config.app_name })
56
+
57
+ response = execute(uri, request)
58
+ return nil unless response.is_a?(Net::HTTPSuccess)
59
+
60
+ JSON.parse(response.body, symbolize_names: true)
61
+ rescue StandardError => e
62
+ log_error("Failed to provision Pulse project: #{e.message}")
63
+ nil
64
+ end
65
+
66
+ def load_cached_credentials
67
+ path = cache_file_path
68
+ return nil unless File.exist?(path)
69
+
70
+ data = JSON.parse(File.read(path), symbolize_names: true)
71
+
72
+ # Validate cached data has required keys
73
+ return nil unless data[:api_key]
74
+
75
+ data
76
+ rescue StandardError => e
77
+ log_error("Failed to load cached Pulse credentials: #{e.message}")
78
+ nil
79
+ end
80
+
81
+ def cache_credentials(project)
82
+ FileUtils.mkdir_p(CACHE_DIR)
83
+ File.write(cache_file_path, JSON.generate(project))
84
+ rescue StandardError => e
85
+ log_error("Failed to cache Pulse credentials: #{e.message}")
86
+ end
87
+
88
+ def cache_file_path
89
+ File.join(CACHE_DIR, "#{@config.app_name}.pulse.json")
90
+ end
91
+
92
+ def apply_credentials(project)
93
+ @config.pulse_api_key = project[:api_key]
94
+
95
+ # Also set service name from app_name if not already set
96
+ @config.service ||= @config.app_name
97
+ end
98
+
99
+ def execute(uri, request)
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = uri.scheme == "https"
102
+ http.open_timeout = 5
103
+ http.read_timeout = 10
104
+ http.request(request)
105
+ end
106
+
107
+ def log_error(message)
108
+ return unless @config.logger
109
+
110
+ @config.logger.error("[BrainzLab::Pulse] #{message}")
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Pulse
5
+ class Tracer
6
+ def initialize(config, client)
7
+ @config = config
8
+ @client = client
9
+ end
10
+
11
+ def current_trace
12
+ Thread.current[:brainzlab_pulse_trace]
13
+ end
14
+
15
+ def current_spans
16
+ Thread.current[:brainzlab_pulse_spans] ||= []
17
+ end
18
+
19
+ def start_trace(name, kind: "custom", **attributes)
20
+ trace = {
21
+ trace_id: SecureRandom.uuid,
22
+ name: name,
23
+ kind: kind,
24
+ started_at: Time.now.utc,
25
+ environment: @config.environment,
26
+ commit: @config.commit,
27
+ host: @config.host,
28
+ **attributes
29
+ }
30
+
31
+ Thread.current[:brainzlab_pulse_trace] = trace
32
+ Thread.current[:brainzlab_pulse_spans] = []
33
+
34
+ trace
35
+ end
36
+
37
+ def finish_trace(error: false, error_class: nil, error_message: nil)
38
+ trace = current_trace
39
+ return unless trace
40
+
41
+ ended_at = Time.now.utc
42
+ duration_ms = ((ended_at - trace[:started_at]) * 1000).round(2)
43
+
44
+ payload = trace.merge(
45
+ ended_at: ended_at.iso8601(3),
46
+ started_at: trace[:started_at].utc.iso8601(3),
47
+ duration_ms: duration_ms,
48
+ error: error,
49
+ error_class: error_class,
50
+ error_message: error_message,
51
+ spans: current_spans.map { |s| format_span(s, trace[:started_at]) }
52
+ ).compact
53
+
54
+ # Add request context if available
55
+ ctx = Context.current
56
+ payload[:request_id] ||= ctx.request_id
57
+ payload[:user_id] ||= ctx.user&.dig(:id)&.to_s
58
+
59
+ @client.send_trace(payload)
60
+
61
+ Thread.current[:brainzlab_pulse_trace] = nil
62
+ Thread.current[:brainzlab_pulse_spans] = nil
63
+
64
+ payload
65
+ end
66
+
67
+ def span(name, kind: "custom", **data)
68
+ span_data = {
69
+ span_id: SecureRandom.uuid,
70
+ name: name,
71
+ kind: kind,
72
+ started_at: Time.now.utc,
73
+ data: data
74
+ }
75
+
76
+ begin
77
+ result = yield
78
+ span_data[:error] = false
79
+ result
80
+ rescue StandardError => e
81
+ span_data[:error] = true
82
+ span_data[:error_class] = e.class.name
83
+ span_data[:error_message] = e.message
84
+ raise
85
+ ensure
86
+ span_data[:ended_at] = Time.now.utc
87
+ span_data[:duration_ms] = ((span_data[:ended_at] - span_data[:started_at]) * 1000).round(2)
88
+ current_spans << span_data
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def format_span(span, trace_started_at)
95
+ {
96
+ span_id: span[:span_id],
97
+ parent_span_id: span[:parent_span_id],
98
+ name: span[:name],
99
+ kind: span[:kind],
100
+ started_at: span[:started_at].utc.iso8601(3),
101
+ ended_at: span[:ended_at].utc.iso8601(3),
102
+ duration_ms: span[:duration_ms],
103
+ error: span[:error],
104
+ error_class: span[:error_class],
105
+ error_message: span[:error_message],
106
+ data: span[:data]
107
+ }.compact
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pulse/client"
4
+ require_relative "pulse/provisioner"
5
+ require_relative "pulse/tracer"
6
+ require_relative "pulse/instrumentation"
7
+ require_relative "pulse/propagation"
8
+
9
+ module BrainzLab
10
+ module Pulse
11
+ class << self
12
+ # Start a new trace
13
+ # @param name [String] the trace name
14
+ # @param kind [String] trace kind (request, job, custom)
15
+ # @param parent_context [Propagation::Context] optional parent context for distributed tracing
16
+ def start_trace(name, kind: "custom", parent_context: nil, **attributes)
17
+ return nil unless enabled?
18
+
19
+ ensure_provisioned!
20
+ return nil unless BrainzLab.configuration.pulse_valid?
21
+
22
+ # Use parent context trace_id if provided (distributed tracing)
23
+ if parent_context&.valid?
24
+ attributes[:parent_trace_id] = parent_context.trace_id
25
+ attributes[:parent_span_id] = parent_context.span_id
26
+ end
27
+
28
+ tracer.start_trace(name, kind: kind, **attributes)
29
+ end
30
+
31
+ # Finish current trace
32
+ def finish_trace(error: false, error_class: nil, error_message: nil)
33
+ return unless enabled?
34
+
35
+ tracer.finish_trace(error: error, error_class: error_class, error_message: error_message)
36
+ end
37
+
38
+ # Add a span to the current trace
39
+ def span(name, kind: "custom", **data)
40
+ return yield unless enabled?
41
+ return yield unless tracer.current_trace
42
+
43
+ tracer.span(name, kind: kind, **data) { yield }
44
+ end
45
+
46
+ # Record a complete trace (for when you have all data upfront)
47
+ def record_trace(name, kind: "request", started_at:, ended_at:, **attributes)
48
+ return unless enabled?
49
+
50
+ ensure_provisioned!
51
+ return unless BrainzLab.configuration.pulse_valid?
52
+
53
+ payload = build_trace_payload(name, kind, started_at, ended_at, attributes)
54
+ client.send_trace(payload)
55
+ end
56
+
57
+ # Record a custom metric
58
+ def record_metric(name, value:, kind: "gauge", tags: {})
59
+ return unless enabled?
60
+
61
+ ensure_provisioned!
62
+ return unless BrainzLab.configuration.pulse_valid?
63
+
64
+ payload = {
65
+ name: name,
66
+ value: value,
67
+ kind: kind,
68
+ timestamp: Time.now.utc.iso8601(3),
69
+ tags: tags
70
+ }
71
+
72
+ client.send_metric(payload)
73
+ end
74
+
75
+ # Convenience methods for metrics
76
+ def gauge(name, value, tags: {})
77
+ record_metric(name, value: value, kind: "gauge", tags: tags)
78
+ end
79
+
80
+ def counter(name, value = 1, tags: {})
81
+ record_metric(name, value: value, kind: "counter", tags: tags)
82
+ end
83
+
84
+ def histogram(name, value, tags: {})
85
+ record_metric(name, value: value, kind: "histogram", tags: tags)
86
+ end
87
+
88
+ def ensure_provisioned!
89
+ return if @provisioned
90
+
91
+ @provisioned = true
92
+ provisioner.ensure_project!
93
+ end
94
+
95
+ def provisioner
96
+ @provisioner ||= Provisioner.new(BrainzLab.configuration)
97
+ end
98
+
99
+ def tracer
100
+ @tracer ||= Tracer.new(BrainzLab.configuration, client)
101
+ end
102
+
103
+ def client
104
+ @client ||= Client.new(BrainzLab.configuration)
105
+ end
106
+
107
+ def reset!
108
+ @client = nil
109
+ @tracer = nil
110
+ @provisioner = nil
111
+ @provisioned = false
112
+ Propagation.clear!
113
+ end
114
+
115
+ # Distributed tracing: inject trace context into outgoing headers
116
+ # @param headers [Hash] the headers hash to inject into
117
+ # @param format [Symbol] :w3c (default), :b3, or :all
118
+ # @return [Hash] the headers with trace context added
119
+ def inject(headers, format: :w3c)
120
+ ctx = Propagation.current || create_propagation_context
121
+ Propagation.inject(headers, context: ctx, format: format)
122
+ end
123
+
124
+ # Distributed tracing: extract trace context from incoming headers
125
+ # @param headers [Hash] incoming headers (Rack env or plain headers)
126
+ # @return [Propagation::Context, nil] extracted context
127
+ def extract(headers)
128
+ Propagation.extract(headers)
129
+ end
130
+
131
+ # Distributed tracing: extract and set as current context
132
+ # @param headers [Hash] incoming headers
133
+ # @return [Propagation::Context, nil] extracted context
134
+ def extract!(headers)
135
+ Propagation.extract!(headers)
136
+ end
137
+
138
+ # Get current propagation context
139
+ def propagation_context
140
+ Propagation.current
141
+ end
142
+
143
+ # Create a child propagation context for a new span
144
+ def child_context
145
+ Propagation.child_context
146
+ end
147
+
148
+ private
149
+
150
+ def create_propagation_context
151
+ trace = tracer.current_trace
152
+ if trace
153
+ Propagation::Context.new(
154
+ trace_id: trace[:trace_id],
155
+ span_id: SecureRandom.hex(8)
156
+ )
157
+ else
158
+ Propagation::Context.new
159
+ end
160
+ end
161
+
162
+ def enabled?
163
+ BrainzLab.configuration.pulse_enabled
164
+ end
165
+
166
+ def build_trace_payload(name, kind, started_at, ended_at, attributes)
167
+ config = BrainzLab.configuration
168
+ ctx = Context.current
169
+
170
+ duration_ms = ((ended_at - started_at) * 1000).round(2)
171
+
172
+ {
173
+ trace_id: attributes[:trace_id] || SecureRandom.uuid,
174
+ name: name,
175
+ kind: kind,
176
+ started_at: started_at.utc.iso8601(3),
177
+ ended_at: ended_at.utc.iso8601(3),
178
+ duration_ms: duration_ms,
179
+
180
+ # Distributed tracing - parent trace info
181
+ parent_trace_id: attributes[:parent_trace_id],
182
+ parent_span_id: attributes[:parent_span_id],
183
+
184
+ # Environment
185
+ environment: config.environment,
186
+ commit: config.commit,
187
+ host: config.host,
188
+
189
+ # Request context
190
+ request_id: ctx.request_id || attributes[:request_id],
191
+ request_method: attributes[:request_method],
192
+ request_path: attributes[:request_path],
193
+ controller: attributes[:controller],
194
+ action: attributes[:action],
195
+ status: attributes[:status],
196
+
197
+ # Timing breakdown
198
+ view_ms: attributes[:view_ms],
199
+ db_ms: attributes[:db_ms],
200
+ external_ms: attributes[:external_ms],
201
+ cache_ms: attributes[:cache_ms],
202
+
203
+ # Job context
204
+ job_class: attributes[:job_class],
205
+ job_id: attributes[:job_id],
206
+ queue: attributes[:queue],
207
+ queue_wait_ms: attributes[:queue_wait_ms],
208
+ executions: attributes[:executions],
209
+
210
+ # User
211
+ user_id: ctx.user&.dig(:id)&.to_s || attributes[:user_id],
212
+
213
+ # Error info
214
+ error: attributes[:error] || false,
215
+ error_class: attributes[:error_class],
216
+ error_message: attributes[:error_message],
217
+
218
+ # Spans
219
+ spans: attributes[:spans] || []
220
+ }.compact
221
+ end
222
+ end
223
+ end
224
+ end