rails_error_dashboard 0.7.2 → 0.8.1
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 +4 -4
- data/README.md +40 -1
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +35 -1
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +1 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +48 -2
- data/lib/rails_error_dashboard/commands/create_issue.rb +1 -1
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +3 -1
- data/lib/rails_error_dashboard/commands/log_error.rb +54 -2
- data/lib/rails_error_dashboard/configuration.rb +50 -7
- data/lib/rails_error_dashboard/engine.rb +14 -0
- data/lib/rails_error_dashboard/integrations/tracer.rb +195 -0
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +45 -5
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +37 -15
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +8 -5
- data/lib/rails_error_dashboard/services/linear_issue_client.rb +248 -0
- data/lib/rails_error_dashboard/services/system_health_snapshot.rb +10 -1
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 532921b0ba2ccb40a532a66e5e3c7b1fcf17fc56e53cb5b0ed3772648644f0c0
|
|
4
|
+
data.tar.gz: 282169f161d90326aaebce5933521cd54c8bb373611e9002b447f114b6654204
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63c2ba5ef4319eaff8450837fec526135e37865ea0d2c72c30b7010ff6e86c03e49f728172d032bec6ad626f8dba39d00841300839fee9b8a493caa912669559
|
|
7
|
+
data.tar.gz: c84238b00e2f6959fd1e4ac5a1c9ff2d696a0d28379a102e99d761585caf4c24a4f6446f4d303d69c933e5135b31e7c3ef1d0a6b7464376e88ad25b14068c75a
|
data/README.md
CHANGED
|
@@ -233,7 +233,7 @@ Payload contract matches the `LlmCallEvent` value object — see [`docs/LLM_OBSE
|
|
|
233
233
|
</details>
|
|
234
234
|
|
|
235
235
|
<details>
|
|
236
|
-
<summary><strong>Issue Tracking — GitHub, GitLab, Codeberg</strong></summary>
|
|
236
|
+
<summary><strong>Issue Tracking — GitHub, GitLab, Codeberg, Linear</strong></summary>
|
|
237
237
|
|
|
238
238
|
One switch connects errors to your issue tracker. Platform becomes the source of truth — status, assignees, labels, and comments are mirrored live in the dashboard.
|
|
239
239
|
|
|
@@ -250,6 +250,17 @@ config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
|
|
|
250
250
|
# That's it — provider and repo auto-detected from git_repository_url
|
|
251
251
|
```
|
|
252
252
|
|
|
253
|
+
Linear works too — it's not a git forge, so set the provider and team key explicitly:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
config.enable_issue_tracking = true
|
|
257
|
+
config.issue_tracker_provider = :linear
|
|
258
|
+
config.issue_tracker_repo = "ENG" # Linear team key (issues land as ENG-123)
|
|
259
|
+
config.issue_tracker_token = ENV["RED_BOT_TOKEN"] # lin_api_... personal API key
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Closing maps to the team's first `completed` workflow state, reopening to `unstarted`/`backlog`. Two-way sync uses Linear webhooks (`Linear-Signature` HMAC verification).
|
|
263
|
+
|
|
253
264
|
[Complete documentation →](docs/guides/CONFIGURATION.md)
|
|
254
265
|
</details>
|
|
255
266
|
|
|
@@ -475,6 +486,32 @@ end
|
|
|
475
486
|
[Plugin System guide →](docs/PLUGIN_SYSTEM.md)
|
|
476
487
|
</details>
|
|
477
488
|
|
|
489
|
+
<details>
|
|
490
|
+
<summary><strong>OpenTelemetry Export — Emit Gem Operations as Spans</strong></summary>
|
|
491
|
+
|
|
492
|
+
Send the gem's error-capture pipeline as OpenTelemetry spans to your existing Datadog, Honeycomb, or Jaeger collector. Each stage of the capture path — DB write, breadcrumb harvest, system health snapshot, and notification dispatch — becomes a named child span so you can audit gem overhead from your own observability dashboards.
|
|
493
|
+
|
|
494
|
+
- Off by default — zero impact unless you opt in
|
|
495
|
+
- No-op when the OTel API gem isn't loaded
|
|
496
|
+
- Per-span-kind opt-in: enable only the stages you care about
|
|
497
|
+
- Every span individually rescue-wrapped — never raises into host code
|
|
498
|
+
- Boot-time warning if `enable_otel_export = true` but `opentelemetry-api` isn't in the Gemfile
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
# Gemfile — only the API gem is required; the SDK is optional
|
|
502
|
+
gem "opentelemetry-api"
|
|
503
|
+
|
|
504
|
+
# config/initializers/rails_error_dashboard.rb
|
|
505
|
+
config.enable_otel_export = true
|
|
506
|
+
config.otel_service_name = "my-app" # falls back to application_name
|
|
507
|
+
config.otel_spans = [:capture, :breadcrumbs, :health, :notifications] # all (default)
|
|
508
|
+
# config.otel_spans = [:capture] # parent span only
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Span names follow the `rails_error_dashboard.<operation>` convention, e.g. `rails_error_dashboard.capture_error`. Both attributes are attached to every span: `rails_error_dashboard.version` and `rails_error_dashboard.service_name` — use them to filter the gem's traffic in your dashboards.
|
|
512
|
+
|
|
513
|
+
</details>
|
|
514
|
+
|
|
478
515
|
---
|
|
479
516
|
|
|
480
517
|
## Quick Start
|
|
@@ -539,6 +576,8 @@ end
|
|
|
539
576
|
|
|
540
577
|
**Multi-App Support** — Track errors from multiple Rails apps in a single shared database. Auto-detects app name, supports per-app filtering. [Multi-App guide →](docs/MULTI_APP_PERFORMANCE.md)
|
|
541
578
|
|
|
579
|
+
**OpenTelemetry Export** — Emit error-capture operations as OTel spans to Datadog, Honeycomb, or Jaeger. Add `gem "opentelemetry-api"` and set `config.enable_otel_export = true`. See [OpenTelemetry Export](#opentelemetry-export--emit-gem-operations-as-spans) above for full options.
|
|
580
|
+
|
|
542
581
|
---
|
|
543
582
|
|
|
544
583
|
## Documentation
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
|
-
# Receives webhooks from GitHub/GitLab/Codeberg for two-way issue sync.
|
|
4
|
+
# Receives webhooks from GitHub/GitLab/Codeberg/Linear for two-way issue sync.
|
|
5
5
|
#
|
|
6
6
|
# When an issue is closed/reopened on the platform, the corresponding
|
|
7
7
|
# error in the dashboard is resolved/reopened to match.
|
|
@@ -10,6 +10,7 @@ module RailsErrorDashboard
|
|
|
10
10
|
# - GitHub: X-Hub-Signature-256 (HMAC-SHA256)
|
|
11
11
|
# - GitLab: X-Gitlab-Token (shared secret)
|
|
12
12
|
# - Codeberg: X-Gitea-Signature (HMAC-SHA256)
|
|
13
|
+
# - Linear: Linear-Signature (HMAC-SHA256)
|
|
13
14
|
class WebhooksController < ActionController::Base
|
|
14
15
|
skip_before_action :verify_authenticity_token
|
|
15
16
|
|
|
@@ -29,6 +30,8 @@ module RailsErrorDashboard
|
|
|
29
30
|
handle_gitlab(payload)
|
|
30
31
|
when "codeberg"
|
|
31
32
|
handle_codeberg(payload)
|
|
33
|
+
when "linear"
|
|
34
|
+
handle_linear(payload)
|
|
32
35
|
else
|
|
33
36
|
head :not_found
|
|
34
37
|
return
|
|
@@ -69,6 +72,8 @@ module RailsErrorDashboard
|
|
|
69
72
|
verify_gitlab_token(secret)
|
|
70
73
|
when "codeberg"
|
|
71
74
|
verify_codeberg_signature(body, secret)
|
|
75
|
+
when "linear"
|
|
76
|
+
verify_linear_signature(body, secret)
|
|
72
77
|
else
|
|
73
78
|
false
|
|
74
79
|
end
|
|
@@ -99,6 +104,14 @@ module RailsErrorDashboard
|
|
|
99
104
|
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
100
105
|
end
|
|
101
106
|
|
|
107
|
+
def verify_linear_signature(body, secret)
|
|
108
|
+
signature = request.headers["Linear-Signature"]
|
|
109
|
+
return false unless signature
|
|
110
|
+
|
|
111
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
|
|
112
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
113
|
+
end
|
|
114
|
+
|
|
102
115
|
def parse_payload
|
|
103
116
|
JSON.parse(request.body.read)
|
|
104
117
|
rescue JSON::ParserError
|
|
@@ -162,6 +175,27 @@ module RailsErrorDashboard
|
|
|
162
175
|
end
|
|
163
176
|
end
|
|
164
177
|
|
|
178
|
+
# Linear: Issue webhook fires with action create/update/remove. State changes
|
|
179
|
+
# arrive as updates with the old stateId in updatedFrom. Linear has no
|
|
180
|
+
# open/closed binary — completed/canceled state types map to resolved.
|
|
181
|
+
def handle_linear(payload)
|
|
182
|
+
return unless payload["type"] == "Issue" && payload["action"] == "update"
|
|
183
|
+
return unless payload["updatedFrom"]&.key?("stateId")
|
|
184
|
+
|
|
185
|
+
issue_number = payload.dig("data", "number")
|
|
186
|
+
return unless issue_number
|
|
187
|
+
|
|
188
|
+
error = find_error_by_issue(issue_number, "linear")
|
|
189
|
+
return unless error
|
|
190
|
+
|
|
191
|
+
state_type = payload.dig("data", "state", "type")
|
|
192
|
+
if state_type.in?(%w[completed canceled])
|
|
193
|
+
resolve_error(error, "Completed on Linear by #{payload.dig("actor", "name")}")
|
|
194
|
+
elsif state_type.in?(%w[triage backlog unstarted started])
|
|
195
|
+
reopen_error(error)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
165
199
|
def find_error_by_issue(issue_number, provider)
|
|
166
200
|
ErrorLog.find_by(
|
|
167
201
|
external_issue_number: issue_number,
|
|
@@ -484,7 +484,42 @@ RailsErrorDashboard.configure do |config|
|
|
|
484
484
|
# config.llm_system_prompt = "Prefer concise answers with file-level next steps."
|
|
485
485
|
|
|
486
486
|
# ============================================================================
|
|
487
|
-
#
|
|
487
|
+
# OPENTELEMETRY EXPORT (OUTBOUND)
|
|
488
|
+
# ============================================================================
|
|
489
|
+
#
|
|
490
|
+
# Emit gem operations as OpenTelemetry spans so the host's existing
|
|
491
|
+
# Datadog / Honeycomb / Jaeger / Grafana Tempo pipeline gets a trace
|
|
492
|
+
# of every error capture. Useful for:
|
|
493
|
+
# - Auditing "when did this error get captured?" against deploy events
|
|
494
|
+
# - Measuring how much time the gem spends in the capture path
|
|
495
|
+
# - Proving the <5ms host-safety budget from operator dashboards
|
|
496
|
+
#
|
|
497
|
+
# Emits four spans per error capture:
|
|
498
|
+
# rails_error_dashboard.capture_error — parent, wraps everything
|
|
499
|
+
# rails_error_dashboard.breadcrumb_collection — buffer drain (~µs)
|
|
500
|
+
# rails_error_dashboard.system_health_snapshot — GC.stat etc. (<1ms)
|
|
501
|
+
# rails_error_dashboard.notification_dispatch — Slack/email enqueue
|
|
502
|
+
#
|
|
503
|
+
# Disabled by default. Requires the host app to already run OpenTelemetry
|
|
504
|
+
# (the gem does NOT add an opentelemetry-* runtime dependency). When OTel
|
|
505
|
+
# is absent, every span call is a zero-overhead no-op.
|
|
506
|
+
#
|
|
507
|
+
# config.enable_otel_export = true
|
|
508
|
+
# config.otel_service_name = "my-app" # Falls back to application_name when nil
|
|
509
|
+
#
|
|
510
|
+
# Per-span opt-out: pass any subset to disable individual span kinds
|
|
511
|
+
# without code changes. Useful when e.g. notification dispatch is slow due
|
|
512
|
+
# to outbound HTTP and you don't want it polluting your trace dashboards.
|
|
513
|
+
#
|
|
514
|
+
# config.otel_spans = [:capture, :breadcrumbs, :health, :notifications] # all (default)
|
|
515
|
+
# config.otel_spans = [:capture] # parent only
|
|
516
|
+
# config.otel_spans = [:capture, :health] # parent + health
|
|
517
|
+
#
|
|
518
|
+
# No PII or request bodies in span attributes — just metadata + timing.
|
|
519
|
+
# Safe to enable on production OTel pipelines.
|
|
520
|
+
|
|
521
|
+
# ============================================================================
|
|
522
|
+
# ISSUE TRACKING (GitHub / GitLab / Codeberg / Linear)
|
|
488
523
|
# ============================================================================
|
|
489
524
|
#
|
|
490
525
|
# One switch enables everything: issue creation, auto-create on first
|
|
@@ -499,7 +534,7 @@ RailsErrorDashboard.configure do |config|
|
|
|
499
534
|
# - Priority → labels from platform (with colors)
|
|
500
535
|
# - Snooze and Mute remain (no platform equivalent)
|
|
501
536
|
#
|
|
502
|
-
# Setup:
|
|
537
|
+
# Setup (GitHub/GitLab/Codeberg):
|
|
503
538
|
# 1. Create a RED bot account on GitHub/GitLab/Codeberg
|
|
504
539
|
# 2. Generate a token and set RED_BOT_TOKEN env var
|
|
505
540
|
# 3. Set git_repository_url above (already used for source code linking)
|
|
@@ -508,6 +543,17 @@ RailsErrorDashboard.configure do |config|
|
|
|
508
543
|
# config.enable_issue_tracking = true
|
|
509
544
|
# config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
|
|
510
545
|
#
|
|
546
|
+
# Setup (Linear):
|
|
547
|
+
# Linear is not a git forge, so it cannot be auto-detected from
|
|
548
|
+
# git_repository_url — set provider and team key explicitly. Issues are
|
|
549
|
+
# created in the team matching the key (e.g. "ENG" for ENG-123 issues).
|
|
550
|
+
# Generate a personal API key under Settings > Security & access.
|
|
551
|
+
#
|
|
552
|
+
# config.enable_issue_tracking = true
|
|
553
|
+
# config.issue_tracker_provider = :linear
|
|
554
|
+
# config.issue_tracker_repo = "ENG" # Linear team key
|
|
555
|
+
# config.issue_tracker_token = ENV["RED_BOT_TOKEN"] # lin_api_... key
|
|
556
|
+
#
|
|
511
557
|
# Optional overrides:
|
|
512
558
|
# config.issue_tracker_labels = ["bug"] # Labels added to new issues
|
|
513
559
|
# config.issue_tracker_auto_create_severities = [:critical, :high] # Auto-create threshold
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
module Commands
|
|
5
|
-
# Command: Create an issue on the configured issue tracker (GitHub/GitLab/Codeberg)
|
|
5
|
+
# Command: Create an issue on the configured issue tracker (GitHub/GitLab/Codeberg/Linear)
|
|
6
6
|
#
|
|
7
7
|
# Creates the issue via the provider API, then stores the issue URL, number,
|
|
8
8
|
# and provider on the error record for linking.
|
|
@@ -14,7 +14,9 @@ module RailsErrorDashboard
|
|
|
14
14
|
PROVIDER_PATTERNS = {
|
|
15
15
|
github: %r{github\.com/([^/]+/[^/]+)/issues/(\d+)}i,
|
|
16
16
|
gitlab: %r{gitlab\.com/([^/]+/[^/]+)/-/issues/(\d+)}i,
|
|
17
|
-
codeberg: %r{codeberg\.org/([^/]+/[^/]+)/issues/(\d+)}i
|
|
17
|
+
codeberg: %r{codeberg\.org/([^/]+/[^/]+)/issues/(\d+)}i,
|
|
18
|
+
# https://linear.app/<workspace>/issue/ENG-123/<slug> — capture team key + number
|
|
19
|
+
linear: %r{linear\.app/[^/]+/issue/([A-Za-z][A-Za-z0-9]*)-(\d+)}i
|
|
18
20
|
}.freeze
|
|
19
21
|
|
|
20
22
|
def self.call(error_id, issue_url:)
|
|
@@ -17,6 +17,21 @@ module RailsErrorDashboard
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Build the base OTel span attributes available before any work happens.
|
|
21
|
+
# Kept as a module-level helper so both sync and async paths can call it.
|
|
22
|
+
# @return [Hash<String, Object>]
|
|
23
|
+
def self.build_capture_span_attributes(exception, was_async:)
|
|
24
|
+
msg = exception.message.to_s
|
|
25
|
+
{
|
|
26
|
+
"error.type" => exception.class.name,
|
|
27
|
+
"error.message" => msg.length > 200 ? "#{msg[0, 200]}…" : msg,
|
|
28
|
+
"rails_error_dashboard.environment" => (defined?(Rails) && Rails.env.to_s) || "unknown",
|
|
29
|
+
"rails_error_dashboard.was_async" => was_async
|
|
30
|
+
}
|
|
31
|
+
rescue StandardError
|
|
32
|
+
{ "error.type" => "unknown", "rails_error_dashboard.was_async" => was_async }
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
# Queue error logging as a background job
|
|
21
36
|
def self.call_async(exception, context = {})
|
|
22
37
|
# Serialize exception data for the job
|
|
@@ -68,7 +83,17 @@ module RailsErrorDashboard
|
|
|
68
83
|
# Enqueue the async job using ActiveJob
|
|
69
84
|
# The queue adapter (:sidekiq, :solid_queue, :async) is configured separately
|
|
70
85
|
begin
|
|
71
|
-
|
|
86
|
+
# OTel: emit a capture span around the enqueue itself. The real capture
|
|
87
|
+
# work runs in the job (which starts its own root span via .new(...).call).
|
|
88
|
+
# For the async path the span here measures *enqueue latency only* — used
|
|
89
|
+
# to detect queue-adapter backpressure or Redis slowness.
|
|
90
|
+
Integrations::Tracer.in_span(
|
|
91
|
+
"capture_error",
|
|
92
|
+
kind: :capture,
|
|
93
|
+
attributes: build_capture_span_attributes(exception, was_async: true)
|
|
94
|
+
) do |_span|
|
|
95
|
+
AsyncErrorLoggingJob.perform_later(exception_data, context)
|
|
96
|
+
end
|
|
72
97
|
rescue => e
|
|
73
98
|
# Queue adapter failed (e.g., Redis down for Sidekiq). Fall back to
|
|
74
99
|
# sync logging so the error is still captured. Without this rescue,
|
|
@@ -118,13 +143,31 @@ module RailsErrorDashboard
|
|
|
118
143
|
end
|
|
119
144
|
|
|
120
145
|
def call
|
|
146
|
+
# OTel: parent capture span. Wraps the entire sync capture path so
|
|
147
|
+
# operators can audit how long error capture takes from their existing
|
|
148
|
+
# tracing pipeline. Child spans (breadcrumbs, health, notifications)
|
|
149
|
+
# nest under this one automatically via OTel context propagation.
|
|
150
|
+
#
|
|
151
|
+
# The span lives INSIDE the rescue clause — if the span itself raises
|
|
152
|
+
# somehow, the outer rescue still catches it and returns nil. Defense
|
|
153
|
+
# in depth. When the block raises, the Tracer façade records the
|
|
154
|
+
# exception on the span and re-raises so the rescue can swallow it.
|
|
155
|
+
Integrations::Tracer.in_span(
|
|
156
|
+
"capture_error",
|
|
157
|
+
kind: :capture,
|
|
158
|
+
attributes: self.class.build_capture_span_attributes(@exception, was_async: false)
|
|
159
|
+
) do |span|
|
|
121
160
|
# Check if this exception should be logged (ignore list + sampling)
|
|
122
|
-
|
|
161
|
+
if !Services::ExceptionFilter.should_log?(@exception)
|
|
162
|
+
span&.set_attribute("rails_error_dashboard.filtered", true)
|
|
163
|
+
next nil
|
|
164
|
+
end
|
|
123
165
|
|
|
124
166
|
error_context = ValueObjects::ErrorContext.new(@context, @context[:source])
|
|
125
167
|
|
|
126
168
|
# Find or create application (cached lookup)
|
|
127
169
|
application = find_or_create_application
|
|
170
|
+
span&.set_attribute("rails_error_dashboard.application", application.name.to_s) if application.respond_to?(:name)
|
|
128
171
|
|
|
129
172
|
# Build error attributes
|
|
130
173
|
truncated_backtrace = Services::BacktraceProcessor.truncate(@exception.backtrace)
|
|
@@ -262,6 +305,14 @@ module RailsErrorDashboard
|
|
|
262
305
|
# This ensures accurate occurrence tracking
|
|
263
306
|
error_log = ErrorLog.find_or_increment_by_hash(error_hash, attributes.merge(error_hash: error_hash))
|
|
264
307
|
|
|
308
|
+
# OTel: now that the error_log exists, attach its id + dedup flag + severity
|
|
309
|
+
# to the parent capture span so operators can correlate to dashboard URLs.
|
|
310
|
+
if span && error_log
|
|
311
|
+
span.set_attribute("rails_error_dashboard.error_id", error_log.id) if error_log.id
|
|
312
|
+
span.set_attribute("rails_error_dashboard.deduplicated", error_log.occurrence_count.to_i > 1)
|
|
313
|
+
span.set_attribute("rails_error_dashboard.severity", error_log.severity.to_s) if error_log.respond_to?(:severity) && error_log.severity
|
|
314
|
+
end
|
|
315
|
+
|
|
265
316
|
# Track individual error occurrence for co-occurrence analysis (if table exists)
|
|
266
317
|
if defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
|
|
267
318
|
begin
|
|
@@ -298,6 +349,7 @@ module RailsErrorDashboard
|
|
|
298
349
|
check_baseline_anomaly(error_log)
|
|
299
350
|
|
|
300
351
|
error_log
|
|
352
|
+
end
|
|
301
353
|
rescue => e
|
|
302
354
|
# Don't let error logging cause more errors - fail silently
|
|
303
355
|
# CRITICAL: Log but never propagate exception
|
|
@@ -92,8 +92,8 @@ module RailsErrorDashboard
|
|
|
92
92
|
# issue_webhook_secret is set.
|
|
93
93
|
attr_accessor :enable_issue_tracking # Master switch (default: false) — enables all platform integration
|
|
94
94
|
attr_accessor :issue_tracker_token # String or lambda/proc for Rails credentials
|
|
95
|
-
attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url)
|
|
96
|
-
attr_accessor :issue_tracker_repo # "owner/repo" (auto-extracted from git_repository_url)
|
|
95
|
+
attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url), or :linear (explicit only)
|
|
96
|
+
attr_accessor :issue_tracker_repo # "owner/repo" (auto-extracted from git_repository_url), or Linear team key like "ENG"
|
|
97
97
|
attr_accessor :issue_tracker_labels # Array of label strings (default: ["bug"])
|
|
98
98
|
attr_accessor :issue_tracker_api_url # Custom API base URL for self-hosted instances
|
|
99
99
|
attr_accessor :issue_tracker_auto_create_severities # Auto-create for these severities (default: [:critical, :high])
|
|
@@ -189,6 +189,14 @@ module RailsErrorDashboard
|
|
|
189
189
|
attr_accessor :llm_observability_content_capture # Capture prompt/completion text (default: false — PII risk)
|
|
190
190
|
attr_accessor :llm_pricing_overrides # Hash of { "model-name" => { input: usd_per_1m, output: usd_per_1m } }
|
|
191
191
|
|
|
192
|
+
# OpenTelemetry outbound export — emit gem operations as OTel spans for
|
|
193
|
+
# Datadog/Honeycomb/Jaeger. Requires the host app to already run OTel.
|
|
194
|
+
# When OTel is absent OR enable_otel_export is false, all emit calls
|
|
195
|
+
# are no-ops with zero overhead.
|
|
196
|
+
attr_accessor :enable_otel_export # Master switch (default: false)
|
|
197
|
+
attr_accessor :otel_service_name # Falls back to application_name when nil
|
|
198
|
+
attr_accessor :otel_spans # Array of enabled span kinds — see Integrations::Tracer::ALL_SPAN_KINDS
|
|
199
|
+
|
|
192
200
|
# Dashboard UI appearance
|
|
193
201
|
attr_accessor :accent_color # :crimson (default), :ruby, :ember, :violet
|
|
194
202
|
|
|
@@ -372,6 +380,13 @@ module RailsErrorDashboard
|
|
|
372
380
|
@llm_observability_content_capture = false
|
|
373
381
|
@llm_pricing_overrides = {}
|
|
374
382
|
|
|
383
|
+
# OTel outbound export defaults — OFF (opt-in). All four span kinds enabled
|
|
384
|
+
# by default once master switch flips on; users can pass a subset to opt out
|
|
385
|
+
# of e.g. notification spans without code changes.
|
|
386
|
+
@enable_otel_export = false
|
|
387
|
+
@otel_service_name = nil
|
|
388
|
+
@otel_spans = %i[capture breadcrumbs health notifications]
|
|
389
|
+
|
|
375
390
|
# Internal logging defaults - SILENT by default
|
|
376
391
|
@enable_internal_logging = false # Opt-in for debugging
|
|
377
392
|
@log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
|
|
@@ -553,6 +568,28 @@ module RailsErrorDashboard
|
|
|
553
568
|
@enable_llm_observability = false
|
|
554
569
|
end
|
|
555
570
|
|
|
571
|
+
# Validate OTel export config — coerce or warn rather than raise so a
|
|
572
|
+
# config typo never blocks the host app from booting.
|
|
573
|
+
if enable_otel_export
|
|
574
|
+
unless otel_spans.is_a?(Array)
|
|
575
|
+
warnings << "otel_spans must be an Array of symbols (e.g. [:capture, :breadcrumbs]). " \
|
|
576
|
+
"Resetting to all-enabled."
|
|
577
|
+
@otel_spans = %i[capture breadcrumbs health notifications]
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
invalid = otel_spans - %i[capture breadcrumbs health notifications]
|
|
581
|
+
if invalid.any?
|
|
582
|
+
warnings << "otel_spans contains unknown kinds: #{invalid.inspect}. Allowed: " \
|
|
583
|
+
"[:capture, :breadcrumbs, :health, :notifications]. Ignoring unknown values."
|
|
584
|
+
@otel_spans = otel_spans - invalid
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
if @otel_spans.empty?
|
|
588
|
+
warnings << "enable_otel_export = true but otel_spans is empty — no spans will be emitted. " \
|
|
589
|
+
"Set otel_spans to enable at least one of [:capture, :breadcrumbs, :health, :notifications]."
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
556
593
|
# Skip credential/service-dependent validations during Docker builds.
|
|
557
594
|
# SECRET_KEY_BASE_DUMMY=1 means no credentials or external services available.
|
|
558
595
|
build_env = ENV["SECRET_KEY_BASE_DUMMY"].present?
|
|
@@ -567,7 +604,8 @@ module RailsErrorDashboard
|
|
|
567
604
|
|
|
568
605
|
if enable_issue_tracking && effective_issue_tracker_provider.nil?
|
|
569
606
|
warnings << "enable_issue_tracking is true but provider could not be detected. " \
|
|
570
|
-
"Set issue_tracker_provider or git_repository_url."
|
|
607
|
+
"Set issue_tracker_provider (:github, :gitlab, :codeberg, :linear) or git_repository_url. " \
|
|
608
|
+
"Note: :linear is never auto-detected — set it explicitly with issue_tracker_repo as the team key."
|
|
571
609
|
end
|
|
572
610
|
end
|
|
573
611
|
|
|
@@ -699,9 +737,11 @@ module RailsErrorDashboard
|
|
|
699
737
|
default || blank
|
|
700
738
|
end
|
|
701
739
|
|
|
702
|
-
# Resolve the effective issue tracker provider (auto-detect from git_repository_url)
|
|
740
|
+
# Resolve the effective issue tracker provider (auto-detect from git_repository_url).
|
|
741
|
+
# Linear is never auto-detected (it is not a git forge) — set issue_tracker_provider
|
|
742
|
+
# explicitly.
|
|
703
743
|
#
|
|
704
|
-
# @return [Symbol, nil] :github, :gitlab, :codeberg, or nil
|
|
744
|
+
# @return [Symbol, nil] :github, :gitlab, :codeberg, :linear, or nil
|
|
705
745
|
def effective_issue_tracker_provider
|
|
706
746
|
return issue_tracker_provider&.to_sym if issue_tracker_provider.present?
|
|
707
747
|
return nil if git_repository_url.blank?
|
|
@@ -714,11 +754,13 @@ module RailsErrorDashboard
|
|
|
714
754
|
end
|
|
715
755
|
end
|
|
716
756
|
|
|
717
|
-
# Resolve the effective issue tracker repository ("owner/repo")
|
|
757
|
+
# Resolve the effective issue tracker repository ("owner/repo", or Linear team key)
|
|
718
758
|
#
|
|
719
|
-
# @return [String, nil] "owner/repo" or nil
|
|
759
|
+
# @return [String, nil] "owner/repo", Linear team key, or nil
|
|
720
760
|
def effective_issue_tracker_repo
|
|
721
761
|
return issue_tracker_repo if issue_tracker_repo.present?
|
|
762
|
+
# A git URL can never yield a Linear team key — require explicit config
|
|
763
|
+
return nil if effective_issue_tracker_provider == :linear
|
|
722
764
|
return nil if git_repository_url.blank?
|
|
723
765
|
|
|
724
766
|
# Extract owner/repo from URL: https://github.com/owner/repo(.git)
|
|
@@ -746,6 +788,7 @@ module RailsErrorDashboard
|
|
|
746
788
|
when :github then "https://api.github.com"
|
|
747
789
|
when :gitlab then "https://gitlab.com/api/v4"
|
|
748
790
|
when :codeberg then "https://codeberg.org/api/v1"
|
|
791
|
+
when :linear then "https://api.linear.app/graphql"
|
|
749
792
|
end
|
|
750
793
|
end
|
|
751
794
|
|
|
@@ -107,6 +107,20 @@ module RailsErrorDashboard
|
|
|
107
107
|
# capability, so this is safe to call unconditionally.
|
|
108
108
|
RailsErrorDashboard::Integrations::LlmSpanProcessor.register!
|
|
109
109
|
|
|
110
|
+
# Outbound OTel export — warn at boot if the feature is enabled but
|
|
111
|
+
# the OTel API isn't loaded. The Tracer façade silently no-ops in that
|
|
112
|
+
# state, so without this warning users could enable the feature and
|
|
113
|
+
# see zero spans without knowing why. Don't auto-disable — the user
|
|
114
|
+
# may install OTel later in the boot sequence.
|
|
115
|
+
if RailsErrorDashboard.configuration.enable_otel_export &&
|
|
116
|
+
!RailsErrorDashboard::Integrations::Tracer.otel_api_loaded?
|
|
117
|
+
Rails.logger.warn(
|
|
118
|
+
"[RailsErrorDashboard] enable_otel_export = true but the OpenTelemetry API " \
|
|
119
|
+
"(opentelemetry-api gem) isn't loaded. Outbound spans will not emit. " \
|
|
120
|
+
"Add `gem \"opentelemetry-api\"` (or the full opentelemetry-sdk) to your Gemfile."
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
110
124
|
# Subscribe to red.llm_call / red.llm_tool_call AS::Notifications — Tier 3
|
|
111
125
|
# path for hosts using direct Net::HTTP / gRPC / local inference servers
|
|
112
126
|
# that aren't covered by OTel or the Faraday middleware.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Integrations
|
|
5
|
+
# OpenTelemetry tracer façade for the outbound direction — emits spans
|
|
6
|
+
# from the gem's capture path so host operators can audit error tracking
|
|
7
|
+
# latency from their existing Datadog/Honeycomb/Jaeger pipeline.
|
|
8
|
+
#
|
|
9
|
+
# Symmetric counterpart to LlmSpanProcessor (which is INBOUND — pulls
|
|
10
|
+
# OTel spans INTO RED breadcrumbs). This module pushes OUTBOUND: gem
|
|
11
|
+
# operations OUT to the host's tracer provider.
|
|
12
|
+
#
|
|
13
|
+
# Designed to be called from hot paths unconditionally. When OTel is
|
|
14
|
+
# absent or the feature is off, `in_span` runs the block with a no-op
|
|
15
|
+
# span object — call sites do NOT branch on availability.
|
|
16
|
+
#
|
|
17
|
+
# HOST APP SAFETY (HOST_APP_SAFETY.md):
|
|
18
|
+
# - No-op when `enable_otel_export = false` OR OTel API not loaded
|
|
19
|
+
# - Per-span-kind opt-in/out via config.otel_spans
|
|
20
|
+
# - Tracer instance memoized per-process (rebuild on `reset!`)
|
|
21
|
+
# - Every public method hard-rescues — never raises into host code
|
|
22
|
+
# - Block return value is preserved even when tracer errors
|
|
23
|
+
# - Exceptions raised by the block re-raise after being recorded
|
|
24
|
+
#
|
|
25
|
+
# Configuration:
|
|
26
|
+
# config.enable_otel_export = true # master switch (default false)
|
|
27
|
+
# config.otel_service_name = "my-app" # falls back to application_name
|
|
28
|
+
# config.otel_spans = [:capture, :breadcrumbs, :health, :notifications]
|
|
29
|
+
#
|
|
30
|
+
# Usage from capture-path code:
|
|
31
|
+
#
|
|
32
|
+
# Tracer.in_span("capture_error", kind: :capture,
|
|
33
|
+
# attributes: { error_type: exception.class.name }) do |span|
|
|
34
|
+
# # ... do the work ...
|
|
35
|
+
# span&.set_attribute("rails_error_dashboard.error_id", error.id)
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# The span object yielded may be the real OTel span or a NoopSpan.
|
|
39
|
+
# Always use safe-nav (`span&.`) or guard with `span.respond_to?(:...)`.
|
|
40
|
+
module Tracer
|
|
41
|
+
INSTRUMENTATION_NAME = "rails_error_dashboard"
|
|
42
|
+
ALL_SPAN_KINDS = %i[capture breadcrumbs health notifications].freeze
|
|
43
|
+
|
|
44
|
+
# No-op stand-in returned to the block when tracing is off or unavailable.
|
|
45
|
+
# Mimics the OTel Span interface (set_attribute, add_event, record_exception)
|
|
46
|
+
# so call sites don't branch.
|
|
47
|
+
class NoopSpan
|
|
48
|
+
def set_attribute(_key, _value); self; end
|
|
49
|
+
def add_event(_name, attributes: nil); self; end
|
|
50
|
+
def record_exception(_exception, attributes: nil); self; end
|
|
51
|
+
def status=(_status); end
|
|
52
|
+
def finish; self; end
|
|
53
|
+
def context; nil; end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
NOOP_SPAN = NoopSpan.new.freeze
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Yields a span object to the block. Returns the block's return value.
|
|
60
|
+
# Records exceptions raised by the block as span events and re-raises.
|
|
61
|
+
#
|
|
62
|
+
# @param name [String] short span name (will be namespaced with INSTRUMENTATION_NAME)
|
|
63
|
+
# @param kind [Symbol] one of ALL_SPAN_KINDS — checked against config.otel_spans
|
|
64
|
+
# @param attributes [Hash<String,Object>] attached to the span at creation
|
|
65
|
+
# @yieldparam span [NoopSpan, ::OpenTelemetry::Trace::Span] real or no-op
|
|
66
|
+
# @return [Object] whatever the block returns
|
|
67
|
+
def in_span(name, kind: :capture, attributes: {})
|
|
68
|
+
return yield(NOOP_SPAN) unless emit?(kind)
|
|
69
|
+
|
|
70
|
+
tr = tracer
|
|
71
|
+
return yield(NOOP_SPAN) unless tr
|
|
72
|
+
|
|
73
|
+
full_name = "#{INSTRUMENTATION_NAME}.#{name}"
|
|
74
|
+
merged = base_attributes.merge(safe_stringify(attributes))
|
|
75
|
+
|
|
76
|
+
tr.in_span(full_name, attributes: merged) do |span|
|
|
77
|
+
begin
|
|
78
|
+
yield span
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
record_block_exception(span, e)
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
# Tracer internals failed (e.g. OTel SDK threw on add_span). Fall back
|
|
86
|
+
# to running the block with a no-op so the host app never sees a crash
|
|
87
|
+
# caused by the tracer.
|
|
88
|
+
Logger.debug("[RailsErrorDashboard] Tracer.in_span(#{name.inspect}) failed: #{e.class}: #{e.message}")
|
|
89
|
+
yield NOOP_SPAN
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns true when the OTel API is loaded AND the master switch is on
|
|
93
|
+
# AND the given span kind is in the enabled set. Cheap — called on every
|
|
94
|
+
# in_span invocation, including in the hot path.
|
|
95
|
+
# @param kind [Symbol]
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def emit?(kind)
|
|
98
|
+
config = RailsErrorDashboard.configuration
|
|
99
|
+
return false unless config.enable_otel_export
|
|
100
|
+
return false unless otel_api_loaded?
|
|
101
|
+
|
|
102
|
+
enabled_kinds = config.otel_spans
|
|
103
|
+
return false if enabled_kinds.nil? || enabled_kinds.empty?
|
|
104
|
+
enabled_kinds.include?(kind)
|
|
105
|
+
rescue StandardError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reset memoized tracer + availability — for spec isolation only.
|
|
110
|
+
def reset!
|
|
111
|
+
@tracer = nil
|
|
112
|
+
@otel_api_loaded = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns true if the OTel API gem is loaded (NOT the SDK). The API alone
|
|
116
|
+
# is enough — it ships a ProxyTracerProvider that's a no-op when no SDK
|
|
117
|
+
# is configured, which is the behavior we want.
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def otel_api_loaded?
|
|
120
|
+
return @otel_api_loaded unless @otel_api_loaded.nil?
|
|
121
|
+
@otel_api_loaded = !!(defined?(::OpenTelemetry) &&
|
|
122
|
+
::OpenTelemetry.respond_to?(:tracer_provider))
|
|
123
|
+
rescue StandardError
|
|
124
|
+
@otel_api_loaded = false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Memoized tracer instance. Returns nil on any failure so the caller
|
|
130
|
+
# falls back to no-op behavior.
|
|
131
|
+
# @return [::OpenTelemetry::Trace::Tracer, nil]
|
|
132
|
+
def tracer
|
|
133
|
+
return @tracer if @tracer
|
|
134
|
+
return nil unless otel_api_loaded?
|
|
135
|
+
|
|
136
|
+
@tracer = ::OpenTelemetry.tracer_provider.tracer(
|
|
137
|
+
INSTRUMENTATION_NAME,
|
|
138
|
+
RailsErrorDashboard::VERSION
|
|
139
|
+
)
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
Logger.debug("[RailsErrorDashboard] Tracer initialization failed: #{e.class}: #{e.message}")
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Attributes attached to every span — service-name and gem version
|
|
146
|
+
# let operators filter the gem's traffic out of their dashboards.
|
|
147
|
+
def base_attributes
|
|
148
|
+
config = RailsErrorDashboard.configuration
|
|
149
|
+
{
|
|
150
|
+
"rails_error_dashboard.version" => RailsErrorDashboard::VERSION,
|
|
151
|
+
"rails_error_dashboard.service_name" => config.otel_service_name ||
|
|
152
|
+
config.application_name ||
|
|
153
|
+
"unknown"
|
|
154
|
+
}
|
|
155
|
+
rescue StandardError
|
|
156
|
+
{}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# OTel attribute values must be strings, bools, numerics, or arrays of those.
|
|
160
|
+
# Coerce hash values to strings as a safety net — host code passing arbitrary
|
|
161
|
+
# objects (e.g. a Symbol or an Exception) won't crash the SDK.
|
|
162
|
+
def safe_stringify(attrs)
|
|
163
|
+
return {} unless attrs.is_a?(Hash)
|
|
164
|
+
attrs.each_with_object({}) do |(k, v), acc|
|
|
165
|
+
key = k.to_s
|
|
166
|
+
acc[key] = case v
|
|
167
|
+
when String, Numeric, TrueClass, FalseClass then v
|
|
168
|
+
when Array
|
|
169
|
+
v.map { |x| x.is_a?(String) || x.is_a?(Numeric) || x == true || x == false ? x : x.to_s }
|
|
170
|
+
when nil then nil
|
|
171
|
+
else v.to_s
|
|
172
|
+
end
|
|
173
|
+
end.compact
|
|
174
|
+
rescue StandardError
|
|
175
|
+
{}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# OTel semconv for exceptions:
|
|
179
|
+
# span.record_exception(exception) -- adds an "exception" event
|
|
180
|
+
# span.status = OpenTelemetry::Trace::Status.error("message")
|
|
181
|
+
def record_block_exception(span, exception)
|
|
182
|
+
return unless span.respond_to?(:record_exception)
|
|
183
|
+
span.record_exception(exception)
|
|
184
|
+
|
|
185
|
+
if defined?(::OpenTelemetry::Trace::Status) &&
|
|
186
|
+
::OpenTelemetry::Trace::Status.respond_to?(:error)
|
|
187
|
+
span.status = ::OpenTelemetry::Trace::Status.error(exception.message.to_s[0, 200])
|
|
188
|
+
end
|
|
189
|
+
rescue StandardError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -120,12 +120,33 @@ module RailsErrorDashboard
|
|
|
120
120
|
# Harvest breadcrumbs from the current buffer and clear it
|
|
121
121
|
# @return [Array<Hash>] Array of breadcrumb hashes (empty if none)
|
|
122
122
|
def self.harvest
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
# OTel: emit a child span around the harvest so operators see the
|
|
124
|
+
# buffer-drain step in the capture trace. Cheap to compute (single
|
|
125
|
+
# Array#size + JSON byte estimate) and contained to LogError invocations
|
|
126
|
+
# via the parent rails_error_dashboard.capture_error span.
|
|
127
|
+
RailsErrorDashboard::Integrations::Tracer.in_span(
|
|
128
|
+
"breadcrumb_collection",
|
|
129
|
+
kind: :breadcrumbs
|
|
130
|
+
) do |span|
|
|
131
|
+
buffer = Thread.current[THREAD_KEY]
|
|
132
|
+
if buffer.nil?
|
|
133
|
+
span&.set_attribute("breadcrumb_count", 0)
|
|
134
|
+
next []
|
|
135
|
+
end
|
|
125
136
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
result = buffer.to_a
|
|
138
|
+
buffer.clear
|
|
139
|
+
|
|
140
|
+
# Only pay for attribute computation when a real span is recording.
|
|
141
|
+
# NoopSpan is the singleton returned when OTel is off — skip the work
|
|
142
|
+
# entirely so the harvest path stays free in the common case.
|
|
143
|
+
if span && !span.equal?(RailsErrorDashboard::Integrations::Tracer::NOOP_SPAN)
|
|
144
|
+
span.set_attribute("breadcrumb_count", result.size)
|
|
145
|
+
span.set_attribute("bytes_serialized_estimate", estimate_byte_size(result))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
result
|
|
149
|
+
end
|
|
129
150
|
rescue => e
|
|
130
151
|
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.harvest failed: #{e.message}")
|
|
131
152
|
[]
|
|
@@ -222,6 +243,25 @@ module RailsErrorDashboard
|
|
|
222
243
|
{}
|
|
223
244
|
end
|
|
224
245
|
private_class_method :truncate_metadata
|
|
246
|
+
|
|
247
|
+
# Rough byte-size estimate without paying the full JSON serialization
|
|
248
|
+
# cost. Sums the (already-truncated) message lengths and metadata string
|
|
249
|
+
# values. Used as the bytes_serialized_estimate attribute on the OTel
|
|
250
|
+
# breadcrumb_collection span.
|
|
251
|
+
def self.estimate_byte_size(breadcrumbs)
|
|
252
|
+
return 0 unless breadcrumbs.is_a?(Array)
|
|
253
|
+
breadcrumbs.sum do |c|
|
|
254
|
+
next 0 unless c.is_a?(Hash)
|
|
255
|
+
# ~12 bytes constant overhead per crumb (timestamp + category key)
|
|
256
|
+
base = 12 + (c[:m] || c["m"]).to_s.bytesize
|
|
257
|
+
meta = c[:meta] || c["meta"]
|
|
258
|
+
base += meta.values.sum { |v| v.to_s.bytesize } if meta.is_a?(Hash)
|
|
259
|
+
base
|
|
260
|
+
end
|
|
261
|
+
rescue StandardError
|
|
262
|
+
0
|
|
263
|
+
end
|
|
264
|
+
private_class_method :estimate_byte_size
|
|
225
265
|
end
|
|
226
266
|
end
|
|
227
267
|
end
|
|
@@ -12,26 +12,48 @@ module RailsErrorDashboard
|
|
|
12
12
|
class ErrorNotificationDispatcher
|
|
13
13
|
# @param error_log [ErrorLog] The error to notify about
|
|
14
14
|
def self.call(error_log)
|
|
15
|
-
|
|
15
|
+
# OTel: emit a child span around the dispatch so operators can see
|
|
16
|
+
# which channels fired for a given error and how long the enqueue
|
|
17
|
+
# itself took. Actual delivery happens in the background jobs (Slack
|
|
18
|
+
# HTTP, SMTP, etc.) — those would need their own instrumentation to
|
|
19
|
+
# measure delivery latency.
|
|
20
|
+
RailsErrorDashboard::Integrations::Tracer.in_span(
|
|
21
|
+
"notification_dispatch",
|
|
22
|
+
kind: :notifications,
|
|
23
|
+
attributes: { "rails_error_dashboard.error_id" => error_log.id.to_i }
|
|
24
|
+
) do |span|
|
|
25
|
+
config = RailsErrorDashboard.configuration
|
|
26
|
+
fired = []
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
if config.enable_slack_notifications && config.slack_webhook_url.present?
|
|
29
|
+
SlackErrorNotificationJob.perform_later(error_log.id)
|
|
30
|
+
fired << "slack"
|
|
31
|
+
end
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
if config.enable_email_notifications && config.notification_email_recipients.present?
|
|
34
|
+
EmailErrorNotificationJob.perform_later(error_log.id)
|
|
35
|
+
fired << "email"
|
|
36
|
+
end
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
if config.enable_discord_notifications && config.discord_webhook_url.present?
|
|
39
|
+
DiscordErrorNotificationJob.perform_later(error_log.id)
|
|
40
|
+
fired << "discord"
|
|
41
|
+
end
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
if config.enable_pagerduty_notifications && config.pagerduty_integration_key.present?
|
|
44
|
+
PagerdutyErrorNotificationJob.perform_later(error_log.id)
|
|
45
|
+
fired << "pagerduty"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if config.enable_webhook_notifications && config.webhook_urls.present?
|
|
49
|
+
WebhookErrorNotificationJob.perform_later(error_log.id)
|
|
50
|
+
fired << "webhook"
|
|
51
|
+
end
|
|
32
52
|
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
if span && !span.equal?(RailsErrorDashboard::Integrations::Tracer::NOOP_SPAN)
|
|
54
|
+
span.set_attribute("channels", fired)
|
|
55
|
+
span.set_attribute("channel_count", fired.size)
|
|
56
|
+
end
|
|
35
57
|
end
|
|
36
58
|
end
|
|
37
59
|
end
|
|
@@ -8,8 +8,9 @@ module RailsErrorDashboard
|
|
|
8
8
|
module Services
|
|
9
9
|
# Base class and factory for issue tracker API clients.
|
|
10
10
|
#
|
|
11
|
-
# Supports GitHub, GitLab,
|
|
12
|
-
# Each provider implements the same methods with provider-specific
|
|
11
|
+
# Supports GitHub, GitLab, Codeberg/Gitea/Forgejo, and Linear via a unified
|
|
12
|
+
# interface. Each provider implements the same methods with provider-specific
|
|
13
|
+
# API calls.
|
|
13
14
|
#
|
|
14
15
|
# @example
|
|
15
16
|
# client = IssueTrackerClient.for(:github, token: "ghp_xxx", repo: "user/repo")
|
|
@@ -23,9 +24,9 @@ module RailsErrorDashboard
|
|
|
23
24
|
|
|
24
25
|
# Factory method — returns the correct client for the provider
|
|
25
26
|
#
|
|
26
|
-
# @param provider [Symbol] :github, :gitlab, or :
|
|
27
|
+
# @param provider [Symbol] :github, :gitlab, :codeberg, or :linear
|
|
27
28
|
# @param token [String] API authentication token
|
|
28
|
-
# @param repo [String] Repository identifier ("owner/repo")
|
|
29
|
+
# @param repo [String] Repository identifier ("owner/repo"), or Linear team key ("ENG")
|
|
29
30
|
# @param api_url [String, nil] Custom API base URL (for self-hosted)
|
|
30
31
|
# @return [IssueTrackerClient] Provider-specific client instance
|
|
31
32
|
def self.for(provider, token:, repo:, api_url: nil)
|
|
@@ -36,8 +37,10 @@ module RailsErrorDashboard
|
|
|
36
37
|
GitLabIssueClient.new(token: token, repo: repo, api_url: api_url)
|
|
37
38
|
when :codeberg
|
|
38
39
|
CodebergIssueClient.new(token: token, repo: repo, api_url: api_url)
|
|
40
|
+
when :linear
|
|
41
|
+
LinearIssueClient.new(token: token, repo: repo, api_url: api_url)
|
|
39
42
|
else
|
|
40
|
-
raise ArgumentError, "Unknown issue tracker provider: #{provider}. Supported: :github, :gitlab, :codeberg"
|
|
43
|
+
raise ArgumentError, "Unknown issue tracker provider: #{provider}. Supported: :github, :gitlab, :codeberg, :linear"
|
|
41
44
|
end
|
|
42
45
|
end
|
|
43
46
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Linear GraphQL API client for issue management.
|
|
6
|
+
#
|
|
7
|
+
# API Docs: https://developers.linear.app/docs/graphql/working-with-the-graphql-api
|
|
8
|
+
# Auth: Personal API key (Settings > Security & access > Personal API keys)
|
|
9
|
+
# Rate limit: 1,500 requests/hour per API key
|
|
10
|
+
#
|
|
11
|
+
# Unlike the git forges, Linear has no "owner/repo" — issues belong to a
|
|
12
|
+
# team. The `repo` argument holds the team key (e.g. "ENG"), and issues
|
|
13
|
+
# are addressed by their human identifier ("ENG-123"), reconstructed from
|
|
14
|
+
# the team key plus the team-scoped issue number we store.
|
|
15
|
+
#
|
|
16
|
+
# Linear also has no open/closed binary — issues move between typed
|
|
17
|
+
# workflow states. Closing maps to the team's first `completed`-type
|
|
18
|
+
# state, reopening to the first `unstarted` (or `backlog`) state.
|
|
19
|
+
class LinearIssueClient < IssueTrackerClient
|
|
20
|
+
REOPEN_STATE_TYPES = [ "unstarted", "backlog", "triage" ].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(token:, repo:, api_url: nil)
|
|
23
|
+
super
|
|
24
|
+
@api_url = api_url || "https://api.linear.app/graphql"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_issue(title:, body:, labels: [])
|
|
28
|
+
return error_response(@last_error || "Linear team '#{@repo}' not found") unless team_id
|
|
29
|
+
|
|
30
|
+
input = { teamId: team_id, title: title, description: truncate_body(body) }
|
|
31
|
+
label_ids = resolve_label_ids(labels)
|
|
32
|
+
input[:labelIds] = label_ids if label_ids.any?
|
|
33
|
+
|
|
34
|
+
data = graphql(<<~GRAPHQL, input: input)
|
|
35
|
+
mutation($input: IssueCreateInput!) {
|
|
36
|
+
issueCreate(input: $input) { success issue { identifier number url } }
|
|
37
|
+
}
|
|
38
|
+
GRAPHQL
|
|
39
|
+
|
|
40
|
+
issue = data&.dig("issueCreate", "issue")
|
|
41
|
+
if issue
|
|
42
|
+
success_response(url: issue["url"], number: issue["number"])
|
|
43
|
+
else
|
|
44
|
+
error_response(@last_error || "Linear API error: issue creation failed")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close_issue(number:)
|
|
49
|
+
update_issue_state(number, "completed")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reopen_issue(number:)
|
|
53
|
+
update_issue_state(number, REOPEN_STATE_TYPES)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_comment(number:, body:)
|
|
57
|
+
issue_id = find_issue_id(number)
|
|
58
|
+
return error_response(@last_error || "Linear issue #{identifier_for(number)} not found") unless issue_id
|
|
59
|
+
|
|
60
|
+
data = graphql(<<~GRAPHQL, input: { issueId: issue_id, body: truncate_body(body) })
|
|
61
|
+
mutation($input: CommentCreateInput!) {
|
|
62
|
+
commentCreate(input: $input) { success comment { url } }
|
|
63
|
+
}
|
|
64
|
+
GRAPHQL
|
|
65
|
+
|
|
66
|
+
comment = data&.dig("commentCreate", "comment")
|
|
67
|
+
comment ? success_response(url: comment["url"]) : error_response(@last_error || "Linear API error: comment failed")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fetch_comments(number:, per_page: 10)
|
|
71
|
+
data = graphql(<<~GRAPHQL, id: identifier_for(number), first: per_page)
|
|
72
|
+
query($id: String!, $first: Int!) {
|
|
73
|
+
issue(id: $id) {
|
|
74
|
+
comments(first: $first) {
|
|
75
|
+
nodes { body createdAt url user { name avatarUrl } }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
GRAPHQL
|
|
80
|
+
|
|
81
|
+
nodes = data&.dig("issue", "comments", "nodes")
|
|
82
|
+
return error_response(@last_error || "Linear API error: could not fetch comments") unless nodes
|
|
83
|
+
|
|
84
|
+
comments = nodes.map { |c|
|
|
85
|
+
{
|
|
86
|
+
author: c.dig("user", "name"),
|
|
87
|
+
avatar_url: c.dig("user", "avatarUrl"),
|
|
88
|
+
body: c["body"],
|
|
89
|
+
created_at: c["createdAt"],
|
|
90
|
+
url: c["url"]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
success_response(comments: comments)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fetch_issue(number:)
|
|
97
|
+
data = graphql(<<~GRAPHQL, id: identifier_for(number))
|
|
98
|
+
query($id: String!) {
|
|
99
|
+
issue(id: $id) {
|
|
100
|
+
title
|
|
101
|
+
state { name type }
|
|
102
|
+
assignee { name avatarUrl }
|
|
103
|
+
labels { nodes { name color } }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
GRAPHQL
|
|
107
|
+
|
|
108
|
+
issue = data&.dig("issue")
|
|
109
|
+
return error_response(@last_error || "Linear API error: could not fetch issue") unless issue
|
|
110
|
+
|
|
111
|
+
assignee = issue["assignee"]
|
|
112
|
+
success_response(
|
|
113
|
+
state: closed_state_type?(issue.dig("state", "type")) ? "closed" : "open",
|
|
114
|
+
title: issue["title"],
|
|
115
|
+
assignees: assignee ? [ { login: assignee["name"], avatar_url: assignee["avatarUrl"] } ] : [],
|
|
116
|
+
labels: (issue.dig("labels", "nodes") || []).map { |l|
|
|
117
|
+
{ name: l["name"], color: l["color"]&.delete("#") }
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Linear issues are addressed by "TEAM-123" — team key + stored number
|
|
125
|
+
def identifier_for(number)
|
|
126
|
+
"#{@repo}-#{number}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def closed_state_type?(state_type)
|
|
130
|
+
[ "completed", "canceled" ].include?(state_type)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def update_issue_state(number, state_types)
|
|
134
|
+
issue_id = find_issue_id(number)
|
|
135
|
+
return error_response(@last_error || "Linear issue #{identifier_for(number)} not found") unless issue_id
|
|
136
|
+
|
|
137
|
+
state_id = workflow_state_id(Array(state_types))
|
|
138
|
+
return error_response(@last_error || "No matching workflow state for #{Array(state_types).join('/')}") unless state_id
|
|
139
|
+
|
|
140
|
+
data = graphql(<<~GRAPHQL, id: issue_id, input: { stateId: state_id })
|
|
141
|
+
mutation($id: String!, $input: IssueUpdateInput!) {
|
|
142
|
+
issueUpdate(id: $id, input: $input) { success }
|
|
143
|
+
}
|
|
144
|
+
GRAPHQL
|
|
145
|
+
|
|
146
|
+
data&.dig("issueUpdate", "success") ? success_response({}) : error_response(@last_error || "Linear API error: state update failed")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def find_issue_id(number)
|
|
150
|
+
data = graphql("query($id: String!) { issue(id: $id) { id } }", id: identifier_for(number))
|
|
151
|
+
data&.dig("issue", "id")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def team_id
|
|
155
|
+
@team_id ||= begin
|
|
156
|
+
data = graphql(<<~GRAPHQL, key: @repo)
|
|
157
|
+
query($key: String!) {
|
|
158
|
+
teams(filter: { key: { eq: $key } }) { nodes { id } }
|
|
159
|
+
}
|
|
160
|
+
GRAPHQL
|
|
161
|
+
data&.dig("teams", "nodes", 0, "id")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Pick the first workflow state whose type matches, in preference order
|
|
166
|
+
def workflow_state_id(preferred_types)
|
|
167
|
+
states = workflow_states
|
|
168
|
+
return nil unless states
|
|
169
|
+
|
|
170
|
+
preferred_types.each do |type|
|
|
171
|
+
match = states.find { |s| s["type"] == type }
|
|
172
|
+
return match["id"] if match
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def workflow_states
|
|
178
|
+
@workflow_states ||= begin
|
|
179
|
+
data = graphql(<<~GRAPHQL, key: @repo)
|
|
180
|
+
query($key: String!) {
|
|
181
|
+
workflowStates(filter: { team: { key: { eq: $key } } }) {
|
|
182
|
+
nodes { id name type position }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
GRAPHQL
|
|
186
|
+
data&.dig("workflowStates", "nodes")&.sort_by { |s| s["position"].to_f }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Best-effort: resolve label names to UUIDs, creating missing ones.
|
|
191
|
+
# Label failures must never block issue creation.
|
|
192
|
+
def resolve_label_ids(names)
|
|
193
|
+
names = Array(names).map(&:to_s).reject(&:empty?)
|
|
194
|
+
return [] if names.empty?
|
|
195
|
+
|
|
196
|
+
data = graphql(<<~GRAPHQL, names: names)
|
|
197
|
+
query($names: [String!]) {
|
|
198
|
+
issueLabels(filter: { name: { in: $names } }) { nodes { id name } }
|
|
199
|
+
}
|
|
200
|
+
GRAPHQL
|
|
201
|
+
existing = data&.dig("issueLabels", "nodes") || []
|
|
202
|
+
ids = existing.map { |l| l["id"] }
|
|
203
|
+
|
|
204
|
+
missing = names - existing.map { |l| l["name"] }
|
|
205
|
+
missing.each do |name|
|
|
206
|
+
created = graphql(<<~GRAPHQL, input: { name: name, teamId: team_id })
|
|
207
|
+
mutation($input: IssueLabelCreateInput!) {
|
|
208
|
+
issueLabelCreate(input: $input) { issueLabel { id } }
|
|
209
|
+
}
|
|
210
|
+
GRAPHQL
|
|
211
|
+
id = created&.dig("issueLabelCreate", "issueLabel", "id")
|
|
212
|
+
ids << id if id
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
ids
|
|
216
|
+
rescue
|
|
217
|
+
[]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Execute a GraphQL request. Returns the "data" hash, or nil on any
|
|
221
|
+
# error (with the message stashed in @last_error for the caller).
|
|
222
|
+
def graphql(query, variables = {})
|
|
223
|
+
@last_error = nil
|
|
224
|
+
response = http_post(@api_url, { query: query, variables: variables }, auth_headers)
|
|
225
|
+
|
|
226
|
+
if response[:status] != 200
|
|
227
|
+
message = response.dig(:body, "errors", 0, "message") || response[:error]
|
|
228
|
+
@last_error = "Linear API error (#{response[:status]}): #{message}"
|
|
229
|
+
return nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
errors = response.dig(:body, "errors")
|
|
233
|
+
if errors.present?
|
|
234
|
+
@last_error = "Linear API error: #{errors.first["message"]}"
|
|
235
|
+
return nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
response.dig(:body, "data")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def auth_headers
|
|
242
|
+
# Personal API keys are passed bare; OAuth tokens need a Bearer prefix
|
|
243
|
+
value = @token.to_s.start_with?("lin_api_") ? @token : "Bearer #{@token}"
|
|
244
|
+
{ "Authorization" => value }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -24,7 +24,16 @@ module RailsErrorDashboard
|
|
|
24
24
|
# Capture current system health metrics
|
|
25
25
|
# @return [Hash] Health snapshot (always safe, never raises)
|
|
26
26
|
def self.capture
|
|
27
|
-
|
|
27
|
+
# OTel: emit a child span around the snapshot so operators can verify
|
|
28
|
+
# the <1ms health-budget claim from their own tracing dashboard. The
|
|
29
|
+
# snapshot itself is read-only (GC.stat, pool.stat, procfs reads) so
|
|
30
|
+
# the span carries no useful attributes beyond timing.
|
|
31
|
+
RailsErrorDashboard::Integrations::Tracer.in_span(
|
|
32
|
+
"system_health_snapshot",
|
|
33
|
+
kind: :health
|
|
34
|
+
) do |_span|
|
|
35
|
+
new.capture
|
|
36
|
+
end
|
|
28
37
|
rescue => e
|
|
29
38
|
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] SystemHealthSnapshot.capture failed: #{e.message}")
|
|
30
39
|
{ captured_at: Time.current.iso8601 }
|
|
@@ -21,6 +21,7 @@ begin; require "turbo-rails"; rescue LoadError; end
|
|
|
21
21
|
require "rails_error_dashboard/value_objects/error_context"
|
|
22
22
|
require "rails_error_dashboard/value_objects/llm_call_event"
|
|
23
23
|
require "rails_error_dashboard/integrations/o_tel"
|
|
24
|
+
require "rails_error_dashboard/integrations/tracer"
|
|
24
25
|
require "rails_error_dashboard/integrations/llm_span_processor"
|
|
25
26
|
require "rails_error_dashboard/integrations/llm_middleware"
|
|
26
27
|
require "rails_error_dashboard/helpers/user_model_detector"
|
|
@@ -67,6 +68,7 @@ require "rails_error_dashboard/services/issue_tracker_client"
|
|
|
67
68
|
require "rails_error_dashboard/services/github_issue_client"
|
|
68
69
|
require "rails_error_dashboard/services/gitlab_issue_client"
|
|
69
70
|
require "rails_error_dashboard/services/codeberg_issue_client"
|
|
71
|
+
require "rails_error_dashboard/services/linear_issue_client"
|
|
70
72
|
require "rails_error_dashboard/services/database_health_inspector"
|
|
71
73
|
require "rails_error_dashboard/services/cache_analyzer"
|
|
72
74
|
require "rails_error_dashboard/services/llm_summary"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -398,6 +398,7 @@ files:
|
|
|
398
398
|
- lib/rails_error_dashboard/integrations/llm_middleware.rb
|
|
399
399
|
- lib/rails_error_dashboard/integrations/llm_span_processor.rb
|
|
400
400
|
- lib/rails_error_dashboard/integrations/o_tel.rb
|
|
401
|
+
- lib/rails_error_dashboard/integrations/tracer.rb
|
|
401
402
|
- lib/rails_error_dashboard/logger.rb
|
|
402
403
|
- lib/rails_error_dashboard/manual_error_reporter.rb
|
|
403
404
|
- lib/rails_error_dashboard/middleware/error_catcher.rb
|
|
@@ -462,6 +463,7 @@ files:
|
|
|
462
463
|
- lib/rails_error_dashboard/services/gitlab_issue_client.rb
|
|
463
464
|
- lib/rails_error_dashboard/services/issue_body_formatter.rb
|
|
464
465
|
- lib/rails_error_dashboard/services/issue_tracker_client.rb
|
|
466
|
+
- lib/rails_error_dashboard/services/linear_issue_client.rb
|
|
465
467
|
- lib/rails_error_dashboard/services/llm_client.rb
|
|
466
468
|
- lib/rails_error_dashboard/services/llm_cost_estimator.rb
|
|
467
469
|
- lib/rails_error_dashboard/services/llm_summary.rb
|
|
@@ -510,7 +512,7 @@ metadata:
|
|
|
510
512
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
511
513
|
post_install_message: |
|
|
512
514
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
513
|
-
RED (Rails Error Dashboard) v0.
|
|
515
|
+
RED (Rails Error Dashboard) v0.8.1
|
|
514
516
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
515
517
|
|
|
516
518
|
First install:
|