studio-engine 0.108.0.rc1 → 0.108.0.rc2
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 +18 -0
- data/README.md +2 -0
- data/app/controllers/studio/health_reports_controller.rb +47 -0
- data/lib/studio/health_report.rb +75 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +8 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2990527dd511a02eea248d9a8ceb2e5f2df97b2b19423f3022b864ea162e38b0
|
|
4
|
+
data.tar.gz: 24d9da854c8773924858c46fe3bcf31e81ecf4b2e5a1c40b629e16067ad661ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 847d51a58aafc2e75e1d7621cdb674b4dda4c6d54b30676a95803fe5980ad5630392ead197848afe25255b8176cc7754b34c6971a1a27c5fc0f9a162c898c6f6
|
|
7
|
+
data.tar.gz: fc2bfa04fe01978a9b60ad854553cf89c6d1cb250ebe9ff0c5b75c9b09b49b02ba3a0203e069b32e774763d6505400f4a8a5c5c5574479a39f353e3606451557
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`GET /_studio/health_report`: the daily health report as JSON, for the
|
|
10
|
+
hub's central pull.** McRitchie Studio runs one 07:00 America/Denver job that
|
|
11
|
+
pulls every app's report and posts it to Discord, so a satellite needs no
|
|
12
|
+
worker or schedule of its own. Drawn by `Studio.routes` in every environment,
|
|
13
|
+
production included, and inert until the app names a token:
|
|
14
|
+
`config.health_report_token` or `STUDIO_HEALTH_TOKEN` (new). With none it
|
|
15
|
+
answers a bare 404; a request without `Authorization: Bearer <token>`
|
|
16
|
+
(compared in constant time) gets 401; `?ending=<ISO 8601>` picks the day
|
|
17
|
+
that ends there (a bad value is 400), else the day the job would report now.
|
|
18
|
+
The body is the same `Studio::HealthReport.build` the email uses, counts only
|
|
19
|
+
(`schema`, `app`, `engine_version`, `window`, `quiet`, `sections`,
|
|
20
|
+
`error_kinds`, `error_groups`): no section's names or emails ever leave the
|
|
21
|
+
app, and an email address quoted in an error message is sent as `[email]`. It runs on `ActionController::API`, so no host `before_action`,
|
|
22
|
+
session, cookie or redirect touches it. See `docs/HEALTH_REPORT.md`, *The
|
|
23
|
+
hub endpoint*.
|
|
24
|
+
|
|
7
25
|
## 0.108.0 — 2026-10-15
|
|
8
26
|
|
|
9
27
|
### Changed
|
data/README.md
CHANGED
|
@@ -148,6 +148,8 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
|
|
|
148
148
|
|
|
149
149
|
In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
|
|
150
150
|
|
|
151
|
+
In every environment, production included, it also draws `/_studio/health_report`: the daily health report as counts-only JSON, which the McRitchie Studio hub pulls at 07:00 and posts to Discord. It answers 404 until the app sets `STUDIO_HEALTH_TOKEN` (or `config.health_report_token`), and 401 to a request without that bearer token ([`docs/HEALTH_REPORT.md`](docs/HEALTH_REPORT.md#the-hub-endpoint)).
|
|
152
|
+
|
|
151
153
|
## Non-Production Banners
|
|
152
154
|
|
|
153
155
|
Consumer layouts can render the shared environment banner inside their sticky
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# GET /_studio/health_report — the daily health report as JSON, for the
|
|
5
|
+
# McRitchie Studio hub's one 07:00 America/Denver job, which pulls every app
|
|
6
|
+
# and posts to Discord. Drawn in every environment, production included; the
|
|
7
|
+
# report is the same Studio::HealthReport.build the email uses, so the two can
|
|
8
|
+
# never disagree. docs/HEALTH_REPORT.md, "The hub endpoint".
|
|
9
|
+
#
|
|
10
|
+
# INERT BY DEFAULT. With no Studio.health_report_token (else
|
|
11
|
+
# ENV["STUDIO_HEALTH_TOKEN"]) it answers a bare 404, the same as a route that
|
|
12
|
+
# is not there. With one, a request without `Authorization: Bearer <token>`
|
|
13
|
+
# gets 401; the compare is constant-time (Studio::HealthReport.authorized?).
|
|
14
|
+
#
|
|
15
|
+
# COUNTS ONLY. Studio::HealthReport.payload drops every section's `items`, so
|
|
16
|
+
# no signup's or contact's name or email ever leaves the app.
|
|
17
|
+
#
|
|
18
|
+
# ActionController::API, NOT the host's ApplicationController: a server-to-
|
|
19
|
+
# server fetch carries no session, and a host's before_actions (sign-in, geo,
|
|
20
|
+
# allow_browser, onboarding) would answer it with a redirect or an HTML page.
|
|
21
|
+
# This base runs none of them, keeps no session, sets no cookie and checks no
|
|
22
|
+
# CSRF token, and every answer here is JSON or a bare status.
|
|
23
|
+
class HealthReportsController < ActionController::API
|
|
24
|
+
def show
|
|
25
|
+
return head(:not_found) if Studio.health_report_token.blank?
|
|
26
|
+
|
|
27
|
+
response.headers["Cache-Control"] = "no-store"
|
|
28
|
+
unless Studio::HealthReport.authorized?(request.authorization)
|
|
29
|
+
response.headers["WWW-Authenticate"] = 'Bearer realm="studio-health-report"'
|
|
30
|
+
return render(json: { error: "unauthorized" }, status: :unauthorized)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
window = begin
|
|
34
|
+
Studio::HealthReport.window_for(params[:ending].presence)
|
|
35
|
+
rescue ArgumentError
|
|
36
|
+
return render(json: { error: "ending must be an ISO 8601 time" }, status: :bad_request)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
render json: Studio::HealthReport.payload(Studio::HealthReport.build(window: window))
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
# build already turns a failing SOURCE into a section's `failure`; this is
|
|
42
|
+
# anything else. Logged as an ErrorLog row, answered as JSON, never HTML.
|
|
43
|
+
Studio::HealthReport.capture(e)
|
|
44
|
+
render json: { error: "health report failed" }, status: :internal_server_error
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/studio/health_report.rb
CHANGED
|
@@ -6,6 +6,10 @@ require "active_support/core_ext/module/attribute_accessors"
|
|
|
6
6
|
require "active_support/core_ext/object/blank"
|
|
7
7
|
require "active_support/core_ext/object/try"
|
|
8
8
|
require "active_support/core_ext/string/filters"
|
|
9
|
+
require "active_support/security_utils"
|
|
10
|
+
require "digest"
|
|
11
|
+
require "time"
|
|
12
|
+
require_relative "version"
|
|
9
13
|
|
|
10
14
|
# The daily application health report: signups, errors and (optionally) contact
|
|
11
15
|
# messages from the last day, emailed at 07:00 in the app's zone. The full
|
|
@@ -48,8 +52,20 @@ module Studio
|
|
|
48
52
|
# config.health_report_contacts = ->(window) { ContactSubmission.where(created_at: window) }
|
|
49
53
|
mattr_accessor :health_report_contacts, default: nil
|
|
50
54
|
|
|
55
|
+
# The bearer token the hub presents to GET /_studio/health_report, the JSON
|
|
56
|
+
# copy of this report that McRitchie Studio pulls from every app at 07:00 and
|
|
57
|
+
# posts to Discord. nil or blank falls through to ENV["STUDIO_HEALTH_TOKEN"],
|
|
58
|
+
# read on every request; blank there too means the endpoint answers 404.
|
|
59
|
+
mattr_writer :health_report_token, default: nil
|
|
60
|
+
|
|
61
|
+
def self.health_report_token
|
|
62
|
+
class_variable_get(:@@health_report_token).presence || ENV.fetch(HealthReport::TOKEN_ENV_KEY, nil).presence
|
|
63
|
+
end
|
|
64
|
+
|
|
51
65
|
module HealthReport
|
|
52
66
|
ENV_KEY = "STUDIO_HEALTH_REPORT_TO"
|
|
67
|
+
TOKEN_ENV_KEY = "STUDIO_HEALTH_TOKEN"
|
|
68
|
+
SCHEMA = 1 # the JSON contract's version; bump on a breaking change
|
|
53
69
|
LISTED = 10 # names shown under a count; the count is always whole
|
|
54
70
|
ERROR_GROUPS = 15 # distinct errors shown; the rest are counted as "more"
|
|
55
71
|
|
|
@@ -131,6 +147,65 @@ module Studio
|
|
|
131
147
|
"#{count} #{count == 1 ? noun : "#{noun}s"}"
|
|
132
148
|
end
|
|
133
149
|
|
|
150
|
+
# ---- the JSON endpoint (Studio::HealthReportsController) ---------------
|
|
151
|
+
|
|
152
|
+
# Does this Authorization header carry the configured token? false when no
|
|
153
|
+
# token is configured. Both sides are digested before the constant-time
|
|
154
|
+
# compare, so neither the token's bytes nor its length leak through timing.
|
|
155
|
+
def authorized?(authorization, token: Studio.health_report_token)
|
|
156
|
+
return false if token.blank?
|
|
157
|
+
|
|
158
|
+
presented = authorization.to_s[/\ABearer[ ]+(\S+)\s*\z/i, 1]
|
|
159
|
+
return false if presented.nil?
|
|
160
|
+
|
|
161
|
+
ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(presented),
|
|
162
|
+
Digest::SHA256.hexdigest(token.to_s))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The window the endpoint reports: the day ENDING at `ending` (an ISO 8601
|
|
166
|
+
# time), else the day the job would report now. Raises ArgumentError on a
|
|
167
|
+
# value that is not ISO 8601, which the controller answers with a 400.
|
|
168
|
+
def window_for(ending, now: Time.now)
|
|
169
|
+
return window(now) if ending.nil?
|
|
170
|
+
|
|
171
|
+
window_ending(Time.iso8601(ending.to_s))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
SECTION_KEYS = %i[signups errors contacts].freeze
|
|
175
|
+
|
|
176
|
+
# The report as the hub reads it. COUNTS ONLY, by construction: a Section's
|
|
177
|
+
# `items` (names and emails) never reach this hash, so no signup or contact
|
|
178
|
+
# detail leaves the app. Error groups carry the class and the truncated
|
|
179
|
+
# message the email already shows.
|
|
180
|
+
def payload(report)
|
|
181
|
+
{
|
|
182
|
+
schema: SCHEMA,
|
|
183
|
+
app: report.app_name.to_s,
|
|
184
|
+
engine_version: Studio::VERSION,
|
|
185
|
+
window: { begin: iso(report.window.begin), end: iso(report.window.end), label: report.window_label },
|
|
186
|
+
quiet: report.quiet?,
|
|
187
|
+
sections: SECTION_KEYS.filter_map do |key|
|
|
188
|
+
section = report.public_send(key)
|
|
189
|
+
next unless section
|
|
190
|
+
|
|
191
|
+
{ key: key.to_s, title: section.title, count: section.count.to_i, failure: section.failure }
|
|
192
|
+
end,
|
|
193
|
+
error_kinds: report.error_kinds.to_i,
|
|
194
|
+
error_groups: Array(report.error_groups).map do |group|
|
|
195
|
+
{ error_class: group.error_class, message: scrub(group.message), count: group.count.to_i,
|
|
196
|
+
first_seen: iso(group.first_seen), last_seen: iso(group.last_seen) }
|
|
197
|
+
end
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def iso(time) = time&.iso8601
|
|
202
|
+
|
|
203
|
+
EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/
|
|
204
|
+
|
|
205
|
+
# An exception message can quote the address it choked on ("no account for
|
|
206
|
+
# ann@example.com"). The email may show it; the JSON leaving the app may not.
|
|
207
|
+
def scrub(message) = message.to_s.gsub(EMAIL, "[email]")
|
|
208
|
+
|
|
134
209
|
# ---- gathering (needs the host's database) ------------------------------
|
|
135
210
|
|
|
136
211
|
def build(window: self.window(Time.now))
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -1447,6 +1447,14 @@ module Studio
|
|
|
1447
1447
|
get "auth/:provider/callback", to: "omniauth_callbacks#create"
|
|
1448
1448
|
get "auth/failure", to: "omniauth_callbacks#failure"
|
|
1449
1449
|
|
|
1450
|
+
# The daily health report as JSON, for the hub's central 07:00 pull
|
|
1451
|
+
# (Studio::HealthReportsController). Drawn in EVERY environment, production
|
|
1452
|
+
# included: the controller is the gate, answering 404 until the app names
|
|
1453
|
+
# a token (Studio.health_report_token / STUDIO_HEALTH_TOKEN) and 401 to a
|
|
1454
|
+
# request that does not present it.
|
|
1455
|
+
get "_studio/health_report", to: "studio/health_reports#show", as: :studio_health_report,
|
|
1456
|
+
defaults: { format: :json }
|
|
1457
|
+
|
|
1450
1458
|
# Developer-desk tools. Drawn outside production, and each controller
|
|
1451
1459
|
# re-checks Studio.local_tool_enabled? per request (loopback only) — the
|
|
1452
1460
|
# route being absent is the outer gate, not the only one.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.108.0.
|
|
4
|
+
version: 0.108.0.rc2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -398,6 +398,7 @@ files:
|
|
|
398
398
|
- app/controllers/studio/component_previews_controller.rb
|
|
399
399
|
- app/controllers/studio/emails_controller.rb
|
|
400
400
|
- app/controllers/studio/geo_settings_controller.rb
|
|
401
|
+
- app/controllers/studio/health_reports_controller.rb
|
|
401
402
|
- app/controllers/studio/knowledge_docs_controller.rb
|
|
402
403
|
- app/controllers/studio/links_controller.rb
|
|
403
404
|
- app/controllers/studio/local_emails_controller.rb
|