otel_beacon 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 87071db6d653ac58e3543e8913fb4fec68ead62da10b082d29df4febed98a9ee
4
+ data.tar.gz: 352b451298dff4b8c68974eb425f1197f590f0b43fad5433b191d04822b8025d
5
+ SHA512:
6
+ metadata.gz: 8c09144054ee299d399a2e5f88bc0de27af87383c45920a50ad358307eca420385a7c01f09dd88bd4e41765683b8abe689dc6ee5167d061adb4032317d4b1f45
7
+ data.tar.gz: c785d4c5067ca5cf533d970278c99989f10d4c617fddfa64d45cfa3ad3c004bff3ed5f1665a099dc6928293a4ed725717e9731d464df2ddf63a908627eeea792
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2024-01-29
4
+
5
+ ### Added
6
+ - Initial release
7
+ - Sentry-style user context (`set_user`)
8
+ - Tags support (`set_tags`)
9
+ - Extra data support (`set_extra`)
10
+ - Breadcrumbs (`add_breadcrumb`)
11
+ - Structured contexts (`set_context`)
12
+ - Fingerprinting for error grouping (`set_fingerprint`)
13
+ - Scoped context (`with_scope`)
14
+ - Manual exception capture (`capture_exception`)
15
+ - Message capture (`capture_message`)
16
+ - Custom spans (`in_span`)
17
+ - Rails integration (controllers, jobs, mailers)
18
+ - Sidekiq integration
19
+ - Runtime context (Ruby, OS, app, device, browser)
20
+ - Configurable current user detection
21
+ - Parameter sanitization
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # OtelBeacon
2
+
3
+ Sentry-style error tracking and context for OpenTelemetry. Get the familiar Sentry developer experience (breadcrumbs, user context, tags, fingerprinting) with any OpenTelemetry backend like SigNoz, Jaeger, Honeycomb, or Grafana Tempo.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'otel_beacon'
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ruby
16
+ OtelBeacon.service_name = "my-app"
17
+ OtelBeacon.service_version = "1.0.0"
18
+ OtelBeacon.environment = Rails.env
19
+ OtelBeacon.current_user_method = :current_user
20
+ ```
21
+
22
+ User detection supports multiple formats:
23
+ - Symbol: `:current_user` (calls method on controller)
24
+ - String: `"Current.user"` (for CurrentAttributes)
25
+ - Proc: `-> (ctx) { ctx.session[:user] }` (custom logic)
26
+
27
+ ## Usage
28
+
29
+ ### User Context
30
+
31
+ ```ruby
32
+ OtelBeacon.set_user(
33
+ id: user.id,
34
+ email: user.email,
35
+ username: user.name,
36
+ subscription: "premium"
37
+ )
38
+ ```
39
+
40
+ Custom attributes beyond id/email/username are also supported.
41
+
42
+ ### Tags
43
+
44
+ ```ruby
45
+ OtelBeacon.set_tags(
46
+ feature: "checkout",
47
+ priority: "high",
48
+ release: "1.2.3"
49
+ )
50
+ ```
51
+
52
+ ### Extra Data
53
+
54
+ ```ruby
55
+ OtelBeacon.set_extra(
56
+ cart_id: 123,
57
+ items_count: 5,
58
+ total_amount: 99.99
59
+ )
60
+ ```
61
+
62
+ ### Breadcrumbs
63
+
64
+ ```ruby
65
+ OtelBeacon.add_breadcrumb(:http, "GET /api/users", status: 200, duration: 150)
66
+ OtelBeacon.add_breadcrumb(:user_action, "Clicked checkout button")
67
+ OtelBeacon.add_breadcrumb(:navigation, "Navigated to /checkout")
68
+ ```
69
+
70
+ ### Structured Contexts
71
+
72
+ ```ruby
73
+ OtelBeacon.set_context(:payment, provider: "stripe", amount: 99.99)
74
+ OtelBeacon.set_context(:feature_flags, dark_mode: true, beta: false)
75
+ ```
76
+
77
+ ### Fingerprinting (Error Grouping)
78
+
79
+ ```ruby
80
+ OtelBeacon.set_fingerprint("payment", "stripe", "card-declined")
81
+ ```
82
+
83
+ ### Scoped Context
84
+
85
+ ```ruby
86
+ OtelBeacon.with_scope do |scope|
87
+ scope.set_tags(feature: "checkout")
88
+ scope.set_extra(cart_id: 123)
89
+ scope.set_fingerprint("checkout", "payment-error")
90
+ process_checkout
91
+ end
92
+ ```
93
+
94
+ Any exception within the block gets the scoped context. Context is automatically restored after the block.
95
+
96
+ ### Manual Exception Capture
97
+
98
+ ```ruby
99
+ begin
100
+ risky_operation
101
+ rescue => e
102
+ OtelBeacon.capture_exception(e,
103
+ extra: { operation: "payment" },
104
+ fingerprint: ["payment", "timeout"]
105
+ )
106
+ raise
107
+ end
108
+ ```
109
+
110
+ ### Custom Spans
111
+
112
+ ```ruby
113
+ OtelBeacon.in_span("payment.process", attributes: { "payment.provider" => "stripe" }) do |span|
114
+ result = process_payment
115
+ span.add_event("payment.complete", attributes: { "payment.id" => result.id })
116
+ end
117
+ ```
118
+
119
+ ### Message Capture
120
+
121
+ ```ruby
122
+ OtelBeacon.capture_message("User completed onboarding", level: :info, tags: { flow: "signup" })
123
+ ```
124
+
125
+ ## Rails Integration
126
+
127
+ OtelBeacon automatically integrates with Rails when the Railtie loads:
128
+
129
+ - **Controllers**: Auto-captures user, request context, and exceptions
130
+ - **Jobs**: Auto-captures job metadata and exceptions
131
+ - **Mailers**: Auto-captures mailer context and exceptions
132
+
133
+ ### Configuration
134
+
135
+ **User detection:**
136
+ ```ruby
137
+ OtelBeacon.current_user_method = :current_user
138
+ OtelBeacon.user_attributes = {
139
+ id: :id,
140
+ email: :email,
141
+ username: %i[username name]
142
+ }
143
+ ```
144
+
145
+ The `user_attributes` mapping tries each method in order until one returns a value.
146
+
147
+ **Sanitization:**
148
+ ```ruby
149
+ OtelBeacon.sanitize_fields = %w[password token secret key auth credential api_key access_token]
150
+ ```
151
+
152
+ **Limits:**
153
+ ```ruby
154
+ OtelBeacon.max_breadcrumbs = 50
155
+ OtelBeacon.max_stacktrace_lines = 20
156
+ ```
157
+
158
+ **Hooks:**
159
+ ```ruby
160
+ OtelBeacon.before_capture = ->(event) { ... }
161
+ OtelBeacon.before_breadcrumb = ->(crumb) { ... }
162
+ ```
163
+
164
+ ## Sidekiq Integration
165
+
166
+ Automatically enabled when Sidekiq is present. For each job, OtelBeacon sets:
167
+
168
+ - **Tags**: job_class, queue, job_id
169
+ - **Context (:sidekiq)**: queue, retry_count, created_at, enqueued_at
170
+ - **On exceptions**: filtered job arguments are captured
171
+
172
+ ## Runtime Context
173
+
174
+ Automatically captured at startup:
175
+ - **:runtime** - Ruby version, platform, engine, engine_version
176
+ - **:os** - OS name, version, kernel_version
177
+ - **:app** - Service name, version, environment, Rails version (if Rails)
178
+
179
+ Captured per request (in controllers):
180
+ - **:device** - User agent, IP address
181
+ - **:browser** - Browser name/version (parsed from user agent)
182
+
183
+ ## Comparison with Sentry
184
+
185
+ | Feature | Sentry | OtelBeacon |
186
+ |---------|--------|------------|
187
+ | User context | ✅ | ✅ |
188
+ | Tags | ✅ | ✅ |
189
+ | Extra data | ✅ | ✅ |
190
+ | Breadcrumbs | ✅ | ✅ |
191
+ | Fingerprinting | ✅ | ✅ |
192
+ | Scopes | ✅ | ✅ |
193
+ | Contexts | ✅ | ✅ |
194
+ | Rails integration | ✅ | ✅ |
195
+ | Sidekiq integration | ✅ | ✅ |
196
+ | Backend | Sentry.io | Any OTel backend |
197
+
198
+ ## Viewing Sentry-style Data in SigNoz
199
+
200
+ OtelBeacon stores all Sentry-style context as OpenTelemetry span attributes and events. Here's how to find them in SigNoz:
201
+
202
+ ### Traces View
203
+
204
+ 1. Go to **Traces** in SigNoz
205
+ 2. Click on any trace/span to see details
206
+ 3. Look in the **Attributes** tab for:
207
+
208
+ | Attribute | Description |
209
+ |-----------|-------------|
210
+ | `user.id`, `user.email`, `user.username` | User context |
211
+ | `tag.*` | Custom tags (e.g., `tag.feature`, `tag.priority`) |
212
+ | `extra` | Extra data as JSON |
213
+ | `error.fingerprint` | Custom error grouping |
214
+ | `context.*.* ` | Structured contexts (e.g., `context.payment.provider`) |
215
+ | `environment`, `service.name`, `service.version` | Environment info |
216
+
217
+ ### Breadcrumbs
218
+
219
+ Breadcrumbs appear as **span events** named `breadcrumb`:
220
+
221
+ 1. In trace details, look at the **Events** section
222
+ 2. Each breadcrumb has attributes:
223
+ - `breadcrumb.category` - Category (http, user_action, etc.)
224
+ - `breadcrumb.message` - Description
225
+ - `breadcrumb.level` - Severity (info, warning, error)
226
+ - `breadcrumb.data` - Additional data as JSON
227
+
228
+ ### Exceptions
229
+
230
+ Exceptions are recorded with full context:
231
+
232
+ 1. Filter traces by `status: error` or look for spans with exceptions
233
+ 2. In span details, find:
234
+ - `exception.type` - Exception class name
235
+ - `exception.message` - Error message
236
+ - `exception.stacktrace` - Stack trace
237
+ - All user/tags/extra context attached
238
+
239
+ ### Querying in SigNoz
240
+
241
+ Use the query builder to filter by OtelBeacon attributes:
242
+
243
+ | Query | Description |
244
+ |-------|-------------|
245
+ | `user.id = "123" AND status = error` | Find all errors for a specific user |
246
+ | `tag.feature = "checkout"` | Find traces with specific tag |
247
+ | `error.fingerprint CONTAINS "payment"` | Find traces with fingerprint |
248
+
249
+ ### Logs View
250
+
251
+ Messages from `capture_message` appear in **Logs** with:
252
+ - `message.text` - Log message
253
+ - `message.level` - Log level
254
+ - All context attributes attached
255
+
256
+ ## License
257
+
258
+ MIT
data/UPGRADING.md ADDED
@@ -0,0 +1,60 @@
1
+ # Upgrading OtelBeacon
2
+
3
+ ## Version 0.1.0
4
+
5
+ This is the initial public release following solid_queue Rails patterns.
6
+
7
+ ### Configuration
8
+
9
+ Configuration uses direct attribute setters on the module:
10
+
11
+ ```ruby
12
+ OtelBeacon.enabled = true
13
+ OtelBeacon.service_name = "my_app"
14
+ OtelBeacon.max_breadcrumbs = 100
15
+ OtelBeacon.sanitize_fields = %w[password token secret]
16
+ ```
17
+
18
+ ### Rails Configuration
19
+
20
+ In Rails, you can also configure via `config/application.rb` or environment files:
21
+
22
+ ```ruby
23
+ config.otel_beacon.enabled = true
24
+ config.otel_beacon.service_name = "my_app"
25
+ config.otel_beacon.max_breadcrumbs = 100
26
+ ```
27
+
28
+ ### Predicate Methods
29
+
30
+ Boolean configuration options have predicate methods:
31
+
32
+ ```ruby
33
+ OtelBeacon.enabled?
34
+ OtelBeacon.capture_exceptions?
35
+ OtelBeacon.capture_logs?
36
+ ```
37
+
38
+ ### Error Reporting
39
+
40
+ Errors are reported via `Rails.error.report` when running in Rails. The `on_thread_error` callback is available for custom error handling:
41
+
42
+ ```ruby
43
+ OtelBeacon.on_thread_error = ->(exception) { Sentry.capture_exception(exception) }
44
+ ```
45
+
46
+ ### Instrumentation
47
+
48
+ ActiveSupport::Notifications events are available:
49
+
50
+ - `capture_exception.otel_beacon`
51
+ - `capture_message.otel_beacon`
52
+ - `add_breadcrumb.otel_beacon`
53
+
54
+ Subscribe to events:
55
+
56
+ ```ruby
57
+ ActiveSupport::Notifications.subscribe("capture_exception.otel_beacon") do |event|
58
+ puts "Exception captured: #{event.payload[:exception].class}"
59
+ end
60
+ ```
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ class Breadcrumb
5
+ attr_reader :category, :message, :level, :data, :timestamp
6
+
7
+ def initialize(category, message = nil, level: :info, **data)
8
+ @category = category.to_s
9
+ @message = message
10
+ @level = level.to_s
11
+ @data = data
12
+ @timestamp = Time.now.iso8601
13
+ end
14
+
15
+ def to_h
16
+ { category: @category, message: @message, level: @level, data: @data, timestamp: @timestamp }
17
+ end
18
+
19
+ class << self
20
+ def info(category, message = nil, **data) = new(category, message, level: :info, **data)
21
+ def warn(category, message = nil, **data) = new(category, message, level: :warn, **data)
22
+ def error(category, message = nil, **data) = new(category, message, level: :error, **data)
23
+ def debug(category, message = nil, **data) = new(category, message, level: :debug, **data)
24
+
25
+ def build(category:, message: nil, level: :info, data: {}, **extra)
26
+ new(category, message, level: level, **data.merge(extra))
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Client
5
+ class << self
6
+ def reset_context!
7
+ Context.reset!
8
+ end
9
+
10
+ def set_user(id: nil, email: nil, username: nil, ip_address: nil, **extra)
11
+ Context.current[:user] =
12
+ { id: id, email: email, username: username, ip_address: ip_address }.compact.merge(extra)
13
+ end
14
+
15
+ def set_tags(**tags)
16
+ Context.tags.merge!(tags.transform_values(&:to_s))
17
+ end
18
+
19
+ def set_extra(**extra)
20
+ Context.extra.merge!(extra)
21
+ end
22
+
23
+ def set_context(name, **data)
24
+ Context.contexts[name.to_sym] = data
25
+ end
26
+
27
+ def set_fingerprint(*fingerprint)
28
+ Context.fingerprint = fingerprint.flatten
29
+ end
30
+
31
+ def with_scope
32
+ Context.push_scope
33
+ scope = Scope.new
34
+ yield scope
35
+ ensure
36
+ Context.pop_scope
37
+ end
38
+
39
+ def add_breadcrumb(category_or_crumb, message = nil, level: :info, **data)
40
+ crumb = if category_or_crumb.is_a?(Breadcrumb)
41
+ category_or_crumb.to_h
42
+ else
43
+ Breadcrumb.new(category_or_crumb, message, level: level, **data).to_h
44
+ end
45
+ Context.breadcrumbs << crumb
46
+ Context.breadcrumbs.shift if Context.breadcrumbs.size > OtelBeacon.max_breadcrumbs
47
+ end
48
+
49
+ def tracer
50
+ OpenTelemetry.tracer_provider.tracer(OtelBeacon.service_name, OtelBeacon::VERSION)
51
+ end
52
+
53
+ def in_span(name, attributes: {}, record_exception: true)
54
+ tracer.in_span(name, attributes: attributes) do |span|
55
+ yield span
56
+ rescue StandardError => e
57
+ if record_exception
58
+ span.record_exception(e)
59
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
60
+ end
61
+ raise
62
+ end
63
+ end
64
+
65
+ def capture_message(message, level: :info, tags: nil, extra: nil, user: nil, request: nil, fingerprint: nil)
66
+ return unless OtelBeacon.enabled?
67
+ return unless otel_logger_available?
68
+
69
+ attrs = build_message_attributes(
70
+ message: message, level: level, tags: tags, extra: extra,
71
+ user: user, request: request, fingerprint: fingerprint
72
+ )
73
+
74
+ current_span = OpenTelemetry::Trace.current_span
75
+ emit_breadcrumbs_to_span(current_span) if current_span&.recording?
76
+
77
+ otel_logger.on_emit(
78
+ severity_text: level.to_s.upcase,
79
+ body: message,
80
+ attributes: attrs,
81
+ context: OpenTelemetry::Context.current
82
+ )
83
+
84
+ OtelBeacon.instrument(:capture_message, message: message, level: level)
85
+ rescue StandardError
86
+ end
87
+
88
+ def capture_exception(exception, request: nil, params: nil, user: nil, tags: nil, extra: nil, fingerprint: nil)
89
+ return unless OtelBeacon.enabled?
90
+
91
+ current_span = OpenTelemetry::Trace.current_span
92
+ return unless current_span&.recording?
93
+
94
+ exc_attrs = build_exception_attributes(
95
+ exception: exception, request: request, params: params,
96
+ user: user, tags: tags, extra: extra, fingerprint: fingerprint
97
+ )
98
+
99
+ set_span_attributes(current_span, exc_attrs)
100
+ emit_breadcrumbs_to_span(current_span)
101
+ current_span.record_exception(exception, attributes: exc_attrs)
102
+ current_span.status = OpenTelemetry::Trace::Status.error(exception.message)
103
+
104
+ log_exception(exception, exc_attrs)
105
+ OtelBeacon.instrument(:capture_exception, exception: exception)
106
+ end
107
+
108
+ def record_event(name, attributes: {})
109
+ current_span = OpenTelemetry::Trace.current_span
110
+ return unless current_span&.recording?
111
+
112
+ current_span.add_event(name, attributes: attributes)
113
+ end
114
+
115
+ def flush_context_to_span
116
+ current_span = OpenTelemetry::Trace.current_span
117
+ return unless current_span&.recording?
118
+
119
+ attrs = {}
120
+ add_user_attributes(attrs, nil)
121
+ add_tag_attributes(attrs, nil)
122
+ add_extra_attributes(attrs, nil)
123
+ add_context_attributes(attrs)
124
+ add_environment_attributes(attrs)
125
+
126
+ set_span_attributes(current_span, attrs)
127
+ emit_breadcrumbs_to_span(current_span)
128
+ end
129
+
130
+ private
131
+
132
+ def otel_logger_available?
133
+ OpenTelemetry.logger_provider&.respond_to?(:logger)
134
+ end
135
+
136
+ def otel_logger
137
+ OpenTelemetry.logger_provider.logger(name: OtelBeacon.service_name, version: OtelBeacon::VERSION)
138
+ end
139
+
140
+ def emit_breadcrumbs_to_span(span)
141
+ Context.breadcrumbs.each_with_index do |crumb, idx|
142
+ span.add_event("breadcrumb", attributes: {
143
+ "breadcrumb.index" => idx,
144
+ "breadcrumb.category" => crumb[:category],
145
+ "breadcrumb.message" => crumb[:message],
146
+ "breadcrumb.level" => crumb[:level],
147
+ "breadcrumb.timestamp" => crumb[:timestamp],
148
+ "breadcrumb.data" => crumb[:data].to_json
149
+ })
150
+ end
151
+ end
152
+
153
+ def build_message_attributes(message:, level:, tags: nil, extra: nil, user: nil, request: nil, fingerprint: nil)
154
+ attrs = { "message.text" => message, "message.level" => level.to_s }
155
+ attrs["message.fingerprint"] = fingerprint.join(":") if fingerprint.is_a?(Array)
156
+
157
+ add_request_attributes(attrs, request) if request
158
+ add_user_attributes(attrs, user)
159
+ add_tag_attributes(attrs, tags)
160
+ add_extra_attributes(attrs, extra)
161
+ add_environment_attributes(attrs)
162
+
163
+ attrs.compact
164
+ end
165
+
166
+ def build_exception_attributes(exception:, request: nil, params: nil, user: nil, tags: nil, extra: nil,
167
+ fingerprint: nil)
168
+ attrs = {
169
+ "exception.type" => exception.class.name,
170
+ "exception.message" => exception.message
171
+ }
172
+ if exception.backtrace
173
+ attrs["exception.stacktrace"] = exception.backtrace.first(OtelBeacon.max_stacktrace_lines).join("\n")
174
+ end
175
+
176
+ add_request_attributes(attrs, request) if request
177
+ add_params_attributes(attrs, params) if params
178
+ add_user_attributes(attrs, user)
179
+ add_tag_attributes(attrs, tags)
180
+ add_extra_attributes(attrs, extra)
181
+ add_context_attributes(attrs)
182
+ add_fingerprint_attributes(attrs, fingerprint)
183
+ add_environment_attributes(attrs)
184
+ attrs["service.version"] = OtelBeacon.service_version
185
+
186
+ attrs.compact
187
+ end
188
+
189
+ def add_request_attributes(attrs, request)
190
+ attrs["http.method"] = begin
191
+ request.request_method
192
+ rescue StandardError
193
+ nil
194
+ end
195
+ attrs["http.url"] = begin
196
+ request.original_url
197
+ rescue StandardError
198
+ nil
199
+ end
200
+ attrs["http.host"] = begin
201
+ request.host
202
+ rescue StandardError
203
+ nil
204
+ end
205
+ attrs["http.path"] = begin
206
+ request.path
207
+ rescue StandardError
208
+ nil
209
+ end
210
+ attrs["http.user_agent"] = begin
211
+ request.user_agent
212
+ rescue StandardError
213
+ nil
214
+ end
215
+ attrs["http.referer"] = begin
216
+ request.referer
217
+ rescue StandardError
218
+ nil
219
+ end
220
+ attrs["client.ip"] = begin
221
+ request.remote_ip
222
+ rescue StandardError
223
+ nil
224
+ end
225
+ attrs["http.request_id"] = begin
226
+ request.request_id
227
+ rescue StandardError
228
+ nil
229
+ end
230
+ end
231
+
232
+ def add_params_attributes(attrs, params)
233
+ sanitized = Sanitizer.extract_params(params)
234
+ attrs["request.params"] = sanitized.to_json unless sanitized.empty?
235
+ end
236
+
237
+ def add_user_attributes(attrs, user)
238
+ merged = Context.user.merge(user || {})
239
+ return if merged.empty?
240
+
241
+ attrs["user.id"] = merged[:id].to_s if merged[:id]
242
+ attrs["user.email"] = merged[:email] if merged[:email]
243
+ attrs["user.username"] = merged[:username] if merged[:username]
244
+ attrs["user.ip_address"] = merged[:ip_address] if merged[:ip_address]
245
+ end
246
+
247
+ def add_tag_attributes(attrs, tags)
248
+ merged = Context.tags.merge(tags || {})
249
+ merged.each { |k, v| attrs["tag.#{k}"] = v.to_s }
250
+ end
251
+
252
+ def add_extra_attributes(attrs, extra)
253
+ merged = Context.extra.merge(extra || {})
254
+ attrs["extra"] = merged.to_json unless merged.empty?
255
+ end
256
+
257
+ def add_context_attributes(attrs)
258
+ Context.contexts.each do |name, data|
259
+ data.each { |k, v| attrs["context.#{name}.#{k}"] = v.to_s }
260
+ end
261
+ end
262
+
263
+ def add_fingerprint_attributes(attrs, fingerprint)
264
+ fp = fingerprint || Context.fingerprint
265
+ attrs["error.fingerprint"] = fp.join(":") if fp.is_a?(Array) && fp.any?
266
+ end
267
+
268
+ def add_environment_attributes(attrs)
269
+ attrs["environment"] = OtelBeacon.environment.to_s
270
+ attrs["service.name"] = OtelBeacon.service_name
271
+ end
272
+
273
+ def set_span_attributes(span, attrs)
274
+ attrs.each do |key, value|
275
+ next if value.nil?
276
+
277
+ span.set_attribute(key.to_s, value.to_s)
278
+ rescue StandardError
279
+ next
280
+ end
281
+ end
282
+
283
+ def log_exception(exception, attributes)
284
+ return unless otel_logger_available?
285
+
286
+ otel_logger.on_emit(
287
+ severity_text: "ERROR",
288
+ body: "#{exception.class}: #{exception.message}",
289
+ attributes: attributes,
290
+ context: OpenTelemetry::Context.current
291
+ )
292
+ rescue StandardError
293
+ end
294
+ end
295
+ end
296
+ end