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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE +26 -0
- data/README.md +311 -0
- data/lib/brainzlab/configuration.rb +215 -0
- data/lib/brainzlab/context.rb +91 -0
- data/lib/brainzlab/instrumentation/action_mailer.rb +181 -0
- data/lib/brainzlab/instrumentation/active_record.rb +111 -0
- data/lib/brainzlab/instrumentation/delayed_job.rb +236 -0
- data/lib/brainzlab/instrumentation/elasticsearch.rb +210 -0
- data/lib/brainzlab/instrumentation/faraday.rb +182 -0
- data/lib/brainzlab/instrumentation/grape.rb +293 -0
- data/lib/brainzlab/instrumentation/graphql.rb +251 -0
- data/lib/brainzlab/instrumentation/httparty.rb +194 -0
- data/lib/brainzlab/instrumentation/mongodb.rb +187 -0
- data/lib/brainzlab/instrumentation/net_http.rb +109 -0
- data/lib/brainzlab/instrumentation/redis.rb +331 -0
- data/lib/brainzlab/instrumentation/sidekiq.rb +264 -0
- data/lib/brainzlab/instrumentation.rb +132 -0
- data/lib/brainzlab/pulse/client.rb +132 -0
- data/lib/brainzlab/pulse/instrumentation.rb +364 -0
- data/lib/brainzlab/pulse/propagation.rb +241 -0
- data/lib/brainzlab/pulse/provisioner.rb +114 -0
- data/lib/brainzlab/pulse/tracer.rb +111 -0
- data/lib/brainzlab/pulse.rb +224 -0
- data/lib/brainzlab/rails/log_formatter.rb +801 -0
- data/lib/brainzlab/rails/log_subscriber.rb +341 -0
- data/lib/brainzlab/rails/railtie.rb +590 -0
- data/lib/brainzlab/recall/buffer.rb +64 -0
- data/lib/brainzlab/recall/client.rb +86 -0
- data/lib/brainzlab/recall/logger.rb +118 -0
- data/lib/brainzlab/recall/provisioner.rb +113 -0
- data/lib/brainzlab/recall.rb +155 -0
- data/lib/brainzlab/reflex/breadcrumbs.rb +55 -0
- data/lib/brainzlab/reflex/client.rb +85 -0
- data/lib/brainzlab/reflex/provisioner.rb +116 -0
- data/lib/brainzlab/reflex.rb +374 -0
- data/lib/brainzlab/version.rb +5 -0
- data/lib/brainzlab-sdk.rb +3 -0
- data/lib/brainzlab.rb +140 -0
- data/lib/generators/brainzlab/install/install_generator.rb +61 -0
- data/lib/generators/brainzlab/install/templates/brainzlab.rb.tt +77 -0
- 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
|