rails_orbit 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 324f351194399de6bab396eda353654d014715422f8dd0b552d9a2df150240f8
4
- data.tar.gz: 26243ec5f12b9425e94ba3201685ecc3d554af0b5e617b67927f49362bb6c3b1
3
+ metadata.gz: f45fb851da082bb63b955526d1adf53dacbbc76938550fb52bf38fb0b7a328b5
4
+ data.tar.gz: 91a250041985e6af4d89e92dbad0597536c3889e33c074ee19082e7cb6bac950
5
5
  SHA512:
6
- metadata.gz: 869988c024f9969d5d24019062d54670a2dc4d6b4c0c1e3bc1e8248defabf6423dd23226467ab8bcb7f020398c9f829d85802b77c5435616115d94ffdb1b0271
7
- data.tar.gz: 20cc91a576016168bbd8f66a2eadee06beb2c3940dd5ee6e30b158b2b52f3f74605c7f444960ce095e27e420ffd36ddb2eaf3f3db6495a41301a99b888fb0499
6
+ metadata.gz: 5c4df5011c201fc5f641199f5a752d8ab93e340bafc59daa5b20273660ec041f17083dcb9d1b788397ed4803ccd47f00b5ff8ca39208a9443d044c683f3173f8
7
+ data.tar.gz: e2a4ec065bc47db14bfecdcf757c89ff243cefc1b5a3a3a8b1ab9f4d80f20496c7792a858189d519b95812c02872d7a761f71580e644c3c653036297ff3c6cc7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-06-10
4
+
5
+ - Errors page now shows the `file:line in method` where each exception originated, taken from the first application frame of its most recent occurrence
6
+ - Errors page now shows an expandable full backtrace with application frames highlighted and gem frames shortened
7
+ - Dashboard CSS/JS asset URLs are versioned so browsers fetch fresh assets after an upgrade instead of serving a stale cached copy
8
+ - Dashboard icons now carry intrinsic dimensions so they stay correctly sized even if the stylesheet loads late
9
+
3
10
  ## [0.1.0] - 2026-03-24
4
11
 
5
12
  - Initial release
data/README.md CHANGED
@@ -52,6 +52,12 @@ export ORBIT_PASSWORD=secret
52
52
 
53
53
  Visit `/orbit` in your browser. That is it.
54
54
 
55
+ ### Screenshot
56
+
57
+ Overview with the range picker and interactive charts (example from a demo workload):
58
+
59
+ ![Orbit dashboard overview: jobs, cache, errors, time range, and charts](docs/images/demo_dashboard.png)
60
+
55
61
  ### Useful Commands
56
62
 
