rails_observatory 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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +42 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/config/rails_observatory_manifest.js +2 -0
  5. data/app/assets/images/rails_observatory/logo.svg +8 -0
  6. data/app/assets/js/application.js +88 -0
  7. data/app/assets/js/controllers/chart_controller.js +176 -0
  8. data/app/assets/js/controllers/event_details_controller.js +15 -0
  9. data/app/assets/js/controllers/index.js +9 -0
  10. data/app/assets/js/controllers/sparkline_controller.js +72 -0
  11. data/app/assets/stylesheets/application/card.css +51 -0
  12. data/app/assets/stylesheets/application/chart.css +34 -0
  13. data/app/assets/stylesheets/application/dropdown.css +62 -0
  14. data/app/assets/stylesheets/application/global_modifiers.css +10 -0
  15. data/app/assets/stylesheets/application/query_table.css +68 -0
  16. data/app/assets/stylesheets/application/side_nav.css +62 -0
  17. data/app/assets/stylesheets/application/side_panel.css +35 -0
  18. data/app/assets/stylesheets/application/tab_nav.css +64 -0
  19. data/app/assets/stylesheets/application/table_chart.css +66 -0
  20. data/app/assets/stylesheets/application/tbd.css +70 -0
  21. data/app/assets/stylesheets/application/top_nav.css +33 -0
  22. data/app/assets/stylesheets/application.css +42 -0
  23. data/app/assets/stylesheets/elements/a.css +8 -0
  24. data/app/assets/stylesheets/elements/button.css +21 -0
  25. data/app/assets/stylesheets/elements/details.css +12 -0
  26. data/app/assets/stylesheets/elements/root.css +26 -0
  27. data/app/assets/stylesheets/elements/section.css +9 -0
  28. data/app/assets/stylesheets/errors/show/details.css +13 -0
  29. data/app/assets/stylesheets/layout/app.css +23 -0
  30. data/app/assets/stylesheets/layout/details-side-panel.css +15 -0
  31. data/app/assets/stylesheets/layout/requests.css +45 -0
  32. data/app/assets/stylesheets/layout/two-column.css +17 -0
  33. data/app/assets/stylesheets/mixins/nav_button.css +19 -0
  34. data/app/assets/stylesheets/requests/stats.css +35 -0
  35. data/app/controllers/rails_observatory/application_controller.rb +24 -0
  36. data/app/controllers/rails_observatory/errors_controller.rb +27 -0
  37. data/app/controllers/rails_observatory/jobs_controller.rb +25 -0
  38. data/app/controllers/rails_observatory/mailers_controller.rb +11 -0
  39. data/app/controllers/rails_observatory/requests_controller.rb +33 -0
  40. data/app/helpers/rails_observatory/application_helper.rb +110 -0
  41. data/app/jobs/rails_observatory/application_job.rb +4 -0
  42. data/app/mailers/rails_observatory/application_mailer.rb +6 -0
  43. data/app/views/layouts/rails_observatory/application.html.erb +93 -0
  44. data/app/views/new_user_mailer/greeting.html.erb +1 -0
  45. data/app/views/posts/index.html.erb +1 -0
  46. data/app/views/rails_observatory/application/_chart.html.erb +23 -0
  47. data/app/views/rails_observatory/application/_events_table.html.erb +24 -0
  48. data/app/views/rails_observatory/application/_sparkline.html.erb +17 -0
  49. data/app/views/rails_observatory/application/_trace.html.erb +122 -0
  50. data/app/views/rails_observatory/errors/index.html.erb +87 -0
  51. data/app/views/rails_observatory/errors/show.html.erb +193 -0
  52. data/app/views/rails_observatory/jobs/_table_chart.html.erb +29 -0
  53. data/app/views/rails_observatory/jobs/index.html.erb +20 -0
  54. data/app/views/rails_observatory/jobs/show.html.erb +8 -0
  55. data/app/views/rails_observatory/logs/index.html.erb +18 -0
  56. data/app/views/rails_observatory/mailers/index.html.erb +11 -0
  57. data/app/views/rails_observatory/mailers/show.html.erb +10 -0
  58. data/app/views/rails_observatory/requests/_text_gauge.html.erb +4 -0
  59. data/app/views/rails_observatory/requests/index.html.erb +56 -0
  60. data/app/views/rails_observatory/requests/show.html.erb +16 -0
  61. data/config/routes.rb +7 -0
  62. data/lib/rails_observatory/action_mailer_subscriber.rb +14 -0
  63. data/lib/rails_observatory/engine.rb +49 -0
  64. data/lib/rails_observatory/event_collector.rb +43 -0
  65. data/lib/rails_observatory/log_collector.rb +46 -0
  66. data/lib/rails_observatory/mailer_previews/delivered_mail_preview.rb +9 -0
  67. data/lib/rails_observatory/middleware.rb +77 -0
  68. data/lib/rails_observatory/models/error.rb +67 -0
  69. data/lib/rails_observatory/models/event_collection.rb +137 -0
  70. data/lib/rails_observatory/models/events.rb +22 -0
  71. data/lib/rails_observatory/models/job_trace.rb +28 -0
  72. data/lib/rails_observatory/models/logs.rb +9 -0
  73. data/lib/rails_observatory/models/mail_delivery.rb +33 -0
  74. data/lib/rails_observatory/models/redis_model.rb +112 -0
  75. data/lib/rails_observatory/models/request_trace.rb +29 -0
  76. data/lib/rails_observatory/railties/active_job_instrumentation.rb +48 -0
  77. data/lib/rails_observatory/railties/redis_runtime.rb +11 -0
  78. data/lib/rails_observatory/redis/logging_middleware.rb +22 -0
  79. data/lib/rails_observatory/redis/redis_client_instrumentation.rb +18 -0
  80. data/lib/rails_observatory/redis/time_series/increment_script.lua +67 -0
  81. data/lib/rails_observatory/redis/time_series/insertion.rb +73 -0
  82. data/lib/rails_observatory/redis/time_series/query_builder.rb +149 -0
  83. data/lib/rails_observatory/redis/time_series/timing_script.lua +89 -0
  84. data/lib/rails_observatory/redis/time_series.rb +91 -0
  85. data/lib/rails_observatory/serializers/event_serializer.rb +19 -0
  86. data/lib/rails_observatory/serializers/headers_serializer.rb +12 -0
  87. data/lib/rails_observatory/serializers/job_serializer.rb +11 -0
  88. data/lib/rails_observatory/serializers/mail_delivery_job_serializer.rb +14 -0
  89. data/lib/rails_observatory/serializers/request_serializer.rb +17 -0
  90. data/lib/rails_observatory/serializers/response_serializer.rb +14 -0
  91. data/lib/rails_observatory/serializers/serializer.rb +51 -0
  92. data/lib/rails_observatory/version.rb +3 -0
  93. data/lib/rails_observatory.rb +3 -0
  94. data/public/assets/js/application.js +11186 -0
  95. data/public/assets/logo_with_text.svg +21 -0
  96. data/public/assets/stylesheets/application.css +757 -0
  97. metadata +197 -0
