railscope 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +227 -0
  5. data/Rakefile +12 -0
  6. data/app/assets/stylesheets/railscope/application.css +504 -0
  7. data/app/controllers/railscope/api/entries_controller.rb +103 -0
  8. data/app/controllers/railscope/application_controller.rb +12 -0
  9. data/app/controllers/railscope/dashboard_controller.rb +33 -0
  10. data/app/controllers/railscope/entries_controller.rb +29 -0
  11. data/app/helpers/railscope/dashboard_helper.rb +157 -0
  12. data/app/jobs/railscope/application_job.rb +6 -0
  13. data/app/jobs/railscope/purge_job.rb +15 -0
  14. data/app/models/railscope/application_record.rb +12 -0
  15. data/app/models/railscope/entry.rb +51 -0
  16. data/app/views/layouts/railscope/application.html.erb +14 -0
  17. data/app/views/railscope/application/index.html.erb +1 -0
  18. data/app/views/railscope/dashboard/index.html.erb +70 -0
  19. data/app/views/railscope/entries/show.html.erb +93 -0
  20. data/client/.gitignore +1 -0
  21. data/client/index.html +12 -0
  22. data/client/package-lock.json +2735 -0
  23. data/client/package.json +28 -0
  24. data/client/postcss.config.js +6 -0
  25. data/client/src/App.tsx +60 -0
  26. data/client/src/api/client.ts +25 -0
  27. data/client/src/api/entries.ts +36 -0
  28. data/client/src/components/Layout.tsx +17 -0
  29. data/client/src/components/PlaceholderPage.tsx +32 -0
  30. data/client/src/components/Sidebar.tsx +198 -0
  31. data/client/src/components/ui/Badge.tsx +67 -0
  32. data/client/src/components/ui/Card.tsx +38 -0
  33. data/client/src/components/ui/JsonViewer.tsx +80 -0
  34. data/client/src/components/ui/Pagination.tsx +45 -0
  35. data/client/src/components/ui/SearchInput.tsx +70 -0
  36. data/client/src/components/ui/Table.tsx +68 -0
  37. data/client/src/index.css +28 -0
  38. data/client/src/lib/hooks.ts +37 -0
  39. data/client/src/lib/types.ts +61 -0
  40. data/client/src/lib/utils.ts +38 -0
  41. data/client/src/main.tsx +13 -0
  42. data/client/src/screens/cache/Index.tsx +15 -0
  43. data/client/src/screens/client-requests/Index.tsx +15 -0
  44. data/client/src/screens/commands/Index.tsx +133 -0
  45. data/client/src/screens/commands/Show.tsx +395 -0
  46. data/client/src/screens/dumps/Index.tsx +15 -0
  47. data/client/src/screens/events/Index.tsx +15 -0
  48. data/client/src/screens/exceptions/Index.tsx +155 -0
  49. data/client/src/screens/exceptions/Show.tsx +480 -0
  50. data/client/src/screens/gates/Index.tsx +15 -0
  51. data/client/src/screens/jobs/Index.tsx +153 -0
  52. data/client/src/screens/jobs/Show.tsx +529 -0
  53. data/client/src/screens/logs/Index.tsx +15 -0
  54. data/client/src/screens/mail/Index.tsx +15 -0
  55. data/client/src/screens/models/Index.tsx +15 -0
  56. data/client/src/screens/notifications/Index.tsx +15 -0
  57. data/client/src/screens/queries/Index.tsx +159 -0
  58. data/client/src/screens/queries/Show.tsx +346 -0
  59. data/client/src/screens/redis/Index.tsx +15 -0
  60. data/client/src/screens/requests/Index.tsx +123 -0
  61. data/client/src/screens/requests/Show.tsx +395 -0
  62. data/client/src/screens/schedule/Index.tsx +15 -0
  63. data/client/src/screens/views/Index.tsx +141 -0
  64. data/client/src/screens/views/Show.tsx +337 -0
  65. data/client/tailwind.config.js +22 -0
  66. data/client/tsconfig.json +25 -0
  67. data/client/tsconfig.node.json +10 -0
  68. data/client/vite.config.ts +37 -0
  69. data/config/routes.rb +17 -0
  70. data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
  71. data/lib/generators/railscope/install_generator.rb +33 -0
  72. data/lib/generators/railscope/templates/initializer.rb +34 -0
  73. data/lib/railscope/context.rb +91 -0
  74. data/lib/railscope/engine.rb +85 -0
  75. data/lib/railscope/entry_data.rb +112 -0
  76. data/lib/railscope/filter.rb +113 -0
  77. data/lib/railscope/middleware.rb +162 -0
  78. data/lib/railscope/storage/base.rb +90 -0
  79. data/lib/railscope/storage/database.rb +83 -0
  80. data/lib/railscope/storage/redis_storage.rb +314 -0
  81. data/lib/railscope/subscribers/base_subscriber.rb +52 -0
  82. data/lib/railscope/subscribers/command_subscriber.rb +237 -0
  83. data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
  84. data/lib/railscope/subscribers/job_subscriber.rb +249 -0
  85. data/lib/railscope/subscribers/query_subscriber.rb +130 -0
  86. data/lib/railscope/subscribers/request_subscriber.rb +121 -0
  87. data/lib/railscope/subscribers/view_subscriber.rb +201 -0
  88. data/lib/railscope/version.rb +5 -0
  89. data/lib/railscope.rb +145 -0
  90. data/lib/tasks/railscope_sample.rake +30 -0
  91. data/public/railscope/assets/app.css +1 -0
  92. data/public/railscope/assets/app.js +70 -0
  93. data/public/railscope/assets/index.html +13 -0
  94. data/sig/railscope.rbs +4 -0
  95. metadata +157 -0
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ module DashboardHelper
5
+ def entry_title(entry)
6
+ case entry.entry_type
7
+ when "request"
8
+ "#{entry.payload["method"]} #{entry.payload["path"]}"
9
+ when "query"
10
+ entry.payload["name"] || entry.payload["sql"].to_s.truncate(50)
11
+ when "exception"
12
+ entry.payload["class"]
13
+ when "job_enqueue", "job_perform"
14
+ entry.payload["job_class"]
15
+ else
16
+ entry.entry_type.titleize
17
+ end
18
+ end
19
+
20
+ def timeline_summary(entry)
21
+ case entry.entry_type
22
+ when "request"
23
+ "#{entry.payload["method"]} #{entry.payload["path"]}"
24
+ when "query"
25
+ entry.payload["sql"].to_s.truncate(40)
26
+ when "exception"
27
+ entry.payload["class"]
28
+ when "job_enqueue", "job_perform"
29
+ entry.payload["job_class"]
30
+ else
31
+ entry.entry_type
32
+ end
33
+ end
34
+
35
+ def render_json(hash, indent: 0)
36
+ return content_tag(:span, "null", class: "json-null") if hash.nil?
37
+
38
+ content_tag(:div, class: "json-object") do
39
+ safe_join(hash.map do |key, value|
40
+ content_tag(:div, class: "json-row", style: "padding-left: #{indent * 16}px") do
41
+ key_tag = content_tag(:span, "#{key}:", class: "json-key")
42
+ value_tag = render_json_value(value, indent: indent)
43
+ safe_join([key_tag, value_tag], " ")
44
+ end
45
+ end)
46
+ end
47
+ end
48
+
49
+ def render_json_value(value, indent: 0)
50
+ case value
51
+ when nil
52
+ content_tag(:span, "null", class: "json-null")
53
+ when true, false
54
+ content_tag(:span, value.to_s, class: "json-boolean")
55
+ when Numeric
56
+ content_tag(:span, value.to_s, class: "json-number")
57
+ when String
58
+ if value.length > 100
59
+ content_tag(:span, class: "json-string long") do
60
+ content_tag(:code, value)
61
+ end
62
+ else
63
+ content_tag(:span, "\"#{value}\"", class: "json-string")
64
+ end
65
+ when Array
66
+ if value.empty?
67
+ content_tag(:span, "[]", class: "json-array-empty")
68
+ else
69
+ content_tag(:div, class: "json-array") do
70
+ safe_join([
71
+ content_tag(:span, "[", class: "json-bracket"),
72
+ content_tag(:div, class: "json-array-items") do
73
+ safe_join(value.map { |v| render_json_value(v, indent: indent + 1) })
74
+ end,
75
+ content_tag(:span, "]", class: "json-bracket")
76
+ ])
77
+ end
78
+ end
79
+ when Hash
80
+ if value.empty?
81
+ content_tag(:span, "{}", class: "json-object-empty")
82
+ else
83
+ render_json(value, indent: indent + 1)
84
+ end
85
+ else
86
+ content_tag(:span, value.to_s, class: "json-string")
87
+ end
88
+ end
89
+
90
+ def render_entry_summary(entry)
91
+ case entry.entry_type
92
+ when "request"
93
+ render_request_summary(entry.payload)
94
+ when "query"
95
+ render_query_summary(entry.payload)
96
+ when "exception"
97
+ render_exception_summary(entry.payload)
98
+ when "job_enqueue", "job_perform"
99
+ render_job_summary(entry.payload)
100
+ else
101
+ entry.payload.to_json.truncate(100)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def render_request_summary(payload)
108
+ status_class = case payload["status"].to_i
109
+ when 200..299 then "success"
110
+ when 300..399 then "redirect"
111
+ when 400..499 then "client-error"
112
+ when 500..599 then "server-error"
113
+ else "unknown"
114
+ end
115
+
116
+ content_tag(:span, class: "request-summary") do
117
+ safe_join([
118
+ content_tag(:span, payload["method"], class: "method"),
119
+ content_tag(:span, payload["path"], class: "path"),
120
+ content_tag(:span, payload["status"], class: "status #{status_class}"),
121
+ content_tag(:span, "#{payload["duration"]}ms", class: "duration")
122
+ ], " ")
123
+ end
124
+ end
125
+
126
+ def render_query_summary(payload)
127
+ sql = payload["sql"].to_s.truncate(80)
128
+ duration = payload["duration"]
129
+
130
+ content_tag(:span, class: "query-summary") do
131
+ safe_join([
132
+ content_tag(:code, sql, class: "sql"),
133
+ content_tag(:span, "#{duration}ms", class: "duration")
134
+ ], " ")
135
+ end
136
+ end
137
+
138
+ def render_exception_summary(payload)
139
+ content_tag(:span, class: "exception-summary") do
140
+ safe_join([
141
+ content_tag(:span, payload["class"], class: "exception-class"),
142
+ content_tag(:span, payload["message"].to_s.truncate(60), class: "exception-message")
143
+ ], ": ")
144
+ end
145
+ end
146
+
147
+ def render_job_summary(payload)
148
+ content_tag(:span, class: "job-summary") do
149
+ safe_join([
150
+ content_tag(:span, payload["job_class"], class: "job-class"),
151
+ content_tag(:span, "[#{payload["queue_name"]}]", class: "queue-name"),
152
+ payload["duration"] ? content_tag(:span, "#{payload["duration"]}ms", class: "duration") : nil
153
+ ].compact, " ")
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ class PurgeJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform
8
+ return unless Railscope.enabled?
9
+
10
+ deleted_count = Railscope.storage.destroy_expired!
11
+ Rails.logger.info("[Railscope] Purged #{deleted_count} expired entries")
12
+ deleted_count
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ # Support for separate database connection when RAILSCOPE_DATABASE_URL is configured
8
+ # This isolates Railscope's writes from the main application database,
9
+ # preventing lock contention during high-traffic periods.
10
+ connects_to database: { writing: :railscope, reading: :railscope } if ENV["RAILSCOPE_DATABASE_URL"].present?
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railscope
4
+ class Entry < ApplicationRecord
5
+ self.table_name = "railscope_entries"
6
+
7
+ validates :entry_type, presence: true
8
+ validates :occurred_at, presence: true
9
+
10
+ # Basic scopes
11
+ scope :by_type, ->(type) { where(entry_type: type) }
12
+ scope :recent, -> { order(occurred_at: :desc) }
13
+ scope :with_tag, ->(tag) { where("? = ANY(tags)", tag) }
14
+ scope :expired, -> { where("occurred_at < ?", Railscope.retention_days.days.ago) }
15
+
16
+ # Telescope-style scopes
17
+ scope :displayable, -> { where(should_display_on_index: true) }
18
+ scope :for_batch, ->(batch_id) { where(batch_id: batch_id) }
19
+ scope :for_family, ->(family_hash) { where(family_hash: family_hash) }
20
+
21
+ # Find by UUID (public identifier)
22
+ def self.find_by_uuid!(uuid)
23
+ find_by!(uuid: uuid)
24
+ end
25
+
26
+ def self.find_by_uuid(uuid)
27
+ find_by(uuid: uuid)
28
+ end
29
+
30
+ # Get all related entries in the same batch
31
+ def batch_entries
32
+ return self.class.none if batch_id.blank?
33
+
34
+ self.class.for_batch(batch_id).where.not(id: id).order(:occurred_at)
35
+ end
36
+
37
+ # Get all entries with the same family_hash
38
+ def family_entries
39
+ return self.class.none if family_hash.blank?
40
+
41
+ self.class.for_family(family_hash).where.not(id: id).recent
42
+ end
43
+
44
+ # Count of similar entries (same family_hash)
45
+ def family_count
46
+ return 0 if family_hash.blank?
47
+
48
+ self.class.for_family(family_hash).count
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Railscope</title>
7
+ <%= csrf_meta_tags %>
8
+ <link rel="stylesheet" href="<%= railscope.root_path %>assets/app.css">
9
+ </head>
10
+ <body class="bg-[#0d1117] text-[#c9d1d9]">
11
+ <%= yield %>
12
+ <script type="module" src="<%= railscope.root_path %>assets/app.js"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1 @@
1
+ <div id="root"></div>
@@ -0,0 +1,70 @@
1
+ <div class="railscope-container">
2
+ <header class="railscope-header">
3
+ <h1>Railscope</h1>
4
+ <p class="subtitle"><%= @total_count %> entries</p>
5
+ </header>
6
+
7
+ <nav class="railscope-filters">
8
+ <%= link_to "All", root_path, class: "filter-btn #{@current_type.nil? ? 'active' : ''}" %>
9
+ <% @entry_types.each do |type| %>
10
+ <%= link_to type.titleize, root_path(type: type), class: "filter-btn #{@current_type == type ? 'active' : ''}" %>
11
+ <% end %>
12
+ </nav>
13
+
14
+ <div class="railscope-entries">
15
+ <% if @entries.any? %>
16
+ <table class="entries-table">
17
+ <thead>
18
+ <tr>
19
+ <th>Type</th>
20
+ <th>Summary</th>
21
+ <th>Tags</th>
22
+ <th>Occurred</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <% @entries.each do |entry| %>
27
+ <tr class="entry-row entry-type-<%= entry.entry_type %>" onclick="window.location='<%= entry_path(entry) %>'" style="cursor: pointer;">
28
+ <td class="entry-type">
29
+ <span class="type-badge <%= entry.entry_type %>"><%= entry.entry_type %></span>
30
+ </td>
31
+ <td class="entry-summary">
32
+ <%= link_to entry_path(entry), class: "entry-link" do %>
33
+ <%= render_entry_summary(entry) %>
34
+ <% end %>
35
+ </td>
36
+ <td class="entry-tags">
37
+ <div class="tags-wrapper">
38
+ <% entry.tags.each do |tag| %>
39
+ <%= link_to tag, root_path(tag: tag, type: @current_type), class: "tag", onclick: "event.stopPropagation();" %>
40
+ <% end %>
41
+ </div>
42
+ </td>
43
+ <td class="entry-time">
44
+ <span title="<%= entry.occurred_at %>"><%= time_ago_in_words(entry.occurred_at) %> ago</span>
45
+ </td>
46
+ </tr>
47
+ <% end %>
48
+ </tbody>
49
+ </table>
50
+ <% else %>
51
+ <div class="empty-state">
52
+ <p>No entries found.</p>
53
+ </div>
54
+ <% end %>
55
+ </div>
56
+
57
+ <% if @total_pages > 1 %>
58
+ <nav class="railscope-pagination">
59
+ <% if @current_page > 1 %>
60
+ <%= link_to "Previous", root_path(page: @current_page - 1, type: @current_type, tag: params[:tag]), class: "page-btn" %>
61
+ <% end %>
62
+
63
+ <span class="page-info">Page <%= @current_page %> of <%= @total_pages %></span>
64
+
65
+ <% if @current_page < @total_pages %>
66
+ <%= link_to "Next", root_path(page: @current_page + 1, type: @current_type, tag: params[:tag]), class: "page-btn" %>
67
+ <% end %>
68
+ </nav>
69
+ <% end %>
70
+ </div>
@@ -0,0 +1,93 @@
1
+ <div class="railscope-container">
2
+ <header class="railscope-header">
3
+ <nav class="breadcrumb">
4
+ <%= link_to "← Back to entries", root_path, class: "back-link" %>
5
+ </nav>
6
+ <div class="entry-header">
7
+ <span class="type-badge <%= @entry.entry_type %>"><%= @entry.entry_type %></span>
8
+ <h1><%= entry_title(@entry) %></h1>
9
+ </div>
10
+ </header>
11
+
12
+ <div class="entry-detail">
13
+ <div class="detail-grid">
14
+ <div class="detail-main">
15
+ <section class="detail-section">
16
+ <h2>Details</h2>
17
+ <div class="detail-meta">
18
+ <div class="meta-item">
19
+ <span class="meta-label">Occurred at</span>
20
+ <span class="meta-value"><%= @entry.occurred_at.strftime("%Y-%m-%d %H:%M:%S.%L") %></span>
21
+ </div>
22
+ <div class="meta-item">
23
+ <span class="meta-label">Entry ID</span>
24
+ <span class="meta-value mono"><%= @entry.id %></span>
25
+ </div>
26
+ <% if @entry.payload["request_id"] %>
27
+ <div class="meta-item">
28
+ <span class="meta-label">Request ID</span>
29
+ <span class="meta-value mono"><%= @entry.payload["request_id"] %></span>
30
+ </div>
31
+ <% end %>
32
+ <% if @entry.payload["duration"] %>
33
+ <div class="meta-item">
34
+ <span class="meta-label">Duration</span>
35
+ <span class="meta-value"><%= @entry.payload["duration"] %>ms</span>
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+ </section>
40
+
41
+ <section class="detail-section">
42
+ <h2>Tags</h2>
43
+ <div class="tags-wrapper">
44
+ <% @entry.tags.each do |tag| %>
45
+ <%= link_to tag, root_path(tag: tag), class: "tag" %>
46
+ <% end %>
47
+ </div>
48
+ </section>
49
+
50
+ <section class="detail-section">
51
+ <h2>Payload</h2>
52
+ <div class="json-viewer">
53
+ <%= render_json(@entry.payload) %>
54
+ </div>
55
+ </section>
56
+
57
+ <% if @entry.entry_type == "exception" && @entry.payload["backtrace"].present? %>
58
+ <section class="detail-section">
59
+ <h2>Backtrace</h2>
60
+ <div class="backtrace">
61
+ <% @entry.payload["backtrace"].each_with_index do |line, i| %>
62
+ <div class="backtrace-line">
63
+ <span class="line-number"><%= i + 1 %></span>
64
+ <code><%= line %></code>
65
+ </div>
66
+ <% end %>
67
+ </div>
68
+ </section>
69
+ <% end %>
70
+ </div>
71
+
72
+ <div class="detail-sidebar">
73
+ <% if @related_entries.any? %>
74
+ <section class="detail-section">
75
+ <h2>Timeline</h2>
76
+ <p class="timeline-info">Events from the same request</p>
77
+ <div class="timeline">
78
+ <% @related_entries.each do |entry| %>
79
+ <div class="timeline-item <%= entry.id == @entry.id ? 'current' : '' %>">
80
+ <%= link_to entry_path(entry), class: "timeline-link" do %>
81
+ <span class="timeline-type type-badge <%= entry.entry_type %>"><%= entry.entry_type %></span>
82
+ <span class="timeline-summary"><%= timeline_summary(entry) %></span>
83
+ <span class="timeline-time"><%= entry.payload["duration"] ? "#{entry.payload['duration']}ms" : "" %></span>
84
+ <% end %>
85
+ </div>
86
+ <% end %>
87
+ </div>
88
+ </section>
89
+ <% end %>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
data/client/.gitignore ADDED
@@ -0,0 +1 @@
1
+ /node_modules
data/client/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Railscope</title>
7
+ </head>
8
+ <body class="bg-dark-bg text-dark-text">
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>