57
63
  ```bash
@@ -103,6 +109,8 @@ Exceptions grouped by class for faster triage:
103
109
 
104
110
  - Each exception class shows occurrence count and "last seen" time
105
111
  - Up to 5 recent messages displayed per group
112
+ - **Error location** — the exact `file:line in method` where each exception originated, taken from the first application frame of its most recent occurrence
113
+ - **Full traceback** — expand any error to see the complete backtrace, with application frames highlighted and gem frames de-emphasized and shortened
106
114
  - Resolved status shown if solid_errors supports it
107
115
  - Scoped to the selected date range
108
116
 
@@ -467,6 +467,69 @@
467
467
  color: var(--orbit-text-muted);
468
468
  }
469
469
 
470
+ /* ── Error message, location & traceback ─────────────────── */
471
+ .orbit-error__message {
472
+ color: var(--orbit-text);
473
+ word-break: break-word;
474
+ }
475
+ .orbit-error__location {
476
+ display: flex;
477
+ align-items: center;
478
+ gap: 0.35rem;
479
+ margin-top: 0.3rem;
480
+ font-family: var(--orbit-mono);
481
+ font-size: 0.72rem;
482
+ color: var(--orbit-text-muted);
483
+ word-break: break-all;
484
+ }
485
+ .orbit-error__location-icon {
486
+ width: 0.72rem;
487
+ height: 0.72rem;
488
+ flex-shrink: 0;
489
+ color: var(--orbit-text-muted);
490
+ }
491
+ .orbit-error__path { color: var(--orbit-primary); }
492
+ .orbit-error__line { color: var(--orbit-warning); font-weight: 600; }
493
+ .orbit-error__method { color: var(--orbit-text-muted); }
494
+
495
+ .orbit-trace {
496
+ margin-top: 0.4rem;
497
+ }
498
+ .orbit-trace__summary {
499
+ display: inline-block;
500
+ cursor: pointer;
501
+ font-size: 0.68rem;
502
+ color: var(--orbit-text-muted);
503
+ user-select: none;
504
+ padding: 0.1rem 0;
505
+ }
506
+ .orbit-trace__summary:hover { color: var(--orbit-text); }
507
+ .orbit-trace__frames {
508
+ list-style: none;
509
+ margin: 0.4rem 0 0;
510
+ padding: 0.5rem 0.75rem;
511
+ background: var(--orbit-surface);
512
+ border: 1px solid var(--orbit-border);
513
+ border-radius: var(--orbit-radius);
514
+ max-height: 18rem;
515
+ overflow: auto;
516
+ }
517
+ .orbit-trace__frame {
518
+ font-family: var(--orbit-mono);
519
+ font-size: 0.7rem;
520
+ line-height: 1.7;
521
+ color: var(--orbit-text-muted);
522
+ word-break: break-all;
523
+ opacity: 0.65;
524
+ }
525
+ .orbit-trace__frame--app {
526
+ color: var(--orbit-text);
527
+ opacity: 1;
528
+ }
529
+ .orbit-trace__frame--app .orbit-trace__file { color: var(--orbit-primary); }
530
+ .orbit-trace__line { color: var(--orbit-warning); }
531
+ .orbit-trace__method { color: var(--orbit-text-muted); }
532
+
470
533
  /* ── Empty state ─────────────────────────────────────────── */
471
534
  .orbit-empty {
472
535
  text-align: center;
@@ -1,5 +1,7 @@
1
1
  module RailsOrbit
2
2
  class DashboardController < ApplicationController
3
+ MAX_ERRORS_PER_GROUP = 5
4
+
3
5
  before_action :set_time_range
4
6
 
5
7
  def overview
@@ -57,25 +59,61 @@ module RailsOrbit
57
59
  end
58
60
 
59
61
  def errors
60
- if defined?(SolidErrors)
61
- all_errors = SolidErrors::Error.where(created_at: @time_range.since..).order(created_at: :desc).limit(200)
62
- @grouped_errors = all_errors.group_by(&:exception_class).map do |klass, records|
63
- {
64
- exception_class: klass,
65
- count: records.size,
66
- last_seen: records.first.created_at,
67
- resolved: records.first.respond_to?(:resolved_at) && records.first.resolved_at.present?,
68
- records: records.first(5),
69
- }
70
- end.sort_by { |g| -g[:count] }
71
- else
62
+ unless defined?(SolidErrors)
72
63
  @grouped_errors = []
73
64
  flash.now[:warning] = "solid_errors is not installed or not configured."
65
+ return
74
66
  end
67
+
68
+ all_errors = SolidErrors::Error
69
+ .where(created_at: @time_range.since..)
70
+ .order(created_at: :desc)
71
+ .limit(200)
72
+ .to_a
73
+
74
+ groups = all_errors.group_by(&:exception_class)
75
+ displayed = groups.values.flat_map { |records| records.first(MAX_ERRORS_PER_GROUP) }
76
+ traces = backtraces_for(displayed)
77
+
78
+ @grouped_errors = groups.map do |klass, records|
79
+ {
80
+ exception_class: klass,
81
+ count: records.size,
82
+ last_seen: records.first.created_at,
83
+ resolved: records.first.respond_to?(:resolved_at) && records.first.resolved_at.present?,
84
+ records: records.first(MAX_ERRORS_PER_GROUP).map { |error| present_error(error, traces[error.id]) },
85
+ }
86
+ end.sort_by { |g| -g[:count] }
75
87
  end
76
88
 
77
89
  private
78
90
 
91
+ def present_error(error, backtrace)
92
+ {
93
+ message: error.message,
94
+ created_at: error.created_at,
95
+ location: backtrace&.top_location,
96
+ frames: backtrace&.frames || [],
97
+ }
98
+ end
99
+
100
+ # Parses the latest occurrence's backtrace for each displayed error, keyed
101
+ # by error id. Done in two bounded queries (one row per error) to avoid an
102
+ # N+1, and degrades gracefully if this solid_errors version has no
103
+ # occurrences table.
104
+ def backtraces_for(errors)
105
+ return {} if errors.empty? || !defined?(SolidErrors::Occurrence)
106
+
107
+ ids = errors.map(&:id)
108
+ latest_ids = SolidErrors::Occurrence.where(error_id: ids).group(:error_id).maximum(:id).values
109
+ return {} if latest_ids.empty?
110
+
111
+ SolidErrors::Occurrence
112
+ .where(id: latest_ids)
113
+ .pluck(:error_id, :backtrace)
114
+ .to_h { |error_id, raw| [error_id, RailsOrbit::Backtrace.parse(raw)] }
115
+ end
116
+
79
117
  def set_time_range
80
118
  @time_range = TimeRange.new(params[:range])
81
119
  @range_key = @time_range.key
@@ -8,6 +8,7 @@ module RailsOrbit
8
8
  orbit: '<svg class="orbit-nav__logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8" stroke-dasharray="4 3"/></svg>',
9
9
  delta_up: '<svg class="orbit-delta__icon" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2L10 7H2z"/></svg>',
10
10
  delta_down:'<svg class="orbit-delta__icon" viewBox="0 0 12 12" fill="currentColor"><path d="M6 10L2 5h8z"/></svg>',
11
+ file: '<svg class="%{css_class}" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
11
12
  }.freeze
12
13
 
13
14
  def orbit_icon(name, css_class: "orbit-icon")
@@ -4,8 +4,8 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title><%= RailsOrbit.configuration.dashboard_title %></title>
7
- <%= stylesheet_link_tag "/assets/rails_orbit/application.css", media: "all" %>
8
- <%= javascript_include_tag "/assets/rails_orbit/application.js", defer: true %>
7
+ <%= stylesheet_link_tag "/assets/rails_orbit/application.css?v=#{RailsOrbit::VERSION}", media: "all" %>
8
+ <%= javascript_include_tag "/assets/rails_orbit/application.js?v=#{RailsOrbit::VERSION}", defer: true %>
9
9
  <%= csrf_meta_tags %>
10
10
  </head>
11
11
  <body class="orbit-body">
@@ -24,15 +24,47 @@
24
24
  <table class="orbit-table orbit-table--compact">
25
25
  <thead>
26
26
  <tr>
27
- <th>Message</th>
27
+ <th>Message &amp; Location</th>
28
28
  <th class="orbit-table__right">Occurred At</th>
29
29
  </tr>
30
30
  </thead>
31
31
  <tbody>
32
32
  <% group[:records].each do |error| %>
33
33
  <tr>
34
- <td><%= truncate(error.message, length: 140) %></td>
35
- <td class="orbit-table__right orbit-table__mono"><%= error.created_at.strftime("%b %d %H:%M:%S") %></td>
34
+ <td>
35
+ <div class="orbit-error__message"><%= truncate(error[:message], length: 140) %></div>
36
+
37
+ <% if (loc = error[:location]) %>
38
+ <div class="orbit-error__location" title="<%= loc.to_s %>">
39
+ <%= orbit_icon(:file, css_class: "orbit-error__location-icon") %>
40
+ <% if loc.parsed? %>
41
+ <span class="orbit-error__path"><%= loc.display_path %></span><span class="orbit-error__line">:<%= loc.line %></span><% if loc.method_name %>
42
+ <span class="orbit-error__method">in <%= loc.method_name %></span><% end %>
43
+ <% else %>
44
+ <span class="orbit-error__path"><%= loc.raw %></span>
45
+ <% end %>
46
+ </div>
47
+ <% end %>
48
+
49
+ <% if error[:frames].any? %>
50
+ <details class="orbit-trace">
51
+ <summary class="orbit-trace__summary"><%= pluralize(error[:frames].size, "frame") %> · view traceback</summary>
52
+ <ol class="orbit-trace__frames">
53
+ <% error[:frames].each do |frame| %>
54
+ <li class="orbit-trace__frame<%= " orbit-trace__frame--app" if frame.application? %>">
55
+ <% if frame.parsed? %>
56
+ <span class="orbit-trace__file"><%= frame.display_path %></span><span class="orbit-trace__line">:<%= frame.line %></span><% if frame.method_name %>
57
+ <span class="orbit-trace__method">in <%= frame.method_name %></span><% end %>
58
+ <% else %>
59
+ <span class="orbit-trace__file"><%= frame.raw %></span>
60
+ <% end %>
61
+ </li>
62
+ <% end %>
63
+ </ol>
64
+ </details>
65
+ <% end %>
66
+ </td>
67
+ <td class="orbit-table__right orbit-table__mono"><%= error[:created_at].strftime("%b %d %H:%M:%S") %></td>
36
68
  </tr>
37
69
  <% end %>
38
70
  </tbody>
@@ -0,0 +1,122 @@
1
+ module RailsOrbit
2
+ # Parses a raw exception backtrace (the newline-joined Ruby backtrace that
3
+ # solid_errors stores on each occurrence) into structured frames, flagging
4
+ # which frames belong to the host application versus third-party gems.
5
+ #
6
+ # It reads no source files — only file, line, and method are extracted — so
7
+ # the dashboard stays fast and works even when the rendering server does not
8
+ # have the source on disk. It also depends only on the raw text, never on
9
+ # solid_errors' own Backtrace/BacktraceLine classes, so it is unaffected by
10
+ # internal changes in that gem.
11
+ class Backtrace
12
+ # Matches "path/to/file.rb:42:in `method'" or ":in 'method'" (Ruby 3.4+),
13
+ # with the method portion optional. A leading "X:" Windows drive is allowed.
14
+ LINE_FORMAT = /\A((?:[A-Za-z]:)?[^:]+):(\d+)(?::in [`']([^']+)')?\z/
15
+
16
+ # A single backtrace frame. `parsed?` is false for lines we could not parse
17
+ # (kept verbatim in `raw` so nothing is silently dropped).
18
+ Frame = Struct.new(:file, :line, :method_name, :application, :raw, keyword_init: true) do
19
+ def application?
20
+ application
21
+ end
22
+
23
+ def parsed?
24
+ !file.nil?
25
+ end
26
+
27
+ def display_path
28
+ return raw unless parsed?
29
+
30
+ application ? Backtrace.relative_to_app(file) : Backtrace.shorten_gem(file)
31
+ end
32
+
33
+ def to_s
34
+ return raw unless parsed?
35
+
36
+ suffix = method_name ? " in #{method_name}" : ""
37
+ "#{display_path}:#{line}#{suffix}"
38
+ end
39
+ end
40
+
41
+ def self.parse(raw)
42
+ frames = String(raw).split("\n").filter_map { |line| parse_line(line) }
43
+ new(frames)
44
+ end
45
+
46
+ def self.parse_line(line)
47
+ stripped = line.to_s.strip
48
+ return nil if stripped.empty?
49
+
50
+ if (match = stripped.match(LINE_FORMAT))
51
+ file = match[1]
52
+ Frame.new(
53
+ file: file,
54
+ line: match[2].to_i,
55
+ method_name: match[3],
56
+ application: application_file?(file),
57
+ raw: stripped
58
+ )
59
+ else
60
+ Frame.new(file: nil, line: nil, method_name: nil, application: false, raw: stripped)
61
+ end
62
+ end
63
+
64
+ # A frame is "application" code when it lives under the app root but not in
65
+ # vendored gems. Gem frames and stdlib frames are not application frames.
66
+ def self.application_file?(file)
67
+ root = app_root
68
+ return false unless root && file.start_with?(root)
69
+
70
+ !file.start_with?(File.join(root, "vendor"))
71
+ end
72
+
73
+ def self.relative_to_app(file)
74
+ root = app_root
75
+ return file unless root && file.start_with?(root)
76
+
77
+ file.sub(/\A#{Regexp.escape(root)}\/?/, "")
78
+ end
79
+
80
+ # Trims the gem install prefix so "/.../gems/foo-1.0/lib/x.rb" reads as
81
+ # "foo-1.0/lib/x.rb". Falls back to the full path if no prefix matches.
82
+ def self.shorten_gem(file)
83
+ gem_paths.each do |path|
84
+ return file.sub(/\A#{Regexp.escape(path)}\/?(?:gems\/)?/, "") if file.start_with?(path)
85
+ end
86
+ file
87
+ end
88
+
89
+ def self.app_root
90
+ return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
91
+
92
+ Rails.root.to_s
93
+ end
94
+
95
+ def self.gem_paths
96
+ return [] unless defined?(Gem)
97
+
98
+ Gem.path.map(&:to_s)
99
+ end
100
+
101
+ attr_reader :frames
102
+
103
+ def initialize(frames)
104
+ @frames = frames
105
+ end
106
+
107
+ def application_frames
108
+ @application_frames ||= frames.select(&:application?)
109
+ end
110
+
111
+ # The most relevant "where did this happen" frame: the first application
112
+ # frame, or the first parseable frame if the trace is entirely framework/gem
113
+ # code, or nil when there is nothing usable.
114
+ def top_location
115
+ application_frames.first || frames.find(&:parsed?)
116
+ end
117
+
118
+ def empty?
119
+ frames.empty?
120
+ end
121
+ end
122
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsOrbit
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/rails_orbit.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "rails_orbit/version"
2
2
  require "rails_orbit/configuration"
3
3
  require "rails_orbit/time_range"
4
+ require "rails_orbit/backtrace"
4
5
  require "rails_orbit/database_setup"
5
6
  require "rails_orbit/metric_writer"
6
7
  require "rails_orbit/instrumentation"
@@ -467,6 +467,69 @@
467
467
  color: var(--orbit-text-muted);
468
468
  }
469
469
 
470
+ /* ── Error message, location & traceback ─────────────────── */
471
+ .orbit-error__message {
472
+ color: var(--orbit-text);
473
+ word-break: break-word;
474
+ }
475
+ .orbit-error__location {
476
+ display: flex;
477
+ align-items: center;
478
+ gap: 0.35rem;
479
+ margin-top: 0.3rem;
480
+ font-family: var(--orbit-mono);
481
+ font-size: 0.72rem;
482
+ color: var(--orbit-text-muted);
483
+ word-break: break-all;
484
+ }
485
+ .orbit-error__location-icon {
486
+ width: 0.72rem;
487
+ height: 0.72rem;
488
+ flex-shrink: 0;
489
+ color: var(--orbit-text-muted);
490
+ }
491
+ .orbit-error__path { color: var(--orbit-primary); }
492
+ .orbit-error__line { color: var(--orbit-warning); font-weight: 600; }
493
+ .orbit-error__method { color: var(--orbit-text-muted); }
494
+
495
+ .orbit-trace {
496
+ margin-top: 0.4rem;
497
+ }
498
+ .orbit-trace__summary {
499
+ display: inline-block;
500
+ cursor: pointer;
501
+ font-size: 0.68rem;
502
+ color: var(--orbit-text-muted);
503
+ user-select: none;
504
+ padding: 0.1rem 0;
505
+ }
506
+ .orbit-trace__summary:hover { color: var(--orbit-text); }
507
+ .orbit-trace__frames {
508
+ list-style: none;
509
+ margin: 0.4rem 0 0;
510
+ padding: 0.5rem 0.75rem;
511
+ background: var(--orbit-surface);
512
+ border: 1px solid var(--orbit-border);
513
+ border-radius: var(--orbit-radius);
514
+ max-height: 18rem;
515
+ overflow: auto;
516
+ }
517
+ .orbit-trace__frame {
518
+ font-family: var(--orbit-mono);
519
+ font-size: 0.7rem;
520
+ line-height: 1.7;
521
+ color: var(--orbit-text-muted);
522
+ word-break: break-all;
523
+ opacity: 0.65;
524
+ }
525
+ .orbit-trace__frame--app {
526
+ color: var(--orbit-text);
527
+ opacity: 1;
528
+ }
529
+ .orbit-trace__frame--app .orbit-trace__file { color: var(--orbit-primary); }
530
+ .orbit-trace__line { color: var(--orbit-warning); }
531
+ .orbit-trace__method { color: var(--orbit-text-muted); }
532
+
470
533
  /* ── Empty state ─────────────────────────────────────────── */
471
534
  .orbit-empty {
472
535
  text-align: center;
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_orbit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-ham
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-10 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -223,6 +224,7 @@ files:
223
224
  - lib/generators/rails_orbit/templates/create_orbit_metrics.rb.erb
224
225
  - lib/generators/rails_orbit/templates/initializer.rb
225
226
  - lib/rails_orbit.rb
227
+ - lib/rails_orbit/backtrace.rb
226
228
  - lib/rails_orbit/configuration.rb
227
229
  - lib/rails_orbit/database_setup.rb
228
230
  - lib/rails_orbit/engine.rb
@@ -244,6 +246,7 @@ metadata:
244
246
  changelog_uri: https://github.com/dev-ham/rails_orbit/blob/main/CHANGELOG.md
245
247
  bug_tracker_uri: https://github.com/dev-ham/rails_orbit/issues
246
248
  rubygems_mfa_required: 'true'
249
+ post_install_message:
247
250
  rdoc_options: []
248
251
  require_paths:
249
252
  - lib
@@ -258,7 +261,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
258
261
  - !ruby/object:Gem::Version
259
262
  version: '0'
260
263
  requirements: []
261
- rubygems_version: 4.0.3
264
+ rubygems_version: 3.3.27
265
+ signing_key:
262
266
  specification_version: 4
263
267
  summary: Observability dashboard for solid_queue, solid_cache, and solid_errors
264
268
  test_files: []