studio-engine 0.104.0 → 0.105.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 +4 -4
- data/CHANGELOG.md +17 -0
- data/app/jobs/studio/health_report_job.rb +28 -0
- data/app/mailers/studio/health_report_mailer.rb +20 -0
- data/app/views/studio/health_report_mailer/daily.html.erb +71 -0
- data/app/views/studio/health_report_mailer/daily.text.erb +26 -0
- data/lib/studio/health_report.rb +223 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +1 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5e9eefb6bad47abe79849c59d248536a70bb9275c6f7074210bfdd88b975e76
|
|
4
|
+
data.tar.gz: 69fd09fd720f85729f90cb354ff8273665ec4f98b04f63368bbc093e57188468
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66b905941833f30f59c9595b34a2c5485089685212e44970acb04f090f0d6fd157681b2523e11c577037bf666420743f5a9646ed50d8d8a4e3593861877aa4d7
|
|
7
|
+
data.tar.gz: cbb687874917a0b899690134eaa19ba94b80d52160c74e846b3b2d81295f2ef64050ca6b3b51591b5184f6bd4c53a65037b8f1741bac2e7c9a7a9b1e85a62be4
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.105.0 — 2026-10-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`Studio::HealthReportJob`: a daily application health report, off by
|
|
12
|
+
default.** One email at 07:00 in the app's zone listing the last day's
|
|
13
|
+
signups, `ErrorLog` errors grouped by class and message with counts and first
|
|
14
|
+
and last seen, and contact-form submissions when the app names a source; a
|
|
15
|
+
quiet day sends a one-line all-clear. The day runs 07:00 to 07:00 on the
|
|
16
|
+
wall clock, so a daylight-saving change makes it 23 or 25 hours, and a late
|
|
17
|
+
run reports the same day. Nothing sends until a host schedules the job AND
|
|
18
|
+
names a recipient (`config.health_report_to` or `STUDIO_HEALTH_REPORT_TO`);
|
|
19
|
+
QA never sends. New settings: `health_report_to`, `health_report_zone`
|
|
20
|
+
(default `America/Denver`), `health_report_hour` (default `7`),
|
|
21
|
+
`health_report_signups` (default: the host's `User` by `created_at`) and
|
|
22
|
+
`health_report_contacts` (default: none). See `docs/HEALTH_REPORT.md`.
|
|
23
|
+
|
|
7
24
|
## 0.104.0 — 2026-10-11
|
|
8
25
|
|
|
9
26
|
### Added
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Emails the daily application health report (Studio::HealthReport). The
|
|
5
|
+
# engine schedules nothing: a host turns it on by registering this job at
|
|
6
|
+
# Studio.health_report_hour in Studio.health_report_zone AND naming a
|
|
7
|
+
# recipient. docs/HEALTH_REPORT.md has both snippets.
|
|
8
|
+
#
|
|
9
|
+
# Best-effort, like mcritchie-studio's ContactFlaggedDigestJob: a failure is an
|
|
10
|
+
# ErrorLog receipt (and so a line in tomorrow's report), never a retry storm.
|
|
11
|
+
class HealthReportJob < (defined?(::ApplicationJob) ? ::ApplicationJob : ActiveJob::Base)
|
|
12
|
+
queue_as :default
|
|
13
|
+
|
|
14
|
+
def perform(now = Time.current)
|
|
15
|
+
# QA boots RAILS_ENV=production with a copy of nothing anyone needs to hear about.
|
|
16
|
+
return if Studio.qa_environment?
|
|
17
|
+
|
|
18
|
+
recipients = Studio::HealthReport.recipients
|
|
19
|
+
return if recipients.empty?
|
|
20
|
+
|
|
21
|
+
to = recipients.join(", ")
|
|
22
|
+
window_end = Studio::HealthReport.window(now).end
|
|
23
|
+
Studio::Email.deliver(Studio::HealthReportMailer, :daily, window_end.iso8601, to, to: to)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
ErrorLog.capture!(e)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The daily application health report. Namespaced so a host's own mailers
|
|
5
|
+
# cannot shadow it (see Studio::NewsletterMailer).
|
|
6
|
+
#
|
|
7
|
+
# It takes the window's END as an ISO-8601 string, not a built report, so the
|
|
8
|
+
# outbox stores a plain string and a retry re-reads the SAME day.
|
|
9
|
+
class HealthReportMailer < ApplicationMailer
|
|
10
|
+
# The report is an operator memo, not a branded email: its own plain
|
|
11
|
+
# document, whatever mailer layout the host's ApplicationMailer sets.
|
|
12
|
+
layout false
|
|
13
|
+
|
|
14
|
+
def daily(window_end, to)
|
|
15
|
+
finish = Time.iso8601(window_end.to_s)
|
|
16
|
+
@report = Studio::HealthReport.build(window: Studio::HealthReport.window_ending(finish))
|
|
17
|
+
mail(to: to, subject: @report.subject)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= @report.subject %></title>
|
|
7
|
+
</head>
|
|
8
|
+
<body style="margin:0;padding:24px 12px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#18181b;">
|
|
9
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e4e4e7;border-radius:8px;">
|
|
10
|
+
<tr>
|
|
11
|
+
<td style="padding:20px 24px;border-bottom:1px solid #e4e4e7;">
|
|
12
|
+
<div style="font-size:18px;font-weight:700;"><%= @report.app_name %> health report</div>
|
|
13
|
+
<div style="font-size:13px;color:#52525b;margin-top:4px;"><%= @report.window_label %></div>
|
|
14
|
+
</td>
|
|
15
|
+
</tr>
|
|
16
|
+
<% if @report.quiet? %>
|
|
17
|
+
<tr>
|
|
18
|
+
<td style="padding:20px 24px;font-size:15px;">
|
|
19
|
+
<strong style="color:#166534;">All clear:</strong>
|
|
20
|
+
no <%= @report.sections.map { |s| s.title.downcase }.to_sentence(two_words_connector: " or ", last_word_connector: ", or ") %> in the last day.
|
|
21
|
+
</td>
|
|
22
|
+
</tr>
|
|
23
|
+
<% else %>
|
|
24
|
+
<% @report.sections.each do |section| %>
|
|
25
|
+
<tr>
|
|
26
|
+
<td style="padding:16px 24px;border-bottom:1px solid #f4f4f5;">
|
|
27
|
+
<div style="font-size:15px;font-weight:600;">
|
|
28
|
+
<%= section.title %>:
|
|
29
|
+
<% if section.failed? %>
|
|
30
|
+
<span style="color:#b91c1c;">could not be read (<%= section.failure %>)</span>
|
|
31
|
+
<% else %>
|
|
32
|
+
<span style="color:<%= section.title == "Errors" && section.count.positive? ? "#b91c1c" : "#18181b" %>;"><%= section.count %></span>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<% if section.items.any? %>
|
|
36
|
+
<ul style="margin:8px 0 0;padding-left:20px;font-size:14px;color:#3f3f46;overflow-wrap:anywhere;">
|
|
37
|
+
<% section.items.each do |item| %>
|
|
38
|
+
<li style="margin:2px 0;"><%= item %></li>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% if section.more.positive? %>
|
|
41
|
+
<li style="margin:2px 0;color:#71717a;">and <%= section.more %> more</li>
|
|
42
|
+
<% end %>
|
|
43
|
+
</ul>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% if section.title == "Errors" && @report.error_groups.any? %>
|
|
46
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:8px;font-size:13px;border-collapse:collapse;">
|
|
47
|
+
<% @report.error_groups.each do |group| %>
|
|
48
|
+
<tr>
|
|
49
|
+
<td style="padding:6px 8px 6px 0;vertical-align:top;font-weight:700;white-space:nowrap;color:#b91c1c;"><%= group.count %>×</td>
|
|
50
|
+
<td style="padding:6px 0;vertical-align:top;border-top:1px solid #f4f4f5;">
|
|
51
|
+
<div style="font-family:Menlo,Consolas,monospace;font-weight:600;word-break:break-all;overflow-wrap:anywhere;"><%= group.error_class %></div>
|
|
52
|
+
<div style="color:#3f3f46;word-break:break-word;"><%= group.message.squish.truncate(200) %></div>
|
|
53
|
+
<div style="color:#71717a;font-size:12px;">first <%= @report.stamp(group.first_seen) %> · last <%= @report.stamp(group.last_seen) %></div>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
<% end %>
|
|
57
|
+
</table>
|
|
58
|
+
<% if @report.error_kinds > @report.error_groups.size %>
|
|
59
|
+
<div style="font-size:13px;color:#71717a;margin-top:6px;">and <%= @report.error_kinds - @report.error_groups.size %> more kinds</div>
|
|
60
|
+
<% end %>
|
|
61
|
+
<% end %>
|
|
62
|
+
</td>
|
|
63
|
+
</tr>
|
|
64
|
+
<% end %>
|
|
65
|
+
<% end %>
|
|
66
|
+
<tr>
|
|
67
|
+
<td style="padding:12px 24px;font-size:12px;color:#71717a;">Your daily report from McRitchie Studio.</td>
|
|
68
|
+
</tr>
|
|
69
|
+
</table>
|
|
70
|
+
</body>
|
|
71
|
+
</html>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%= @report.app_name %> health report
|
|
2
|
+
<%= @report.window_label %>
|
|
3
|
+
|
|
4
|
+
<% if @report.quiet? -%>
|
|
5
|
+
All clear: no <%= @report.sections.map { |s| s.title.downcase }.to_sentence(two_words_connector: " or ", last_word_connector: ", or ") %> in the last day.
|
|
6
|
+
<% else -%>
|
|
7
|
+
<% @report.sections.each do |section| -%>
|
|
8
|
+
<%= section.title %>: <%= section.failed? ? "could not be read (#{section.failure})" : section.count %>
|
|
9
|
+
<% section.items.each do |item| -%>
|
|
10
|
+
- <%= item %>
|
|
11
|
+
<% end -%>
|
|
12
|
+
<% if section.more.positive? -%>
|
|
13
|
+
... and <%= section.more %> more
|
|
14
|
+
<% end -%>
|
|
15
|
+
<% if section.title == "Errors" -%>
|
|
16
|
+
<% @report.error_groups.each do |group| -%>
|
|
17
|
+
<%= group.count %>x <%= group.error_class %>: <%= group.message.squish.truncate(160) %>
|
|
18
|
+
first <%= @report.stamp(group.first_seen) %>, last <%= @report.stamp(group.last_seen) %>
|
|
19
|
+
<% end -%>
|
|
20
|
+
<% if @report.error_kinds > @report.error_groups.size -%>
|
|
21
|
+
... and <%= @report.error_kinds - @report.error_groups.size %> more kinds
|
|
22
|
+
<% end -%>
|
|
23
|
+
<% end -%>
|
|
24
|
+
|
|
25
|
+
<% end -%>
|
|
26
|
+
<% end -%>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/time"
|
|
5
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
6
|
+
require "active_support/core_ext/object/blank"
|
|
7
|
+
require "active_support/core_ext/object/try"
|
|
8
|
+
require "active_support/core_ext/string/filters"
|
|
9
|
+
|
|
10
|
+
# The daily application health report: signups, errors and (optionally) contact
|
|
11
|
+
# messages from the last day, emailed at 07:00 in the app's zone. The full
|
|
12
|
+
# adoption guide is docs/HEALTH_REPORT.md.
|
|
13
|
+
#
|
|
14
|
+
# OFF BY DEFAULT, TWICE. Nothing sends until a host both SCHEDULES
|
|
15
|
+
# Studio::HealthReportJob (config/recurring.yml or sidekiq-cron) and NAMES a
|
|
16
|
+
# recipient (config.health_report_to, else ENV["STUDIO_HEALTH_REPORT_TO"]). The
|
|
17
|
+
# engine registers no schedule of its own, and with no recipient the job returns
|
|
18
|
+
# before it reads a row.
|
|
19
|
+
#
|
|
20
|
+
# THE WINDOW IS 07:00 TO 07:00 ON THE APP'S WALL CLOCK, end excluded, copied from
|
|
21
|
+
# mcritchie-studio's ContactFlaggedDigestJob. `24.hours.ago` would drop an hour
|
|
22
|
+
# on the fall-back day (a 25-hour day) and report an hour twice on the
|
|
23
|
+
# spring-forward one. Anchoring on the hour also makes the report a function of
|
|
24
|
+
# the DAY, not the run: a run that lands late, or a retry, reports exactly the
|
|
25
|
+
# window an on-time run would have, and a row at 07:00:00 belongs to one report.
|
|
26
|
+
#
|
|
27
|
+
# The config lives HERE rather than in lib/studio.rb so the unit suite loads it
|
|
28
|
+
# with the rules it governs; `Studio.configure` sets it like any other setting.
|
|
29
|
+
module Studio
|
|
30
|
+
# Who gets the report. A String (comma-separated is fine), an Array, or a
|
|
31
|
+
# callable returning either. nil or blank falls through to
|
|
32
|
+
# ENV["STUDIO_HEALTH_REPORT_TO"]; nobody there either means no email.
|
|
33
|
+
mattr_accessor :health_report_to, default: nil
|
|
34
|
+
|
|
35
|
+
# The wall clock the window is cut on, and the hour that opens it. Schedule
|
|
36
|
+
# the job at this hour in this zone; running it later reports the same day.
|
|
37
|
+
mattr_accessor :health_report_zone, default: "America/Denver"
|
|
38
|
+
mattr_accessor :health_report_hour, default: 7
|
|
39
|
+
|
|
40
|
+
# Where signups come from: a callable taking the window (a Range of times)
|
|
41
|
+
# and returning a relation, an Array, or an Integer count. The default reads
|
|
42
|
+
# the host's `User` by created_at, which is every consumer today; an app whose
|
|
43
|
+
# accounts live elsewhere points this at that model. nil omits the section.
|
|
44
|
+
mattr_accessor :health_report_signups, default: :default
|
|
45
|
+
|
|
46
|
+
# Contact-form submissions, OPTIONAL: nil (the default) omits the section,
|
|
47
|
+
# because the engine has no contact model and each app names its own, e.g.
|
|
48
|
+
# config.health_report_contacts = ->(window) { ContactSubmission.where(created_at: window) }
|
|
49
|
+
mattr_accessor :health_report_contacts, default: nil
|
|
50
|
+
|
|
51
|
+
module HealthReport
|
|
52
|
+
ENV_KEY = "STUDIO_HEALTH_REPORT_TO"
|
|
53
|
+
LISTED = 10 # names shown under a count; the count is always whole
|
|
54
|
+
ERROR_GROUPS = 15 # distinct errors shown; the rest are counted as "more"
|
|
55
|
+
|
|
56
|
+
# One section of the report. `items` is at most LISTED labels; `count` is
|
|
57
|
+
# the true total. `failure` is set when the source raised: the report still
|
|
58
|
+
# sends, and says that this one part could not be read.
|
|
59
|
+
Section = Struct.new(:title, :count, :items, :failure, keyword_init: true) do
|
|
60
|
+
def failed? = !failure.nil?
|
|
61
|
+
# Rows counted but not listed. A count-only section lists none and owes no tail.
|
|
62
|
+
def more = items.empty? ? 0 : [count.to_i - items.size, 0].max
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# One distinct error: its class, its message, how often, first and last.
|
|
66
|
+
ErrorGroup = Struct.new(:error_class, :message, :count, :first_seen, :last_seen, keyword_init: true)
|
|
67
|
+
|
|
68
|
+
Report = Struct.new(:app_name, :window, :signups, :errors, :error_groups, :error_kinds, :contacts,
|
|
69
|
+
keyword_init: true) do
|
|
70
|
+
def sections = [signups, errors, contacts].compact
|
|
71
|
+
|
|
72
|
+
# Nothing happened and nothing failed: the email is the all-clear line.
|
|
73
|
+
def quiet? = sections.all? { |s| !s.failed? && s.count.to_i.zero? }
|
|
74
|
+
|
|
75
|
+
def subject
|
|
76
|
+
return "#{app_name} health: all clear" if quiet?
|
|
77
|
+
|
|
78
|
+
parts = sections.reject { |s| s.count.to_i.zero? && !s.failed? }.map do |s|
|
|
79
|
+
s.failed? ? "#{s.title.downcase} unavailable" : HealthReport.pluralize(s.count, s.title.downcase.chomp("s"))
|
|
80
|
+
end
|
|
81
|
+
"#{app_name} health: #{parts.join(', ')}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A time on the report's own clock: "Oct 13, 6:42 AM".
|
|
85
|
+
def stamp(time) = time ? time.in_time_zone(window.end.time_zone).strftime("%b %-d, %-l:%M %p") : "unknown"
|
|
86
|
+
|
|
87
|
+
# "Oct 12, 7:00 AM to Oct 13, 7:00 AM MDT"
|
|
88
|
+
def window_label
|
|
89
|
+
from, to = window.begin, window.end
|
|
90
|
+
"#{from.strftime('%b %-d, %-l:%M %p')} to #{to.strftime('%b %-d, %-l:%M %p %Z')}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
module_function
|
|
95
|
+
|
|
96
|
+
def window(now = Time.now, zone: Studio.health_report_zone, hour: Studio.health_report_hour)
|
|
97
|
+
finish = now.in_time_zone(zone).change(hour: hour)
|
|
98
|
+
finish -= 1.day if finish > now
|
|
99
|
+
(finish - 1.day)...finish
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The window that ENDS at `finish` (the job passes this through the outbox,
|
|
103
|
+
# so a retry re-reads the same day rather than the day it happens to run on).
|
|
104
|
+
def window_ending(finish, zone: Studio.health_report_zone)
|
|
105
|
+
finish = finish.in_time_zone(zone)
|
|
106
|
+
(finish - 1.day)...finish
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def recipients
|
|
110
|
+
configured = Studio.health_report_to
|
|
111
|
+
configured = configured.call if configured.respond_to?(:call)
|
|
112
|
+
list = split(configured)
|
|
113
|
+
list = split(ENV.fetch(ENV_KEY, nil)) if list.empty?
|
|
114
|
+
list
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def split(value)
|
|
118
|
+
Array(value).flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ErrorLog.capture! stores "#<Class: message>"; a row written by hand may
|
|
122
|
+
# carry only a message. Returns [class, message].
|
|
123
|
+
def error_name(text)
|
|
124
|
+
return ["Error", "(no message)"] if text.blank?
|
|
125
|
+
|
|
126
|
+
match = /\A#<([A-Z][\w:]*): (.*)>\z/m.match(text.to_s)
|
|
127
|
+
match ? [match[1], match[2]] : ["Error", text.to_s]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pluralize(count, noun)
|
|
131
|
+
"#{count} #{count == 1 ? noun : "#{noun}s"}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ---- gathering (needs the host's database) ------------------------------
|
|
135
|
+
|
|
136
|
+
def build(window: self.window(Time.now))
|
|
137
|
+
Report.new(
|
|
138
|
+
app_name: Studio.app_name,
|
|
139
|
+
window: window,
|
|
140
|
+
signups: section("Signups", Studio.health_report_signups, window),
|
|
141
|
+
contacts: section("Contact messages", Studio.health_report_contacts, window),
|
|
142
|
+
**errors(window)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def section(title, source, window)
|
|
147
|
+
source = default_signups_source if source == :default
|
|
148
|
+
return nil if source.nil?
|
|
149
|
+
|
|
150
|
+
collect(title, source.call(window))
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
capture(e)
|
|
153
|
+
Section.new(title: title, count: 0, items: [], failure: e.class.name)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The host's `User`, newest first, when it has a created_at; otherwise no
|
|
157
|
+
# section at all, rather than a "0 signups" line that is not true.
|
|
158
|
+
def default_signups_source
|
|
159
|
+
return nil unless defined?(::User) && ::User.respond_to?(:column_names) && ::User.column_names.include?("created_at")
|
|
160
|
+
|
|
161
|
+
->(window) { ::User.where(created_at: window).order(created_at: :desc) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def collect(title, result)
|
|
165
|
+
case result
|
|
166
|
+
when nil then Section.new(title: title, count: 0, items: [])
|
|
167
|
+
when Integer then Section.new(title: title, count: result, items: [])
|
|
168
|
+
else
|
|
169
|
+
count = result.respond_to?(:count) ? result.count : result.size
|
|
170
|
+
rows = result.respond_to?(:limit) ? result.limit(LISTED).to_a : Array(result).first(LISTED)
|
|
171
|
+
Section.new(title: title, count: count, items: rows.map { |r| label(r) })
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# A short, human name for a row: its own health_report_label if it has
|
|
176
|
+
# one, else a name, then an email, then "Model #id".
|
|
177
|
+
def label(record)
|
|
178
|
+
return record.to_s if record.is_a?(String)
|
|
179
|
+
return record.health_report_label.to_s if record.respond_to?(:health_report_label)
|
|
180
|
+
|
|
181
|
+
name = %i[display_name name].lazy.filter_map { |m| record.public_send(m).presence if record.respond_to?(m) }.first
|
|
182
|
+
email = record.public_send(:email).presence if record.respond_to?(:email)
|
|
183
|
+
return "#{name} <#{email}>" if name && email && name != email
|
|
184
|
+
return (name || email).to_s if name || email
|
|
185
|
+
|
|
186
|
+
"#{record.class.name} ##{record.try(:id)}"
|
|
187
|
+
rescue StandardError
|
|
188
|
+
"#{record.class.name} ##{record.try(:id)}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Grouped in SQL: a bad day can hold thousands of rows. The key is the
|
|
192
|
+
# stored "#<Class: message>", which is the class AND the message.
|
|
193
|
+
def errors(window)
|
|
194
|
+
empty = { errors: Section.new(title: "Errors", count: 0, items: []), error_groups: [], error_kinds: 0 }
|
|
195
|
+
return empty unless defined?(::ErrorLog) && ::ErrorLog.table_exists?
|
|
196
|
+
|
|
197
|
+
key = "COALESCE(#{::ErrorLog.connection.quote_column_name('inspect')}, message)"
|
|
198
|
+
scope = ::ErrorLog.where(created_at: window)
|
|
199
|
+
total = scope.count
|
|
200
|
+
return empty if total.zero?
|
|
201
|
+
|
|
202
|
+
kinds = scope.distinct.count(Arel.sql(key))
|
|
203
|
+
created = ::ErrorLog.type_for_attribute("created_at")
|
|
204
|
+
groups = scope.group(Arel.sql(key)).order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(ERROR_GROUPS)
|
|
205
|
+
.pluck(Arel.sql(key), Arel.sql("COUNT(*)"), Arel.sql("MIN(created_at)"), Arel.sql("MAX(created_at)"))
|
|
206
|
+
.map do |text, count, first, last|
|
|
207
|
+
klass, message = error_name(text)
|
|
208
|
+
ErrorGroup.new(error_class: klass, message: message.to_s.truncate(300), count: count.to_i,
|
|
209
|
+
first_seen: created.deserialize(first), last_seen: created.deserialize(last))
|
|
210
|
+
end
|
|
211
|
+
{ errors: Section.new(title: "Errors", count: total, items: []), error_groups: groups, error_kinds: kinds }
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
capture(e)
|
|
214
|
+
{ errors: Section.new(title: "Errors", count: 0, items: [], failure: e.class.name), error_groups: [], error_kinds: 0 }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def capture(error)
|
|
218
|
+
::ErrorLog.capture!(error) if defined?(::ErrorLog)
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -38,6 +38,7 @@ require "studio/profile_sections"
|
|
|
38
38
|
require "studio/profile_image"
|
|
39
39
|
require "studio/oauth_identity"
|
|
40
40
|
require "studio/newsletter"
|
|
41
|
+
require "studio/health_report"
|
|
41
42
|
require "studio/username_generator"
|
|
42
43
|
require "studio/public_user"
|
|
43
44
|
require "studio/name_parts"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.105.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -474,7 +474,9 @@ files:
|
|
|
474
474
|
- app/javascript/studio/vendor/stimulus.js
|
|
475
475
|
- app/jobs/error_log_cleanup_job.rb
|
|
476
476
|
- app/jobs/studio/email_delivery_job.rb
|
|
477
|
+
- app/jobs/studio/health_report_job.rb
|
|
477
478
|
- app/mailers/application_mailer.rb
|
|
479
|
+
- app/mailers/studio/health_report_mailer.rb
|
|
478
480
|
- app/mailers/studio/newsletter_mailer.rb
|
|
479
481
|
- app/mailers/studio/profile_mailer.rb
|
|
480
482
|
- app/mailers/user_mailer.rb
|
|
@@ -574,6 +576,8 @@ files:
|
|
|
574
576
|
- app/views/studio/emails/show.html.erb
|
|
575
577
|
- app/views/studio/fields/_date_of_birth.html.erb
|
|
576
578
|
- app/views/studio/geo_settings/edit.html.erb
|
|
579
|
+
- app/views/studio/health_report_mailer/daily.html.erb
|
|
580
|
+
- app/views/studio/health_report_mailer/daily.text.erb
|
|
577
581
|
- app/views/studio/knowledge_docs/_access_chips.html.erb
|
|
578
582
|
- app/views/studio/knowledge_docs/_browser.html.erb
|
|
579
583
|
- app/views/studio/knowledge_docs/_chip_styles.html.erb
|
|
@@ -733,6 +737,7 @@ files:
|
|
|
733
737
|
- lib/studio/geo.rb
|
|
734
738
|
- lib/studio/geo/countries.rb
|
|
735
739
|
- lib/studio/geo/lookup.rb
|
|
740
|
+
- lib/studio/health_report.rb
|
|
736
741
|
- lib/studio/hold_button.rb
|
|
737
742
|
- lib/studio/host_schema.rb
|
|
738
743
|
- lib/studio/image_cache.rb
|