rails_observatory 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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