railwatch 0.3.7 → 0.5.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 +58 -0
- data/app/controllers/railwatch/commands_controller.rb +1 -1
- data/app/controllers/railwatch/environment_scoped.rb +18 -36
- data/app/controllers/railwatch/exceptions_controller.rb +8 -9
- data/app/controllers/railwatch/processes_controller.rb +1 -1
- data/app/controllers/railwatch/releases_controller.rb +2 -2
- data/app/controllers/railwatch/tenants_controller.rb +2 -2
- data/app/controllers/railwatch/thresholds_controller.rb +8 -1
- data/{lib/generators/railwatch/install/templates → app/frontend/lib}/railwatch.ts +162 -42
- data/app/jobs/railwatch/detect_performance_issues_job.rb +12 -2
- data/app/models/railwatch/anomaly_rule.rb +20 -2
- data/app/models/railwatch/ingest/batch.rb +1 -0
- data/app/models/railwatch/ingest/mapper.rb +12 -0
- data/app/models/railwatch/telemetry/aggregations.rb +154 -19
- data/app/models/railwatch/telemetry/exception.rb +7 -0
- data/app/models/railwatch/telemetry/execution.rb +18 -0
- data/app/models/railwatch/telemetry/log.rb +6 -0
- data/app/models/railwatch/telemetry/query.rb +7 -0
- data/app/models/railwatch/telemetry/release_health.rb +12 -6
- data/app/models/railwatch/telemetry/rollup.rb +17 -2
- data/app/models/railwatch/telemetry/tenant.rb +8 -23
- data/app/models/railwatch/telemetry_record.rb +8 -6
- data/app/models/railwatch/threshold.rb +41 -4
- data/app/models/railwatch/window.rb +66 -0
- data/docs/embedded.md +11 -0
- data/lib/generators/railwatch/install/install_generator.rb +5 -1
- data/lib/railwatch/transport/http.rb +15 -5
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +856 -0
- data/lib/railwatch.rb +22 -0
- data/lib/tasks/railwatch_tasks.rake +7 -0
- data/public/railwatch/assets/app-layout-DDyQa72H.js +1 -0
- data/public/railwatch/assets/{app-wordmark-1SRllgHv.js → app-wordmark-o9CODKP0.js} +1 -1
- data/public/railwatch/assets/{appearance-DbwahDrc.js → appearance-BwuCXabr.js} +1 -1
- data/public/railwatch/assets/application-B7h1MIhi.css +1 -0
- data/public/railwatch/assets/{arrow-up-DwKQcab4.js → arrow-up-C6PxDiY3.js} +1 -1
- data/public/railwatch/assets/{auth-layout-DXfxXAN_.js → auth-layout-BRt8MGFD.js} +1 -1
- data/public/railwatch/assets/{badge-CApjyWcg.js → badge-CAxXV8za.js} +1 -1
- data/public/railwatch/assets/{braces-kGSgxWnS.js → braces-DgomTCNf.js} +1 -1
- data/public/railwatch/assets/{card-BaJ8kNkp.js → card-cAtqCxWl.js} +1 -1
- data/public/railwatch/assets/{chart-Xnrz1Nqw.js → chart-BBeBkkNa.js} +1 -1
- data/public/railwatch/assets/{chart-hover-XYPNE2CX.js → chart-hover-B1M9jc0y.js} +1 -1
- data/public/railwatch/assets/chart-panel-DUQTz_C8.js +1 -0
- data/public/railwatch/assets/{checkbox-Bf41R8W6.js → checkbox-CmhMHWZO.js} +1 -1
- data/public/railwatch/assets/{code-a-9sh36A.js → code-DESvxyTj.js} +1 -1
- data/public/railwatch/assets/{copy-block-zykZWQym.js → copy-block-BkSU5832.js} +1 -1
- data/public/railwatch/assets/{copy-id-b6CW5ESL.js → copy-id-D03GhN9F.js} +1 -1
- data/public/railwatch/assets/{cursor-load-more-0cHzbTPT.js → cursor-load-more-CRyuMeQb.js} +1 -1
- data/public/railwatch/assets/{data-table-IvA7xvrK.js → data-table-BIlt7Rtm.js} +1 -1
- data/public/railwatch/assets/{edit-DTtRV1fO.js → edit-Bb6MKoe4.js} +1 -1
- data/public/railwatch/assets/{edit-7lmf3k80.js → edit-DJ0D0wHN.js} +1 -1
- data/public/railwatch/assets/{edit-ZKE15r-F.js → edit-O0NSBWxo.js} +1 -1
- data/public/railwatch/assets/{empty-state-BFTnQ2oT.js → empty-state-C38il627.js} +1 -1
- data/public/railwatch/assets/env-layout-REF7OM4q.js +1 -0
- data/public/railwatch/assets/{execution-path-Di5uUHi-.js → execution-path-FYLq1TwC.js} +1 -1
- data/public/railwatch/assets/{filter-bar-Btm__gQi.js → filter-bar-CYog9Alp.js} +1 -1
- data/public/railwatch/assets/{flamegraph-C-mZdqa7.js → flamegraph-DSs69foN.js} +1 -1
- data/public/railwatch/assets/{frames-KZBsjg6-.js → frames-BUi2J5Mk.js} +1 -1
- data/public/railwatch/assets/{google-sign-in-button-DJRoD72g.js → google-sign-in-button-BQiIKFdd.js} +1 -1
- data/public/railwatch/assets/{index-9LL_C3eG.js → index-8-hnAhOD.js} +1 -1
- data/public/railwatch/assets/{index-Bjw4sTXU.js → index-BaR1U9An.js} +1 -1
- data/public/railwatch/assets/{index-BtSlLmkg.js → index-BeOh2t_S.js} +1 -1
- data/public/railwatch/assets/{index-DXUwhSCQ.js → index-BiiyMcA0.js} +1 -1
- data/public/railwatch/assets/{index-P_e_cI0O.js → index-BoUBioBP.js} +1 -1
- data/public/railwatch/assets/{index-kNhZPLVj.js → index-C-PmdhXA.js} +1 -1
- data/public/railwatch/assets/{index-DCAho97K.js → index-C3A_9imx.js} +1 -1
- data/public/railwatch/assets/{index-BsrCnQqg.js → index-C7OtLq_3.js} +1 -1
- data/public/railwatch/assets/{index-Bot7kHMA.js → index-CFFpnzIS.js} +1 -1
- data/public/railwatch/assets/{index-Z1c4nFL0.js → index-CFRLPs4J.js} +1 -1
- data/public/railwatch/assets/{index-BAkZNNEh.js → index-CGs4m_fa.js} +1 -1
- data/public/railwatch/assets/{index-CIUl9POV.js → index-CICUIFHL.js} +1 -1
- data/public/railwatch/assets/{index-wOj5j0KC.js → index-C_upSl_k.js} +1 -1
- data/public/railwatch/assets/{index-Cj3_jD1E.js → index-CiPo4Gob.js} +1 -1
- data/public/railwatch/assets/{index-EGU__DOB.js → index-CpkI015n.js} +1 -1
- data/public/railwatch/assets/{index-DEfu_YvU.js → index-CrZ3vHDL.js} +1 -1
- data/public/railwatch/assets/{index-CAzQkw-h.js → index-CsoN51vW.js} +1 -1
- data/public/railwatch/assets/{index-CeqMDByC.js → index-DDI_Zx5V.js} +1 -1
- data/public/railwatch/assets/index-DSvlZVWG.js +1 -0
- data/public/railwatch/assets/{index-CU6yhEyt.js → index-DW2CBbxU.js} +1 -1
- data/public/railwatch/assets/{index-uHBVO8Lh.js → index-Dh4IRLFI.js} +1 -1
- data/public/railwatch/assets/{index-HCJmFcZu.js → index-DqTFTP8p.js} +1 -1
- data/public/railwatch/assets/index-DrcKVG2f.js +1 -0
- data/public/railwatch/assets/{index-QyHE9WsU.js → index-DtHmuB9Q.js} +1 -1
- data/public/railwatch/assets/{index-CBJZQgHA.js → index-DvjY3dPD.js} +1 -1
- data/public/railwatch/assets/{index-Bo8hrjbs.js → index-FhUaPPab.js} +1 -1
- data/public/railwatch/assets/{index-BWSyQRZI.js → index-JdCVBrw8.js} +1 -1
- data/public/railwatch/assets/{index-DAEzO_vq.js → index-QpTtwFwu.js} +1 -1
- data/public/railwatch/assets/{index-D4968-TC.js → index-ZOGOB8SA.js} +1 -1
- data/public/railwatch/assets/{index-CP6jrrPT.js → index-ZSZg9rtq.js} +1 -1
- data/public/railwatch/assets/{index-DcItJxa5.js → index-r0tSIplE.js} +1 -1
- data/public/railwatch/assets/{index-CYYmZpZu.js → index-sTYvcbkh.js} +1 -1
- data/public/railwatch/assets/{index-B0VXzXYG.js → index-so4lRrRq.js} +1 -1
- data/public/railwatch/assets/{index-B-bSm1jB.js → index-tpz-OGUP.js} +1 -1
- data/public/railwatch/assets/{index-CX8Br7m9.js → index-umIAl-pL.js} +1 -1
- data/public/railwatch/assets/{inertia-Degv_G9N.js → inertia-DLew8ZNx.js} +3 -3
- data/public/railwatch/assets/{input-error-C6SqO_iU.js → input-error-cvM6_Jht.js} +1 -1
- data/public/railwatch/assets/{json-viewer-DcQIxT86.js → json-viewer-D922McGi.js} +1 -1
- data/public/railwatch/assets/{klass-WgQEfWpo.js → klass-CrwICqN8.js} +1 -1
- data/public/railwatch/assets/{label-B_vK19tR.js → label-GWl7I6sf.js} +1 -1
- data/public/railwatch/assets/{layout-C8q7GUlZ.js → layout-0ZAnD3zl.js} +1 -1
- data/public/railwatch/assets/{live-dot-DaXEbkXo.js → live-dot-D1n_BreY.js} +1 -1
- data/public/railwatch/assets/{nav-BnD5XArZ.js → nav-DPxr1NNC.js} +1 -1
- data/public/railwatch/assets/{new-BL9OqXHe.js → new-Cdl6pqST.js} +1 -1
- data/public/railwatch/assets/{new-uxMnBtoY.js → new-D-ZzUK9a.js} +1 -1
- data/public/railwatch/assets/{new-BqP0NhOF.js → new-DEVkYv-z.js} +1 -1
- data/public/railwatch/assets/{new-CWP3wtwt.js → new-DHAHDrN7.js} +1 -1
- data/public/railwatch/assets/{new-DIjwu2jF.js → new-Dz4lZf1L.js} +1 -1
- data/public/railwatch/assets/{new-BhRnQn4w.js → new-GMrRFurX.js} +1 -1
- data/public/railwatch/assets/{onboarding-C_Y3xTsH.js → onboarding-D1vwaHYT.js} +1 -1
- data/public/railwatch/assets/{origin-identity-CNhOnfBa.js → origin-identity-Bk9yHWZ1.js} +1 -1
- data/public/railwatch/assets/{percentile-picker-BujVEhP6.js → percentile-picker-DfSx9yJO.js} +1 -1
- data/public/railwatch/assets/{relative-time-6CoKr1lN.js → relative-time-CjIjb8Lg.js} +1 -1
- data/public/railwatch/assets/{release-health-1Qzc_XhT.js → release-health-4b3tivEf.js} +1 -1
- data/public/railwatch/assets/{route-xqgYjOwJ.js → route-C_5BUtHK.js} +1 -1
- data/public/railwatch/assets/{segmented-CVKq2Xpa.js → segmented-BgbT3wZa.js} +1 -1
- data/public/railwatch/assets/{select-CsmSXsJ4.js → select-DmunxCKE.js} +1 -1
- data/public/railwatch/assets/{separator-CrM10Oot.js → separator-BXzEdZ_8.js} +1 -1
- data/public/railwatch/assets/series-chart-Xf49v9cv.js +1 -0
- data/public/railwatch/assets/{show-zxVxhUel.js → show-B2zLAW83.js} +1 -1
- data/public/railwatch/assets/{show-BM-E2iYc.js → show-BLpWUHWD.js} +1 -1
- data/public/railwatch/assets/{show-CvNV23gH.js → show-CAl7xcex.js} +1 -1
- data/public/railwatch/assets/{show-BlNR3ZOZ.js → show-DI8IhNUH.js} +1 -1
- data/public/railwatch/assets/{show-B0nHy31L.js → show-DSP9Cq_C.js} +2 -2
- data/public/railwatch/assets/{show-rysBbvt-.js → show-DXs4deaC.js} +1 -1
- data/public/railwatch/assets/{show-DUwQlohF.js → show-DYskfl3-.js} +1 -1
- data/public/railwatch/assets/{show-ixK2r2f1.js → show-DcpTFiLi.js} +1 -1
- data/public/railwatch/assets/{show-CQ4Ze-Un.js → show-Dily73Xk.js} +1 -1
- data/public/railwatch/assets/{show-B3izvRdU.js → show-DlRVS18-.js} +1 -1
- data/public/railwatch/assets/{show-8H9WAGav.js → show-Dn-GwFZL.js} +1 -1
- data/public/railwatch/assets/{show-DZwcZZrz.js → show-DnR1Dnjd.js} +1 -1
- data/public/railwatch/assets/{show-CMisHNAI.js → show-SHwZjXb7.js} +1 -1
- data/public/railwatch/assets/{show-B7NhcWgp.js → show-SvLOcPrx.js} +1 -1
- data/public/railwatch/assets/{show-CJMeX75E.js → show-Y74rM0VT.js} +1 -1
- data/public/railwatch/assets/{show-DMZlwzL_.js → show-mU38uGTg.js} +1 -1
- data/public/railwatch/assets/{show-9jDeBNpc.js → show-vQ4bndYD.js} +1 -1
- data/public/railwatch/assets/{sort-header-CKj_7G3U.js → sort-header-Dcq9bzmo.js} +1 -1
- data/public/railwatch/assets/{sparkline-cell-Ckg29yVq.js → sparkline-cell-BON3qQUB.js} +1 -1
- data/public/railwatch/assets/{stat-TxvoBiC8.js → stat-DFEyFxkO.js} +1 -1
- data/public/railwatch/assets/{status-badge-5NCVJHjo.js → status-badge-BaUKP7Yo.js} +1 -1
- data/public/railwatch/assets/{tenant-path-B5ZRK4x3.js → tenant-path-DPZPc985.js} +1 -1
- data/public/railwatch/assets/{text-link-BPllRj0F.js → text-link-BO77t9Xk.js} +1 -1
- data/public/railwatch/assets/{textarea-DtD5yZp_.js → textarea-DTqrCiV0.js} +1 -1
- data/public/railwatch/assets/{timeline-DZz5ZnHN.js → timeline-D5rJ0es2.js} +1 -1
- data/public/railwatch/assets/{transition-Ds6Cakuc.js → transition-DMIrZVth.js} +1 -1
- data/public/railwatch/assets/{use-clipboard-BLjjYH8V.js → use-clipboard-ByoUGQqA.js} +1 -1
- data/public/railwatch/assets/{use-live-GKze1mNC.js → use-live-D7xKz2ma.js} +1 -1
- data/public/railwatch/manifest.json +1284 -1284
- metadata +119 -117
- data/public/railwatch/assets/app-layout-B5Fcdwfz.js +0 -1
- data/public/railwatch/assets/application-CwshqwM6.css +0 -1
- data/public/railwatch/assets/chart-panel-BYk68mJJ.js +0 -1
- data/public/railwatch/assets/env-layout-erSDpIXg.js +0 -1
- data/public/railwatch/assets/index-BoTOVM0v.js +0 -1
- data/public/railwatch/assets/index-DBTxQw77.js +0 -1
- data/public/railwatch/assets/series-chart-D33UhdG2.js +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 538efe4f9ba268dd88e694a7052542638a86d64f34978bffd45e5edb9e71dd18
|
|
4
|
+
data.tar.gz: 3f5d5b22228381e4f9d6657544c44fb3e337c754c9b7a9d683dada967d1592af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 923dc66f9d7e94815affc41d5d0df65db1891a6e46b9462ab6dd2931989f60786149fe70142de5f451a1b1d3ff13a8c551fb8b2487b8bd93aade6e95d0ce3600
|
|
7
|
+
data.tar.gz: 5f1fc6b7a9e2afade92562883b6467b1ace633c1dcd85ef2d6cde7c6b72f53328922f6e4c7e8c47eb596af2662dcb5b4d09103d061dd4a718484d512ff766913
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 (2026-09-21)
|
|
4
|
+
|
|
5
|
+
The telemetry layer the hosted platform runs is this gem's, not a copy. Everything
|
|
6
|
+
Railwatch Cloud had improved in its fork comes home, and the two seams the platform
|
|
7
|
+
needs are named.
|
|
8
|
+
|
|
9
|
+
- **Chart bucket widths.** `Telemetry::Aggregations` offers `STEPS` (1m to 1d),
|
|
10
|
+
`default_step`, and `steps_for` a window; `series` takes a `step:` and reads raw
|
|
11
|
+
rows with exact per-bucket percentiles under an hour, rollups at an hour and up;
|
|
12
|
+
`fill` zero-fills quiet buckets. The dashboard shows a step picker beside the
|
|
13
|
+
window picker, and every series page, release health, tenants, processes, and
|
|
14
|
+
exceptions draw at the chosen step. Latency lines break over an empty bucket
|
|
15
|
+
instead of dropping to zero.
|
|
16
|
+
- **One `Window`.** `Railwatch::Window` resolves `?window=` presets and custom
|
|
17
|
+
`?from=&to=` ranges, with the previous period for deltas; the controller concern
|
|
18
|
+
reads it instead of carrying the table.
|
|
19
|
+
- **Rollup extras.** `Telemetry::Rollup.summarize` merges each type's `extra`
|
|
20
|
+
(LLM spend and tokens, cache hits and misses) the way `absorb!` does.
|
|
21
|
+
- **LLM thresholds and anomaly rules.** `spend`, `tokens`, and `truncation_rate`
|
|
22
|
+
metrics on `llm_calls` and `llm_tools`; the form narrows metrics by kind and
|
|
23
|
+
shows the unit; `Threshold#format_value` prints dollars and percentages.
|
|
24
|
+
- **`as_row`** on Execution, Log, Exception, and Query: the one row shape every
|
|
25
|
+
list surface shows. The commands page reads the exit code from `status`.
|
|
26
|
+
- `Railwatch::TelemetryRecord` documents that the hosted platform tenants it, and
|
|
27
|
+
`docs/embedded.md` says what the platform is in terms of this engine.
|
|
28
|
+
|
|
29
|
+
## 0.4.0 (2026-09-21)
|
|
30
|
+
|
|
31
|
+
The gem is the one author of what goes over the wire and what runs in the
|
|
32
|
+
browser. Anything that receives from it, Railwatch Cloud first, reads these
|
|
33
|
+
from the gem instead of keeping a copy.
|
|
34
|
+
|
|
35
|
+
- **One browser client.** The gem carried two: the install template wrote
|
|
36
|
+
the `railwatch_session` cookie that `Railwatch::Sessions` reads, while the
|
|
37
|
+
dashboard's own copy still wrote `lantern_session`, so every embedded
|
|
38
|
+
dashboard shipped a beacon whose sessions never stitched to their server
|
|
39
|
+
requests. There is one file now, `app/frontend/lib/railwatch.ts`, shipped
|
|
40
|
+
in the gem (`Railwatch.browser_client_path`): the dashboard bundle is built
|
|
41
|
+
from it, `railwatch:install` copies it, and a host with its own frontend
|
|
42
|
+
build imports it. A spec holds the cookie name to the regex.
|
|
43
|
+
- **Shipped wire fixtures.** `lib/railwatch/wire_fixtures.json` holds one
|
|
44
|
+
record per type in `Railwatch::Record::VERSIONS`, produced by the gem's own
|
|
45
|
+
subscribers and middleware, stabilized, and checked in.
|
|
46
|
+
`Railwatch.wire_fixtures` loads it. A receiver tests its mapper against
|
|
47
|
+
these rather than hand-writing what it remembers the gem sending. The spec
|
|
48
|
+
that generates them fails when the file is stale; `rake
|
|
49
|
+
railwatch:wire_fixtures` regenerates it, and that diff is the review
|
|
50
|
+
surface for a wire change.
|
|
51
|
+
- **Record versions are enforced.** Every record has carried a `v` since the
|
|
52
|
+
first release and no receiver read it. `Railwatch::Ingest::Mapper` now
|
|
53
|
+
refuses a record whose version is not the one this gem emits for its type,
|
|
54
|
+
by name (`log v2 is not v1`), on both the trusted embedded path and the
|
|
55
|
+
untrusted one. A shape change without a version bump is caught by the
|
|
56
|
+
fixture spec; a version bump the receiver has not shipped is refused
|
|
57
|
+
instead of misread.
|
|
58
|
+
- `Railwatch::Transport::Http::HEADERS` names the delivery headers in one
|
|
59
|
+
place for the receiver to read.
|
|
60
|
+
|
|
3
61
|
## 0.3.7 (2026-09-20)
|
|
4
62
|
|
|
5
63
|
- Stop a rake task or `rails runner` waiting on the network as it finishes.
|
|
@@ -5,7 +5,7 @@ module Railwatch
|
|
|
5
5
|
def index
|
|
6
6
|
rows = telemetry { Telemetry::Execution.commands.between(*window_range).recent.limit(200).to_a }
|
|
7
7
|
render inertia: { commands: grouped("command", limit: 100),
|
|
8
|
-
runs: rows.map
|
|
8
|
+
runs: rows.map(&:as_row) }
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def show
|
|
@@ -6,14 +6,13 @@ module Railwatch
|
|
|
6
6
|
module EnvironmentScoped
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
|
-
WINDOWS = { "1h" => 1.hour, "6h" => 6.hours, "24h" => 24.hours, "7d" => 7.days, "30d" => 30.days }.freeze
|
|
10
|
-
MAX_CUSTOM_RANGE = 90.days
|
|
11
|
-
|
|
12
9
|
included do
|
|
13
10
|
before_action :set_environment
|
|
14
11
|
inertia_share environment: -> { environment_props }
|
|
15
|
-
inertia_share window: -> {
|
|
16
|
-
inertia_share range: -> {
|
|
12
|
+
inertia_share window: -> { window.key }
|
|
13
|
+
inertia_share range: -> { window.to_h }
|
|
14
|
+
inertia_share step: -> { step_key }
|
|
15
|
+
inertia_share steps: -> { Telemetry::Aggregations.steps_for(*window_range) }
|
|
17
16
|
inertia_share saved_views: -> { SavedView.props_for(environment, Viewer.user) }
|
|
18
17
|
end
|
|
19
18
|
|
|
@@ -25,38 +24,19 @@ module Railwatch
|
|
|
25
24
|
@environment = Environment.find(params[:environment_id] || Environment::ID)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def window_range
|
|
33
|
-
return @window_range if defined?(@window_range)
|
|
34
|
-
@window_range = custom_range || begin
|
|
35
|
-
key = WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h"
|
|
36
|
-
to = Time.current
|
|
37
|
-
[ to - WINDOWS[key], to ]
|
|
38
|
-
end
|
|
27
|
+
def window
|
|
28
|
+
@window ||= Window.parse(window: params[:window], from: params[:from], to: params[:to])
|
|
39
29
|
end
|
|
40
30
|
|
|
41
|
-
def
|
|
42
|
-
return @custom_range if defined?(@custom_range)
|
|
43
|
-
from = parse_time(params[:from])
|
|
44
|
-
to = from && (parse_time(params[:to]) || Time.current)
|
|
45
|
-
valid = from && to && to > from && (to - from) <= MAX_CUSTOM_RANGE
|
|
46
|
-
@custom_range = valid ? [ from, to ] : nil
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def parse_time(value)
|
|
50
|
-
return nil if value.blank?
|
|
51
|
-
Time.zone.parse(value.to_s)
|
|
52
|
-
rescue ArgumentError, TypeError
|
|
53
|
-
nil
|
|
54
|
-
end
|
|
31
|
+
def window_range = window.range
|
|
55
32
|
|
|
56
|
-
|
|
33
|
+
# The chart bucket width: ?step= when it is one the window offers, else the
|
|
34
|
+
# window's default (a minute for an hour, an hour for a day, and so on).
|
|
35
|
+
def step_key
|
|
36
|
+
return @step_key if defined?(@step_key)
|
|
57
37
|
from, to = window_range
|
|
58
|
-
|
|
59
|
-
[
|
|
38
|
+
offered = Telemetry::Aggregations.steps_for(from, to)
|
|
39
|
+
@step_key = offered.include?(params[:step].to_s) ? params[:step].to_s : Telemetry::Aggregations.default_step(from, to)
|
|
60
40
|
end
|
|
61
41
|
|
|
62
42
|
def telemetry(&block) = environment.with_telemetry(&block)
|
|
@@ -79,9 +59,11 @@ module Railwatch
|
|
|
79
59
|
environment.deploys.between(*window_range).recent.limit(50).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } }
|
|
80
60
|
end
|
|
81
61
|
|
|
82
|
-
|
|
62
|
+
# Time-bucketed series for charts, one point per step_key across the
|
|
63
|
+
# window: [{t, count, errors, client_errors, avg, p50, p95, p99}]
|
|
64
|
+
def series(record_type, group_hash: nil)
|
|
83
65
|
from, to = window_range
|
|
84
|
-
Telemetry::Aggregations.series(environment, record_type, from: from, to: to, group_hash: group_hash,
|
|
66
|
+
Telemetry::Aggregations.series(environment, record_type, from: from, to: to, group_hash: group_hash, step: step_key)
|
|
85
67
|
end
|
|
86
68
|
|
|
87
69
|
def grouped(record_type, limit: 100, order: nil, dir: nil)
|
|
@@ -91,7 +73,7 @@ module Railwatch
|
|
|
91
73
|
|
|
92
74
|
def summary_with_delta(record_type, group_hash: nil)
|
|
93
75
|
from, to = window_range
|
|
94
|
-
previous_from, previous_to =
|
|
76
|
+
previous_from, previous_to = window.previous.range
|
|
95
77
|
Telemetry::Aggregations.summary_with_delta(environment, record_type, from: from, to: to,
|
|
96
78
|
previous_from: previous_from, previous_to: previous_to, group_hash: group_hash)
|
|
97
79
|
end
|
|
@@ -38,16 +38,15 @@ module Railwatch
|
|
|
38
38
|
# `errors` carries the unhandled count so the chart legend (handled /
|
|
39
39
|
# unhandled) agrees with the Unhandled card above the table.
|
|
40
40
|
def exception_series(from, to)
|
|
41
|
+
step = Telemetry::Aggregations::STEPS.fetch(step_key)
|
|
42
|
+
bucket = Telemetry::Aggregations.bucket_sql("occurred_at", step)
|
|
41
43
|
buckets = Hash.new { |h, k| h[k] = { count: 0, errors: 0 } }
|
|
42
|
-
Telemetry::Exception.between(from, to)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
end
|
|
49
|
-
buckets.map { |bucket, b| { t: bucket, count: b[:count], errors: b[:errors], client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0 } }
|
|
50
|
-
.sort_by { |r| r[:t] }
|
|
44
|
+
Telemetry::Exception.between(from, to).group(bucket, :handled).count.each do |(b, handled), c|
|
|
45
|
+
buckets[b][:count] += c
|
|
46
|
+
buckets[b][:errors] += c unless handled
|
|
47
|
+
end
|
|
48
|
+
points = buckets.map { |b, c| { t: Time.at(b).utc, count: c[:count], errors: c[:errors], client_errors: 0, avg: nil, p50: nil, p95: nil, p99: nil } }
|
|
49
|
+
Telemetry::Aggregations.fill(points, from, to, step)
|
|
51
50
|
end
|
|
52
51
|
end
|
|
53
52
|
end
|
|
@@ -6,7 +6,7 @@ module Railwatch
|
|
|
6
6
|
live_since = Telemetry::HealthSample::LIVE_WINDOW.ago
|
|
7
7
|
samples, series, queues, live_servers, processes = telemetry do
|
|
8
8
|
live = Telemetry::HealthSample.live(live_since).to_a
|
|
9
|
-
[ live, Telemetry::HealthSample.series(*window_range), Telemetry::HealthSample.queue_depths(live),
|
|
9
|
+
[ live, Telemetry::HealthSample.series(*window_range, bucket: Telemetry::Aggregations::STEPS.fetch(step_key)), Telemetry::HealthSample.queue_depths(live),
|
|
10
10
|
live.map(&:server) | Telemetry::Execution.servers_since(live_since),
|
|
11
11
|
Telemetry::Process.recent.limit(200).to_a ]
|
|
12
12
|
end
|
|
@@ -17,7 +17,7 @@ module Railwatch
|
|
|
17
17
|
new_issues = new_issue_counts(deploys, to)
|
|
18
18
|
summary, series, adoption, health = telemetry do
|
|
19
19
|
[ Telemetry::ReleaseHealth.for_deploy(nil, from, to),
|
|
20
|
-
Telemetry::ReleaseHealth.series(nil, from, to),
|
|
20
|
+
Telemetry::ReleaseHealth.series(nil, from, to, step: step_key),
|
|
21
21
|
Telemetry::ReleaseHealth.adoption(from, to),
|
|
22
22
|
deploys.to_h { |d| [ d.deploy, Telemetry::ReleaseHealth.for_deploy(d.deploy, from, to) ] } ]
|
|
23
23
|
end
|
|
@@ -37,7 +37,7 @@ module Railwatch
|
|
|
37
37
|
data = telemetry do
|
|
38
38
|
{ summary: Telemetry::ReleaseHealth.for_deploy(release, from, to),
|
|
39
39
|
previous_summary: previous && Telemetry::ReleaseHealth.for_deploy(previous.deploy, *previous.window),
|
|
40
|
-
series: Telemetry::ReleaseHealth.series(release, from, to),
|
|
40
|
+
series: Telemetry::ReleaseHealth.series(release, from, to, step: step_key),
|
|
41
41
|
sessions: Telemetry::Session.where(deploy: release).recent.limit(SESSIONS_SHOWN).map { |s| session_row(s) } }
|
|
42
42
|
end
|
|
43
43
|
render inertia: data.merge(
|
|
@@ -20,8 +20,8 @@ module Railwatch
|
|
|
20
20
|
from, to = window_range
|
|
21
21
|
data = telemetry do
|
|
22
22
|
{ tenant: tenant, summary: { current: Telemetry::Tenant.summary(tenant, from, to),
|
|
23
|
-
previous: Telemetry::Tenant.summary(tenant, *
|
|
24
|
-
series: Telemetry::Tenant.series(tenant, from, to), routes: Telemetry::Tenant.routes(tenant, from, to),
|
|
23
|
+
previous: Telemetry::Tenant.summary(tenant, *window.previous.range) },
|
|
24
|
+
series: Telemetry::Tenant.series(tenant, from, to, step: step_key), routes: Telemetry::Tenant.routes(tenant, from, to),
|
|
25
25
|
jobs: Telemetry::Tenant.job_classes(tenant, from, to), exceptions: Telemetry::Tenant.exceptions(tenant, from, to),
|
|
26
26
|
people: Telemetry::Tenant.people(tenant), recent_requests: Telemetry::Tenant.recent_requests(tenant, from, to) }
|
|
27
27
|
end
|
|
@@ -8,7 +8,14 @@ module Railwatch
|
|
|
8
8
|
jobs: telemetry { Telemetry::Rollup.for_type("job_attempt").where("bucket > ?", 7.days.ago).distinct.pluck(:name).sort },
|
|
9
9
|
kinds: Threshold::TARGET_KINDS, metrics: Threshold::METRICS,
|
|
10
10
|
anomaly_rules: environment.anomaly_rules.order(:target_kind, :target).map { |r| r.slice(:id, :target_kind, :target, :metric, :deviation, :window_minutes, :baseline_days, :enabled, :last_fired_at).merge(description: r.description) },
|
|
11
|
-
|
|
11
|
+
# Spend and tokens only exist on llm_calls, so the form
|
|
12
|
+
# narrows with the kind rather than offering a rule that
|
|
13
|
+
# could never fire.
|
|
14
|
+
metrics_for_kind: Threshold::TARGET_KINDS.index_with { |k| Threshold.metrics_for(k) },
|
|
15
|
+
units: Threshold::UNITS,
|
|
16
|
+
llm_models: telemetry { Telemetry::Rollup.for_type("llm_call").where("bucket > ?", 7.days.ago).distinct.pluck(:name).compact.sort },
|
|
17
|
+
anomaly_target_kinds: AnomalyRule::TARGET_KINDS, anomaly_metrics: AnomalyRule::METRICS,
|
|
18
|
+
anomaly_metrics_for_kind: AnomalyRule::TARGET_KINDS.index_with { |k| AnomalyRule.metrics_for(k) } }
|
|
12
19
|
end
|
|
13
20
|
|
|
14
21
|
def create
|
|
@@ -77,7 +77,11 @@ const DEFAULT_IGNORE_ERRORS = [
|
|
|
77
77
|
"ResizeObserver loop limit exceeded",
|
|
78
78
|
"ResizeObserver loop completed with undelivered notifications",
|
|
79
79
|
]
|
|
80
|
-
const DEFAULT_DENY_URLS = [
|
|
80
|
+
const DEFAULT_DENY_URLS = [
|
|
81
|
+
/extensions\//i,
|
|
82
|
+
/^chrome:\/\//i,
|
|
83
|
+
/^moz-extension:\/\//i,
|
|
84
|
+
]
|
|
81
85
|
|
|
82
86
|
const SESSION_ID_KEY = "railwatch.session"
|
|
83
87
|
const SESSION_STARTED_KEY = "railwatch.session.at"
|
|
@@ -128,10 +132,18 @@ function endpoint() {
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
function csrf() {
|
|
131
|
-
return
|
|
135
|
+
return (
|
|
136
|
+
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
|
|
137
|
+
?.content ?? ""
|
|
138
|
+
)
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
function post(payload: {
|
|
141
|
+
function post(payload: {
|
|
142
|
+
visits: Visit[]
|
|
143
|
+
errors: JsError[]
|
|
144
|
+
session?: Session
|
|
145
|
+
tenant?: string
|
|
146
|
+
}) {
|
|
135
147
|
const body = JSON.stringify(payload)
|
|
136
148
|
const blob = new Blob([body], { type: "application/json" })
|
|
137
149
|
if (navigator.sendBeacon?.(endpoint(), blob)) return
|
|
@@ -145,7 +157,12 @@ function post(payload: { visits: Visit[]; errors: JsError[]; session?: Session;
|
|
|
145
157
|
|
|
146
158
|
function flush(ended = false) {
|
|
147
159
|
if (queue.length === 0 && errors.length === 0 && !ended) return
|
|
148
|
-
post({
|
|
160
|
+
post({
|
|
161
|
+
visits: queue.splice(0, queue.length),
|
|
162
|
+
errors: errors.splice(0, errors.length),
|
|
163
|
+
session: beat(ended),
|
|
164
|
+
tenant: tenant(),
|
|
165
|
+
})
|
|
149
166
|
}
|
|
150
167
|
|
|
151
168
|
// The app's tenant resolver runs on the flush path, where a throw would cost
|
|
@@ -175,15 +192,23 @@ function startSession() {
|
|
|
175
192
|
try {
|
|
176
193
|
const existing = sessionStorage.getItem(SESSION_ID_KEY)
|
|
177
194
|
const id = existing ?? randomId()
|
|
178
|
-
const startedAt =
|
|
195
|
+
const startedAt =
|
|
196
|
+
Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
|
|
179
197
|
if (!existing) {
|
|
180
198
|
sessionStorage.setItem(SESSION_ID_KEY, id)
|
|
181
199
|
sessionStorage.setItem(SESSION_STARTED_KEY, String(startedAt))
|
|
182
200
|
}
|
|
201
|
+
// Railwatch::Sessions::COOKIE reads this name to stitch server requests to the browser session.
|
|
183
202
|
document.cookie = `railwatch_session=${id}; path=/; SameSite=Lax`
|
|
184
203
|
session = { id, started_at: startedAt }
|
|
185
204
|
// No duration on the first beat: that is what opens the session.
|
|
186
|
-
if (!existing)
|
|
205
|
+
if (!existing)
|
|
206
|
+
post({
|
|
207
|
+
visits: [],
|
|
208
|
+
errors: [],
|
|
209
|
+
session: { ...session },
|
|
210
|
+
tenant: tenant(),
|
|
211
|
+
})
|
|
187
212
|
} catch {
|
|
188
213
|
// sessionStorage is unavailable (private mode, storage disabled).
|
|
189
214
|
// Everything else still reports; this tab just has no session.
|
|
@@ -213,7 +238,8 @@ function crumb(kind: Crumb["kind"], text: string) {
|
|
|
213
238
|
// oldest entries until it fits the byte budget.
|
|
214
239
|
function trail(): Crumb[] {
|
|
215
240
|
const taken = crumbs.slice()
|
|
216
|
-
while (taken.length > 0 && JSON.stringify(taken).length > MAX_CRUMB_BYTES)
|
|
241
|
+
while (taken.length > 0 && JSON.stringify(taken).length > MAX_CRUMB_BYTES)
|
|
242
|
+
taken.shift()
|
|
217
243
|
return taken
|
|
218
244
|
}
|
|
219
245
|
|
|
@@ -223,16 +249,26 @@ function trail(): Crumb[] {
|
|
|
223
249
|
function describeTarget(target: EventTarget | null): string {
|
|
224
250
|
if (!(target instanceof Element)) return ""
|
|
225
251
|
const id = target.id ? `#${target.id}` : ""
|
|
226
|
-
const className =
|
|
252
|
+
const className =
|
|
253
|
+
typeof target.className === "string" ? target.className.trim() : ""
|
|
227
254
|
const classes = className ? `.${className.split(/\s+/).join(".")}` : ""
|
|
228
|
-
const text =
|
|
255
|
+
const text =
|
|
256
|
+
target instanceof HTMLInputElement
|
|
257
|
+
? ""
|
|
258
|
+
: (target.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80)
|
|
229
259
|
return `${target.tagName.toLowerCase()}${id}${classes}${text ? ` "${text}"` : ""}`
|
|
230
260
|
}
|
|
231
261
|
|
|
232
262
|
function startBreadcrumbs() {
|
|
233
|
-
document.addEventListener(
|
|
263
|
+
document.addEventListener(
|
|
264
|
+
"click",
|
|
265
|
+
(event) => crumb("click", describeTarget(event.target)),
|
|
266
|
+
true,
|
|
267
|
+
)
|
|
234
268
|
for (const level of ["error", "warn"] as const) {
|
|
235
|
-
const original = console[level].bind(console) as (
|
|
269
|
+
const original = console[level].bind(console) as (
|
|
270
|
+
...args: unknown[]
|
|
271
|
+
) => void
|
|
236
272
|
console[level] = (...args: unknown[]) => {
|
|
237
273
|
crumb("console", `${level}: ${args.map(stringify).join(" ")}`)
|
|
238
274
|
original(...args)
|
|
@@ -267,14 +303,26 @@ function topFrameUrl(stack: string): string | undefined {
|
|
|
267
303
|
// the app's own code at all -- an extension, an injected widget, a tag
|
|
268
304
|
// manager. None of those are anything this app can fix.
|
|
269
305
|
function ignored(message: string, stack?: string): boolean {
|
|
270
|
-
if (
|
|
306
|
+
if (
|
|
307
|
+
ignoreErrors.some((pattern) =>
|
|
308
|
+
typeof pattern === "string"
|
|
309
|
+
? message.includes(pattern)
|
|
310
|
+
: pattern.test(message),
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
return true
|
|
271
314
|
const url = stack ? topFrameUrl(stack) : undefined
|
|
272
315
|
if (!url) return false
|
|
273
316
|
if (denyUrls.some((pattern) => pattern.test(url))) return true
|
|
274
317
|
return !url.startsWith("/") && !url.startsWith(`${location.origin}/`)
|
|
275
318
|
}
|
|
276
319
|
|
|
277
|
-
function capture(
|
|
320
|
+
function capture(
|
|
321
|
+
name: string,
|
|
322
|
+
message: string,
|
|
323
|
+
stack?: string,
|
|
324
|
+
context?: Record<string, unknown>,
|
|
325
|
+
) {
|
|
278
326
|
if (errors.length >= MAX_ERRORS) return
|
|
279
327
|
if (ignored(message, stack)) return
|
|
280
328
|
const breadcrumbs = trail()
|
|
@@ -292,15 +340,28 @@ function capture(name: string, message: string, stack?: string, context?: Record
|
|
|
292
340
|
// Deduped within the flush, not across the page's life: a render loop
|
|
293
341
|
// throws the same error every retry. (Inertia's re-rejection of a failed
|
|
294
342
|
// request is dropped by identity in the rejection handler instead.)
|
|
295
|
-
if (
|
|
343
|
+
if (
|
|
344
|
+
errors.some(
|
|
345
|
+
(e) =>
|
|
346
|
+
e.name === error.name &&
|
|
347
|
+
e.message === error.message &&
|
|
348
|
+
e.stack === error.stack,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
return
|
|
296
352
|
errors.push(error)
|
|
297
353
|
}
|
|
298
354
|
|
|
299
355
|
// Anything at all can be thrown or rejected in JavaScript, not just an
|
|
300
356
|
// Error. A non-Error value is reported under `fallback` with whatever it
|
|
301
357
|
// stringifies to as the message.
|
|
302
|
-
function captureValue(
|
|
303
|
-
|
|
358
|
+
function captureValue(
|
|
359
|
+
value: unknown,
|
|
360
|
+
fallback: string,
|
|
361
|
+
context?: Record<string, unknown>,
|
|
362
|
+
) {
|
|
363
|
+
if (value instanceof Error)
|
|
364
|
+
capture(value.name, value.message, value.stack, context)
|
|
304
365
|
else capture(fallback, stringify(value), undefined, context)
|
|
305
366
|
}
|
|
306
367
|
|
|
@@ -336,7 +397,11 @@ export function railwatchRootOptions(): {
|
|
|
336
397
|
onUncaughtError: (error: unknown, info: ReactErrorInfo) => void
|
|
337
398
|
} {
|
|
338
399
|
const report = (error: unknown, info: ReactErrorInfo) =>
|
|
339
|
-
captureValue(
|
|
400
|
+
captureValue(
|
|
401
|
+
error,
|
|
402
|
+
"Error",
|
|
403
|
+
info.componentStack ? { componentStack: info.componentStack } : undefined,
|
|
404
|
+
)
|
|
340
405
|
return { onCaughtError: report, onUncaughtError: report }
|
|
341
406
|
}
|
|
342
407
|
|
|
@@ -346,7 +411,12 @@ export function railwatchRootOptions(): {
|
|
|
346
411
|
function stringify(value: unknown) {
|
|
347
412
|
try {
|
|
348
413
|
if (value instanceof Error) return `${value.name}: ${value.message}`
|
|
349
|
-
if (
|
|
414
|
+
if (
|
|
415
|
+
Array.isArray(value) ||
|
|
416
|
+
(typeof value === "object" &&
|
|
417
|
+
value !== null &&
|
|
418
|
+
value.toString === Object.prototype.toString)
|
|
419
|
+
) {
|
|
350
420
|
return JSON.stringify(value).slice(0, MAX_CRUMB_TEXT)
|
|
351
421
|
}
|
|
352
422
|
return String(value)
|
|
@@ -376,13 +446,20 @@ function startErrorCapture() {
|
|
|
376
446
|
})
|
|
377
447
|
window.addEventListener("unhandledrejection", (event) => {
|
|
378
448
|
const reason = event.reason as unknown
|
|
379
|
-
if (
|
|
449
|
+
if (
|
|
450
|
+
typeof reason === "object" &&
|
|
451
|
+
reason !== null &&
|
|
452
|
+
requestFailures.has(reason)
|
|
453
|
+
)
|
|
454
|
+
return
|
|
380
455
|
captureValue(reason, "UnhandledRejection")
|
|
381
456
|
})
|
|
382
457
|
onInertia("exception", (detail) => captureRequestFailure(detail.exception))
|
|
383
458
|
onInertia("networkError", (detail) => captureRequestFailure(detail.error))
|
|
384
459
|
onInertia("invalid", (detail) => captureInvalidResponse(detail.response))
|
|
385
|
-
onInertia("httpException", (detail) =>
|
|
460
|
+
onInertia("httpException", (detail) =>
|
|
461
|
+
captureInvalidResponse(detail.response),
|
|
462
|
+
)
|
|
386
463
|
}
|
|
387
464
|
|
|
388
465
|
// A visit the user is waiting on: one that shows the progress bar, or the
|
|
@@ -402,12 +479,16 @@ function waiting(visit: object): boolean {
|
|
|
402
479
|
// the next waiting `before`, a waiting `start`, or a reported failure,
|
|
403
480
|
// which can only over-report.
|
|
404
481
|
function idOf(record: object, key: string): string | undefined {
|
|
405
|
-
const value =
|
|
482
|
+
const value =
|
|
483
|
+
key in record ? (record as Record<string, unknown>)[key] : undefined
|
|
406
484
|
return typeof value === "string" ? value : undefined
|
|
407
485
|
}
|
|
408
486
|
|
|
409
487
|
function landed(detail: object) {
|
|
410
|
-
if (
|
|
488
|
+
if (
|
|
489
|
+
foregroundRequest?.id !== undefined &&
|
|
490
|
+
idOf(detail, "visitId") === foregroundRequest.id
|
|
491
|
+
) {
|
|
411
492
|
foregroundRequest = null
|
|
412
493
|
}
|
|
413
494
|
}
|
|
@@ -424,10 +505,17 @@ function captureRequestFailure(error: unknown) {
|
|
|
424
505
|
captureValue(error, "InertiaException")
|
|
425
506
|
}
|
|
426
507
|
|
|
427
|
-
function onInertia(
|
|
508
|
+
function onInertia(
|
|
509
|
+
name: string,
|
|
510
|
+
handler: (detail: Record<string, unknown>) => void,
|
|
511
|
+
) {
|
|
428
512
|
document.addEventListener(`inertia:${name}`, (event) => {
|
|
429
513
|
const detail = (event as CustomEvent<unknown>).detail
|
|
430
|
-
handler(
|
|
514
|
+
handler(
|
|
515
|
+
typeof detail === "object" && detail !== null
|
|
516
|
+
? (detail as Record<string, unknown>)
|
|
517
|
+
: {},
|
|
518
|
+
)
|
|
431
519
|
})
|
|
432
520
|
}
|
|
433
521
|
|
|
@@ -439,19 +527,31 @@ function onInertia(name: string, handler: (detail: Record<string, unknown>) => v
|
|
|
439
527
|
// predicate for the two cases is the x-inertia response header, so that is
|
|
440
528
|
// the gate here; Inertia 2's `invalid` only ever fired for the first kind.
|
|
441
529
|
function captureInvalidResponse(response: unknown) {
|
|
442
|
-
const res = (response ?? {}) as {
|
|
530
|
+
const res = (response ?? {}) as {
|
|
531
|
+
status?: unknown
|
|
532
|
+
headers?: Record<string, unknown>
|
|
533
|
+
}
|
|
443
534
|
if (inertiaResponse(res.headers)) return
|
|
444
535
|
const status = typeof res.status === "number" ? res.status : "unknown status"
|
|
445
536
|
const contentType = res.headers?.["content-type"]
|
|
446
|
-
capture(
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
537
|
+
capture(
|
|
538
|
+
"InertiaInvalidResponse",
|
|
539
|
+
`Inertia invalid response (${status})`,
|
|
540
|
+
undefined,
|
|
541
|
+
{
|
|
542
|
+
status,
|
|
543
|
+
...(typeof contentType === "string" ? { content_type: contentType } : {}),
|
|
544
|
+
},
|
|
545
|
+
)
|
|
450
546
|
}
|
|
451
547
|
|
|
452
|
-
function inertiaResponse(
|
|
548
|
+
function inertiaResponse(
|
|
549
|
+
headers: Record<string, unknown> | undefined,
|
|
550
|
+
): boolean {
|
|
453
551
|
if (!headers || typeof headers !== "object") return false
|
|
454
|
-
return Object.keys(headers).some(
|
|
552
|
+
return Object.keys(headers).some(
|
|
553
|
+
(key) => key.toLowerCase() === "x-inertia" && headers[key],
|
|
554
|
+
)
|
|
455
555
|
}
|
|
456
556
|
|
|
457
557
|
// --- Core Web Vitals ---------------------------------------------------
|
|
@@ -472,10 +572,16 @@ interface ObserveOptions {
|
|
|
472
572
|
durationThreshold?: number
|
|
473
573
|
}
|
|
474
574
|
|
|
475
|
-
function observe(
|
|
575
|
+
function observe(
|
|
576
|
+
type: string,
|
|
577
|
+
callback: (entries: VitalEntry[]) => void,
|
|
578
|
+
options: ObserveOptions = {},
|
|
579
|
+
) {
|
|
476
580
|
if (typeof PerformanceObserver === "undefined") return
|
|
477
581
|
try {
|
|
478
|
-
const observer = new PerformanceObserver((list) =>
|
|
582
|
+
const observer = new PerformanceObserver((list) =>
|
|
583
|
+
callback(list.getEntries()),
|
|
584
|
+
)
|
|
479
585
|
observer.observe({ type, buffered: true, ...options })
|
|
480
586
|
} catch {
|
|
481
587
|
// This browser does not support this entry type. Skip that metric only.
|
|
@@ -487,9 +593,12 @@ function observe(type: string, callback: (entries: VitalEntry[]) => void, option
|
|
|
487
593
|
// this file is imported into) does not define, and a ReferenceError here
|
|
488
594
|
// would take the whole client down with it.
|
|
489
595
|
function navigationEntry(): PerformanceNavigationTiming | undefined {
|
|
490
|
-
const entries: PerformanceEntry[] =
|
|
596
|
+
const entries: PerformanceEntry[] =
|
|
597
|
+
performance.getEntriesByType?.("navigation") ?? []
|
|
491
598
|
const nav = entries[0]
|
|
492
|
-
return nav?.entryType === "navigation"
|
|
599
|
+
return nav?.entryType === "navigation"
|
|
600
|
+
? (nav as PerformanceNavigationTiming)
|
|
601
|
+
: undefined
|
|
493
602
|
}
|
|
494
603
|
|
|
495
604
|
function startVitals() {
|
|
@@ -511,7 +620,11 @@ function startVitals() {
|
|
|
511
620
|
for (const entry of entries) {
|
|
512
621
|
if (entry.hadRecentInput) continue
|
|
513
622
|
const value = entry.value ?? 0
|
|
514
|
-
if (
|
|
623
|
+
if (
|
|
624
|
+
sessionValue &&
|
|
625
|
+
entry.startTime - sessionLast < 1000 &&
|
|
626
|
+
entry.startTime - sessionFirst < 5000
|
|
627
|
+
) {
|
|
515
628
|
sessionValue += value
|
|
516
629
|
sessionLast = entry.startTime
|
|
517
630
|
} else {
|
|
@@ -541,7 +654,9 @@ function startVitals() {
|
|
|
541
654
|
// serialized page object. Inertia's own events take over from here.
|
|
542
655
|
function pageComponent(): string | undefined {
|
|
543
656
|
try {
|
|
544
|
-
const page = JSON.parse(
|
|
657
|
+
const page = JSON.parse(
|
|
658
|
+
document.getElementById("app")?.dataset.page ?? "{}",
|
|
659
|
+
) as { component?: string }
|
|
545
660
|
return page.component
|
|
546
661
|
} catch {
|
|
547
662
|
// Not an Inertia-rendered page, or the payload moved. Report it anyway.
|
|
@@ -557,7 +672,10 @@ function initialVisit(): Visit | null {
|
|
|
557
672
|
|
|
558
673
|
const duration = (nav.loadEventEnd || nav.responseEnd) - nav.startTime
|
|
559
674
|
return {
|
|
560
|
-
started_at: Math.round(
|
|
675
|
+
started_at: Math.round(
|
|
676
|
+
(performance.timeOrigin ?? Date.now() - performance.now()) +
|
|
677
|
+
nav.startTime,
|
|
678
|
+
),
|
|
561
679
|
url: location.pathname + location.search,
|
|
562
680
|
method: "GET",
|
|
563
681
|
component,
|
|
@@ -587,8 +705,8 @@ function finalize() {
|
|
|
587
705
|
}
|
|
588
706
|
|
|
589
707
|
export function startRailwatch(options: RailwatchOptions = {}) {
|
|
590
|
-
ignoreErrors = [
|
|
591
|
-
denyUrls = [
|
|
708
|
+
ignoreErrors = [...DEFAULT_IGNORE_ERRORS, ...(options.ignoreErrors ?? [])]
|
|
709
|
+
denyUrls = [...DEFAULT_DENY_URLS, ...(options.denyUrls ?? [])]
|
|
592
710
|
tenantOf = options.tenant
|
|
593
711
|
startVitals()
|
|
594
712
|
startSession()
|
|
@@ -609,7 +727,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
|
|
|
609
727
|
if (!waiting(v) || event.defaultPrevented) return
|
|
610
728
|
const request = (foregroundRequest = { id: idOf(v, "id") })
|
|
611
729
|
queueMicrotask(() => {
|
|
612
|
-
if (event.defaultPrevented && foregroundRequest === request)
|
|
730
|
+
if (event.defaultPrevented && foregroundRequest === request)
|
|
731
|
+
foregroundRequest = null
|
|
613
732
|
})
|
|
614
733
|
})
|
|
615
734
|
router.on("start", (event) => {
|
|
@@ -642,7 +761,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
|
|
|
642
761
|
router.on("finish", (event) => {
|
|
643
762
|
// Fires once whether the visit completed, failed, or was cancelled.
|
|
644
763
|
// Clamped so a missed start can only over-report, never silence.
|
|
645
|
-
if (waiting(event.detail.visit))
|
|
764
|
+
if (waiting(event.detail.visit))
|
|
765
|
+
foregroundVisits = Math.max(0, foregroundVisits - 1)
|
|
646
766
|
if (!current) return
|
|
647
767
|
current.duration_ms = Date.now() - current.started_at
|
|
648
768
|
current.status ??= "cancelled"
|