@@ -0,0 +1,193 @@
1
+ <% content_for(:title) do %>
2
+ <%= @error.class_name %>
3
+ <span class="_location">
4
+ in <%= @error.location.classify %>
5
+ </span>
6
+ <% end %>
7
+ <% content_for(:hide_duration, 'true') %>
8
+ <% content_for(:main_css_class, 'layout-two-column') %>
9
+
10
+ <style>
11
+
12
+
13
+ ._location {
14
+ font-weight: 200;
15
+ }
16
+
17
+
18
+ .hll {
19
+ background-color: #49483e;
20
+ /*text-underline-style: wave;*/
21
+ /*text-decoration: underline;*/
22
+
23
+ & pre > span {
24
+ text-decoration-line: underline;
25
+ text-decoration-style: wavy;
26
+ text-decoration-color: var(--red);
27
+ }
28
+ }
29
+
30
+ .highlight {
31
+ /*background-color: var(--surface-card);*/
32
+ width: 100%;
33
+ overflow: auto;
34
+
35
+ & table {
36
+ border-spacing: 0;
37
+ width: 100%;
38
+ }
39
+
40
+ & tr td:first-child {
41
+ padding-inline: .25rem;
42
+ position: sticky;
43
+ left: 0;
44
+ background-color: #161b22;
45
+
46
+ .hll & {
47
+ background-color: #49483e;
48
+ }
49
+ }
50
+ }
51
+
52
+ .backtrace-line {
53
+ padding: .5rem 1rem;
54
+ display: flex;
55
+ flex-direction: column;
56
+ border-top: 1px solid var(--divider);
57
+ gap: 1rem;
58
+ color: var(--black-secondary);
59
+
60
+ &.--application-frame {
61
+ & summary::after {
62
+ content: 'Application Frame';
63
+ font-size: .75rem;
64
+ text-align: right;
65
+ color: color-mix(in oklab, #0c8be8 5%, var(--white));
66
+ padding: .1rem .35rem;
67
+ border-radius: .35rem;
68
+ font-weight: 400;
69
+ /* text gradient fill, blue to white */
70
+ background: color-mix(in oklab, #0c8be8 80%, var(--black));
71
+ border: 1px solid color-mix(in oklab, #0c8be8 60%, var(--white));
72
+ /*background-clip: text;*/
73
+
74
+ }
75
+ }
76
+
77
+ & > :not(summary) {
78
+ margin-top: 1rem;
79
+ }
80
+
81
+ summary {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: .5rem;
85
+ color: var(--white);
86
+
87
+ & > :not(svg) {
88
+ flex-grow: 1;
89
+ }
90
+
91
+ & > svg {
92
+ flex-shrink: 0;
93
+ }
94
+ }
95
+
96
+ ._path {
97
+ white-space: nowrap;
98
+ overflow: hidden;
99
+ text-overflow: ellipsis;
100
+ }
101
+
102
+
103
+ }
104
+
105
+ .framework-trace-chunk {
106
+ border-top: 1px solid var(--divider);
107
+ font-style: italic;
108
+ color: var(--black-secondary);
109
+
110
+
111
+ &[open] {
112
+
113
+ color: color-mix(in oklab, var(--black-secondary) 40%, var(--white));
114
+ }
115
+
116
+ & > summary {
117
+ &:hover {
118
+ /*background-color: var(--surface-active);*/
119
+ }
120
+
121
+ padding: .5rem 1rem;
122
+ display: flex;
123
+ gap: .5rem;
124
+ }
125
+ }
126
+ </style>
127
+
128
+ <div class="layout-two-column-side-panel side-panel">
129
+ <dl>
130
+ <dt>Occurrences</dt>
131
+ <dd class="_occurrence_count"><%= @count %></dd>
132
+ <dt>Last Seen</dt>
133
+ <dd title="<%= @error.time %>"><%= time_ago_in_words(@error.time) %> ago</dd>
134
+
135
+ <dt>Past 24 Hours</dt>
136
+ <dd>
137
+ <%= render "sparkline", type: "bar", series: @past_24_hours %>
138
+ </dd>
139
+
140
+ <dt>Past 7 Days</dt>
141
+ <dd>
142
+ <%= render "sparkline", type: "bar", series: @past_7_days %>
143
+ </dd>
144
+
145
+ <dt>Past 30 Days</dt>
146
+ <dd><%= render "sparkline", type: "bar", series: @past_30_days %></dd>
147
+ </dl>
148
+ </div>
149
+
150
+ <div class="layout-two-column-main">
151
+ <div style="padding: 1rem;padding-top:0;padding-bottom:2rem;">
152
+ <h2><%= @error.message %></h2>
153
+ <% @error.causes.each do |cause| %>
154
+ <div style="color: var(--black-secondary);text-transform: uppercase; font-weight: var(--font-weight-bold);">caused by:</div>
155
+ <h2><%= cause["class_name"] %></h2>
156
+ <%= cause['message'] %>
157
+ <% end %>
158
+ </div>
159
+
160
+ <div class="card">
161
+ <h2>Trace</h2>
162
+ <% @error.trace.slice_when { _1["is_application_trace"] != _2["is_application_trace"] }.each do |trace_chunk| %>
163
+ <% if !trace_chunk.first["is_application_trace"] %>
164
+ <details class="framework-trace-chunk">
165
+ <summary>
166
+ <svg class="arrow-right" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="16">
167
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
168
+ </svg>
169
+ Plus <%= trace_chunk.length %> Framework frames
170
+ </summary>
171
+ <% end %>
172
+ <% trace_chunk.each do |trace_line| %>
173
+ <details class="backtrace-line <%= '--application-frame' if trace_line["is_application_trace"] %>" <%= 'open' if trace_line["is_application_trace"] %>>
174
+ <summary>
175
+ <svg class="arrow-right" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="16">
176
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
177
+ </svg>
178
+ <span class="_path"><%= trace_line["trace"] %></span>
179
+ </summary>
180
+ <div class="highlight">
181
+ <%#= trace_line %>
182
+ <%== highlight_source_extract(@error.source_extracts[trace_line["id"]]) %>
183
+ </div>
184
+ </details>
185
+ <% end %>
186
+ <% if !trace_chunk.first["is_application_trace"] %>
187
+ </details>
188
+ <% end %>
189
+ <% end %>
190
+ </div>
191
+
192
+
193
+ </div>
@@ -0,0 +1,29 @@
1
+ <% hashes = metrics.map { |name, agg| RailsObservatory::TimeSeries.where(name:).group(group_by).send(agg) } %>
2
+
3
+ <section>
4
+ <h2><%= name %></h2>
5
+
6
+ <div class="table-chart">
7
+ <table>
8
+ <thead>
9
+ <tr>
10
+ <th><%= group_by %></th>
11
+ <% metrics.keys.each do |k| %>
12
+ <th><%= k %></th>
13
+ <% end %>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% hashes.first.each do |label, val| %>
18
+ <tr>
19
+ <td><%= label %></td>
20
+ <td><%= val %></td>
21
+ <% hashes.slice(1..).each do |h| %>
22
+ <td><%= h[label] %></td>
23
+ <% end %>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ </div>
29
+ </section>
@@ -0,0 +1,20 @@
1
+ <% content_for(:title, 'Jobs') %>
2
+
3
+ <%#= render 'chart', name: 'Latency', series: @latency_series, type: 'area', aggregate_using: :avg %>
4
+ <div style="padding: 2rem;">
5
+ <%= render 'chart', name: 'Performed', series: series_for(name: 'job.count', aggregate_using: :sum), type: 'bar' %>
6
+ </div>
7
+
8
+ <%= render 'table_chart', name: 'By Queue', group_by: :queue_name, metrics: { "job.count": :sum, "job.queue_latency": :last } %>
9
+ <%= render 'table_chart', name: 'By Job', group_by: :job_class, metrics: { "job.count": :sum, latency: :avg, "job.error_count": :sum } %>
10
+
11
+ <section>
12
+ <h2>Recent Jobs</h2>
13
+ <%= render 'events_table',
14
+ events: @recent_jobs,
15
+ fields: [:time, :job_id, :job_class, :duration],
16
+ formatters: {
17
+ time: ->(time, event) { link_to Time.at(time).to_fs('%H:%M:%S'), job_path(event.job_id) },
18
+ }
19
+ %>
20
+ </section>
@@ -0,0 +1,8 @@
1
+ <% content_for :title do %>
2
+ <%= @job.job_class %><br/>
3
+ <span style="font-size: 1rem; font-weight: 200;"><%= time_ago_in_words(Time.at(@job.time)) %> ago
4
+ | <%= @job.duration.round(2) %>ms</span>
5
+ <% end %>
6
+ <% content_for(:hide_duration, 'true') %>
7
+
8
+ <%= render 'trace', model: @job, events: @events %>
@@ -0,0 +1,18 @@
1
+ <% content_for(:title, 'Logs') %>
2
+
3
+ <style>
4
+ @scope {
5
+ :scope {
6
+ display: grid;
7
+ gap: 2rem;
8
+ grid-template-columns: minmax(400px, 1fr);
9
+ }
10
+ }
11
+ </style>
12
+
13
+ <%= render 'chart', type: 'bar', series: @logs_by_type, name: 'Log Volume' %>
14
+
15
+ <div class="card">
16
+ <h2>Recent Logs</h2>
17
+ <%= render 'events_table', events: @recent_logs, fields: [:timestamp, :level, :message, :request_id] %>
18
+ </div>
@@ -0,0 +1,11 @@
1
+ <% content_for(:title, 'Mailers') %>
2
+ <section>
3
+ <h2>Email Delivered</h2>
4
+
5
+ <%= render 'events_table', events: @deliveries, fields: [
6
+ :time, :to, :from, :subject, :view], formatters: {
7
+ time: ->(time, event) { Time.at(time).to_fs('%H:%M:%S') },
8
+ view: ->(view, event) { link_to 'View', preview_mail_path(event.message_id) }
9
+ }
10
+ %>
11
+ </section>
@@ -0,0 +1,10 @@
1
+ <%= content_for :title, 'Mail' %>
2
+ <% content_for(:hide_duration, 'true') %>
3
+
4
+ <div class="card">
5
+ <h2>Mail Preview</h2>
6
+ <div>To: <%= @mail.to.join(', ') %></div>
7
+ <div>From: <%= @mail.from.join(', ') %></div>
8
+ <div>Subject: <%= @mail.subject %></div>
9
+ <%== @mail.body %>
10
+ </div>
@@ -0,0 +1,4 @@
1
+ <text-gauge>
2
+ <text-gauge-title><%= title %></text-gauge-title>
3
+ <text-gauge-value><%= value %><span style="color: var(--black-secondary);font-size:1.5rem"><%= local_assigns[:unit] %></span></text-gauge-value>
4
+ </text-gauge>
@@ -0,0 +1,56 @@
1
+ <% content_for(:title, params[:controller_action].presence || 'Requests') %>
2
+ <% content_for(:subtitle, 'Requests') if params[:controller_action] %>
3
+ <% content_for(:main_css_class, 'layout-requests_index') %>
4
+ <div class="layout-requests_index-glance" style="display:flex;justify-content: space-evenly">
5
+ <% request_count = series_value(name: "request.count", aggregate_using: :sum) %>
6
+ <% if request_count.to_i > 0 %>
7
+ <%= render 'text_gauge', title: 'Requests', value: series_value(name: "request.count", aggregate_using: :sum) %>
8
+ <%= render 'text_gauge', title: 'Throughput', value: series_value(name: "request.count", aggregate_using: :sum).fdiv(duration / 60).round(2), unit: 'rpm' %>
9
+ <% end %>
10
+ </div>
11
+ <div class="layout-requests_index-chart">
12
+ <%= render 'chart', name: 'Requests', type: 'bar', series: series_for(name: 'request.count', aggregate_using: :sum, action: params[:controller_action]) %>
13
+ <%= render 'chart', name: 'Latency', type: 'area', series: series_for(name: 'request.latency', aggregate_using: :avg, action: params[:controller_action]) %>
14
+
15
+ <%#= render 'chart', name: "Latency Breakdown", series: @latency_composition, type: 'area' %>
16
+ <%#= render 'chart', name: 'Errors', series: @errors, type: 'bar', palette: 'palette7' %>
17
+ </div>
18
+ <% if params[:controller_action].blank? %>
19
+ <section class="layout-requests_index-by_controller">
20
+ <h2>By Controller Action</h2>
21
+ <div class="table-chart ">
22
+ <table>
23
+ <thead>
24
+ <tr>
25
+ <th>Controller Action</th>
26
+ <th>Requests</th>
27
+ <th>Avg Latency</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ <% @count_by_controller.each do |series| %>
32
+ <tr>
33
+ <td><%= link_to series.labels[:action].classify, requests_path(controller_action: series.labels[:action].to_param), class: 'table-chart-row-action' %></td>
34
+ <td><%= series.data.dig(0, 1) %></td>
35
+ <td><%= @latency_by_controller[series.labels[:action]]&.data&.dig(0, 1).to_f.round(2) %> ms</td>
36
+ </tr>
37
+ <% end %>
38
+
39
+ <!-- More people... -->
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ </section>
44
+ <% end %>
45
+ <section class="layout-requests_index-events">
46
+ <h2>Recent Requests</h2>
47
+
48
+
49
+ <%= render 'events_table', events: @events, fields: [
50
+ :time, :http_method, :action, :route_pattern, :duration, :status,
51
+ ], formatters: {
52
+ time: ->(time, event) { link_to Time.at(time).to_fs('%H:%M:%S'), request_path(event.request_id) },
53
+ } %>
54
+ </section>
55
+
56
+
@@ -0,0 +1,16 @@
1
+ <% content_for :title do %>
2
+ <span style="font-weight:200"><%= @request.http_method %></span> <%= @request.name.classify %>
3
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" height="18">
4
+ <path stroke-linecap="round" stroke-linejoin="round" d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3" />
5
+ </svg>
6
+ <span class="status-<%= 'success' if @request.status.to_s.start_with?('2') %>"><%= @request.status %> <%= Rack::Utils::HTTP_STATUS_CODES[@request.status] %></span>
7
+ <br>
8
+ <span style="font-size: 1rem; font-weight: 200;"><%= time_ago_in_words(Time.at(@request.time)) %> ago
9
+ | <%= @request.duration.round(2) %>ms
10
+ | <%= @request.path %></span>
11
+ <% end %>
12
+ <% content_for(:hide_duration, 'true') %>
13
+
14
+ <% content_for(:main_css_class, 'layout-events-breakdown') %>
15
+ <%= render 'trace', model: @request, events: @events %>
16
+
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ RailsObservatory::Engine.routes.draw do
2
+ resources :requests, only: [:index, :show]
3
+ resources :jobs, only: [:index, :show]
4
+ resources :mailers, only: [:index, :show]
5
+ resources :errors, only: [:index, :show]
6
+ root to: "requests#index"
7
+ end
@@ -0,0 +1,14 @@
1
+ require_relative './models/mail_delivery'
2
+ module RailsObservatory
3
+ class ActionMailerSubscriber < ActiveSupport::Subscriber
4
+ attach_to :action_mailer
5
+
6
+ def deliver(event)
7
+ event.payload => {mail:, mailer:, to:, from:, subject:, message_id:}
8
+ MailDelivery.new(mail:, mailer:, to:, from:, subject:, message_id:, time: Time.now.to_f, duration: event.duration).save
9
+
10
+ TimeSeries.record_occurrence("mailer.delivery_count", labels: {mailer:})
11
+ end
12
+
13
+ end
14
+ end
@@ -0,0 +1,49 @@
1
+ require 'redis-client'
2
+
3
+ require_relative './action_mailer_subscriber'
4
+ module RailsObservatory
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace RailsObservatory
7
+
8
+ # This middleware is specific to the engine and will not be included in the application stack
9
+ middleware.use(Rack::Static, urls: ["/assets"], root: config.root.join("public"))
10
+
11
+ config.rails_observatory = ActiveSupport::OrderedOptions.new
12
+ config.rails_observatory.redis = { host: "localhost", port: 6379, db: 0, pool_size: ENV["RAILS_MAX_THREADS"] || 3 }
13
+
14
+ initializer "rails_observatory.redis" do |app|
15
+ require_relative './redis/logging_middleware'
16
+ require_relative './redis/redis_client_instrumentation'
17
+ app.config.rails_observatory.redis => pool_size:, **redis_config
18
+ redis_config = RedisClient.config(**redis_config.merge(middlewares: [RedisClientInstrumentation]))
19
+ app.config.rails_observatory.redis = redis_config.new_pool(timeout: 0.5, size: pool_size)
20
+ end
21
+
22
+ initializer "rails_observatory.middleware" do |app|
23
+ require_relative './middleware'
24
+
25
+ # Middleware is not instrumented UNLESS there's a subscriber listening.
26
+ # By instantiating the collector, we ensure that the InstrumentationProxy is used
27
+ EventCollector.instance
28
+
29
+ app.middleware.unshift(Middleware)
30
+ end
31
+
32
+ initializer "rails_observatory.active_job_instrumentation" do
33
+ require_relative './models/job_trace'
34
+ ActiveSupport.on_load(:active_job) do |active_job|
35
+ require_relative './railties/active_job_instrumentation'
36
+ active_job.include(Railties::ActiveJobInstrumentation)
37
+ end
38
+ end
39
+
40
+ initializer "rails_observatory.logger" do |app|
41
+ require_relative './log_collector'
42
+ Rails.logger.broadcast_to(LogCollector.new)
43
+ end
44
+
45
+ initializer "rails_observatory.mailer_instrumentation" do |app|
46
+ config.action_mailer.preview_paths << "#{config.root}/lib/rails_observatory/mailer_previews"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,43 @@
1
+ module RailsObservatory
2
+ class EventCollector
3
+ include Singleton
4
+
5
+ COLLECTOR_LIST_KEY = :ro_collector
6
+
7
+ def initialize
8
+ @subscriber ||= ActiveSupport::Notifications.subscribe(/\A[^!]/, self)
9
+ end
10
+
11
+ def call(event)
12
+ return if ActiveSupport::IsolatedExecutionState[COLLECTOR_LIST_KEY].blank?
13
+
14
+ ActiveSupport::IsolatedExecutionState[COLLECTOR_LIST_KEY].each do |key|
15
+ if (events = ActiveSupport::IsolatedExecutionState[key])
16
+ events << event
17
+ end
18
+ end
19
+
20
+ end
21
+
22
+ def generate_collector_key
23
+ "collector:#{Object.new.object_id}"
24
+ end
25
+
26
+
27
+ def collect_events
28
+ events = []
29
+ key = generate_collector_key
30
+ ActiveSupport::IsolatedExecutionState[COLLECTOR_LIST_KEY] ||= []
31
+ ActiveSupport::IsolatedExecutionState[COLLECTOR_LIST_KEY] << key
32
+ ActiveSupport::IsolatedExecutionState[key] = events
33
+ yield
34
+ events
35
+ rescue Exception => e
36
+ e.instance_variable_set(:@_trace_events, events)
37
+ raise
38
+ ensure
39
+ ActiveSupport::IsolatedExecutionState[COLLECTOR_LIST_KEY].delete(key)
40
+ ActiveSupport::IsolatedExecutionState.delete(key)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ module RailsObservatory
2
+ class LogCollector < ::Logger
3
+
4
+ KEY = :observatory_logs
5
+
6
+ def initialize(*args, **kwargs)
7
+ _, *rest = args
8
+ super(nil, *rest, **kwargs)
9
+ end
10
+
11
+ def self.collect_logs
12
+ logs = []
13
+ ActiveSupport::IsolatedExecutionState[KEY] = logs
14
+ yield
15
+ logs
16
+ ensure
17
+ ActiveSupport::IsolatedExecutionState.delete(KEY)
18
+ end
19
+
20
+ def add(severity, message = nil, progname = nil, &block)
21
+ if (logs = ActiveSupport::IsolatedExecutionState[KEY])
22
+ severity ||= UNKNOWN
23
+ return true if severity < level
24
+ progname = @progname if progname.nil?
25
+ if message.nil?
26
+ if block_given?
27
+ message = yield
28
+ else
29
+ message = progname
30
+ progname = @progname
31
+ end
32
+ end
33
+ logs << { severity:, message:, progname:, time: Time.now.to_f }
34
+ end
35
+ end
36
+
37
+ alias log add
38
+
39
+ def <<(message)
40
+ if (logs = ActiveSupport::IsolatedExecutionState[KEY])
41
+ logs << { severity: UNKNOWN, message:, progname: nil, time: Time.now.to_f }
42
+ end
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # Preview all emails at http://localhost:3000/rails/mailers/new_user_mailer
2
+ class DeliveredMailPreview < ActionMailer::Preview
3
+
4
+ def preview
5
+ mail_delivery = RailsObservatory::MailDelivery.find(params[:message_id])
6
+ Mail.new(mail_delivery.mail)
7
+ end
8
+
9
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative './event_collector'
5
+ require_relative './models/request_trace'
6
+ require_relative './models/error'
7
+ require_relative './serializers/serializer'
8
+ require 'zlib'
9
+ require 'benchmark'
10
+
11
+ module RailsObservatory
12
+ class Middleware
13
+
14
+ def initialize(app)
15
+ @app = app
16
+ end
17
+
18
+ def collect_events_and_logs
19
+ logs = []
20
+ events = EventCollector.instance.collect_events do
21
+ logs = LogCollector.collect_logs do
22
+ yield
23
+ end
24
+ end
25
+ [events, logs]
26
+ end
27
+
28
+ def call(env)
29
+ start_at = Time.now
30
+ request = ActionDispatch::Request.new(env)
31
+
32
+ start_at_mono = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
33
+ response = nil
34
+ events, logs = collect_events_and_logs do
35
+ response = @app.call(env)
36
+ end
37
+
38
+ controller_action = "#{request.params[:controller]}##{request.params[:action]}"
39
+
40
+ if (event = events.find { _1.name == 'process_action.action_controller' && _1.payload[:exception_object] })
41
+ error = Error.new(exception: event.payload[:exception_object], location: controller_action, time: Time.now)
42
+ error.save
43
+ TimeSeries.record_occurrence("error.count", labels: { fingerprint: error.fingerprint })
44
+ end
45
+
46
+ return response if request.params[:controller].blank? || request.params[:controller] =~ /rails_observatory/
47
+
48
+ status, headers, body = response
49
+ body = ::Rack::BodyProxy.new(body) do
50
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start_at_mono)
51
+ RequestTrace.new(
52
+ request_id: request.request_id,
53
+ status:,
54
+ http_method: request.method,
55
+ route_pattern: request.route_uri_pattern,
56
+ action: controller_action,
57
+ error: events.any? { _1.payload[:exception] },
58
+ format: request.format,
59
+ duration:,
60
+ time: start_at.to_f,
61
+ path: request.path,
62
+ events: events.map { Serializer.serialize(_1) },
63
+ logs:
64
+ ).save
65
+ labels = { action: controller_action, format: request.format, status:, http_method: request.method }
66
+ TimeSeries.record_occurrence("request.count", labels:)
67
+ TimeSeries.record_occurrence("request.error_count", labels:) if status >= 500
68
+ TimeSeries.record_timing("request.latency", duration, labels:)
69
+ rescue => e
70
+ puts e
71
+ puts e.backtrace.join("\n")
72
+ end
73
+
74
+ [status, headers, body]
75
+ end
76
+ end
77
+ end