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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/app/controllers/railwatch/commands_controller.rb +1 -1
  4. data/app/controllers/railwatch/environment_scoped.rb +18 -36
  5. data/app/controllers/railwatch/exceptions_controller.rb +8 -9
  6. data/app/controllers/railwatch/processes_controller.rb +1 -1
  7. data/app/controllers/railwatch/releases_controller.rb +2 -2
  8. data/app/controllers/railwatch/tenants_controller.rb +2 -2
  9. data/app/controllers/railwatch/thresholds_controller.rb +8 -1
  10. data/{lib/generators/railwatch/install/templates → app/frontend/lib}/railwatch.ts +162 -42
  11. data/app/jobs/railwatch/detect_performance_issues_job.rb +12 -2
  12. data/app/models/railwatch/anomaly_rule.rb +20 -2
  13. data/app/models/railwatch/ingest/batch.rb +1 -0
  14. data/app/models/railwatch/ingest/mapper.rb +12 -0
  15. data/app/models/railwatch/telemetry/aggregations.rb +154 -19
  16. data/app/models/railwatch/telemetry/exception.rb +7 -0
  17. data/app/models/railwatch/telemetry/execution.rb +18 -0
  18. data/app/models/railwatch/telemetry/log.rb +6 -0
  19. data/app/models/railwatch/telemetry/query.rb +7 -0
  20. data/app/models/railwatch/telemetry/release_health.rb +12 -6
  21. data/app/models/railwatch/telemetry/rollup.rb +17 -2
  22. data/app/models/railwatch/telemetry/tenant.rb +8 -23
  23. data/app/models/railwatch/telemetry_record.rb +8 -6
  24. data/app/models/railwatch/threshold.rb +41 -4
  25. data/app/models/railwatch/window.rb +66 -0
  26. data/docs/embedded.md +11 -0
  27. data/lib/generators/railwatch/install/install_generator.rb +5 -1
  28. data/lib/railwatch/transport/http.rb +15 -5
  29. data/lib/railwatch/version.rb +1 -1
  30. data/lib/railwatch/wire_fixtures.json +856 -0
  31. data/lib/railwatch.rb +22 -0
  32. data/lib/tasks/railwatch_tasks.rake +7 -0
  33. data/public/railwatch/assets/app-layout-DDyQa72H.js +1 -0
  34. data/public/railwatch/assets/{app-wordmark-1SRllgHv.js → app-wordmark-o9CODKP0.js} +1 -1
  35. data/public/railwatch/assets/{appearance-DbwahDrc.js → appearance-BwuCXabr.js} +1 -1
  36. data/public/railwatch/assets/application-B7h1MIhi.css +1 -0
  37. data/public/railwatch/assets/{arrow-up-DwKQcab4.js → arrow-up-C6PxDiY3.js} +1 -1
  38. data/public/railwatch/assets/{auth-layout-DXfxXAN_.js → auth-layout-BRt8MGFD.js} +1 -1
  39. data/public/railwatch/assets/{badge-CApjyWcg.js → badge-CAxXV8za.js} +1 -1
  40. data/public/railwatch/assets/{braces-kGSgxWnS.js → braces-DgomTCNf.js} +1 -1
  41. data/public/railwatch/assets/{card-BaJ8kNkp.js → card-cAtqCxWl.js} +1 -1
  42. data/public/railwatch/assets/{chart-Xnrz1Nqw.js → chart-BBeBkkNa.js} +1 -1
  43. data/public/railwatch/assets/{chart-hover-XYPNE2CX.js → chart-hover-B1M9jc0y.js} +1 -1
  44. data/public/railwatch/assets/chart-panel-DUQTz_C8.js +1 -0
  45. data/public/railwatch/assets/{checkbox-Bf41R8W6.js → checkbox-CmhMHWZO.js} +1 -1
  46. data/public/railwatch/assets/{code-a-9sh36A.js → code-DESvxyTj.js} +1 -1
  47. data/public/railwatch/assets/{copy-block-zykZWQym.js → copy-block-BkSU5832.js} +1 -1
  48. data/public/railwatch/assets/{copy-id-b6CW5ESL.js → copy-id-D03GhN9F.js} +1 -1
  49. data/public/railwatch/assets/{cursor-load-more-0cHzbTPT.js → cursor-load-more-CRyuMeQb.js} +1 -1
  50. data/public/railwatch/assets/{data-table-IvA7xvrK.js → data-table-BIlt7Rtm.js} +1 -1
  51. data/public/railwatch/assets/{edit-DTtRV1fO.js → edit-Bb6MKoe4.js} +1 -1
  52. data/public/railwatch/assets/{edit-7lmf3k80.js → edit-DJ0D0wHN.js} +1 -1
  53. data/public/railwatch/assets/{edit-ZKE15r-F.js → edit-O0NSBWxo.js} +1 -1
  54. data/public/railwatch/assets/{empty-state-BFTnQ2oT.js → empty-state-C38il627.js} +1 -1
  55. data/public/railwatch/assets/env-layout-REF7OM4q.js +1 -0
  56. data/public/railwatch/assets/{execution-path-Di5uUHi-.js → execution-path-FYLq1TwC.js} +1 -1
  57. data/public/railwatch/assets/{filter-bar-Btm__gQi.js → filter-bar-CYog9Alp.js} +1 -1
  58. data/public/railwatch/assets/{flamegraph-C-mZdqa7.js → flamegraph-DSs69foN.js} +1 -1
  59. data/public/railwatch/assets/{frames-KZBsjg6-.js → frames-BUi2J5Mk.js} +1 -1
  60. data/public/railwatch/assets/{google-sign-in-button-DJRoD72g.js → google-sign-in-button-BQiIKFdd.js} +1 -1
  61. data/public/railwatch/assets/{index-9LL_C3eG.js → index-8-hnAhOD.js} +1 -1
  62. data/public/railwatch/assets/{index-Bjw4sTXU.js → index-BaR1U9An.js} +1 -1
  63. data/public/railwatch/assets/{index-BtSlLmkg.js → index-BeOh2t_S.js} +1 -1
  64. data/public/railwatch/assets/{index-DXUwhSCQ.js → index-BiiyMcA0.js} +1 -1
  65. data/public/railwatch/assets/{index-P_e_cI0O.js → index-BoUBioBP.js} +1 -1
  66. data/public/railwatch/assets/{index-kNhZPLVj.js → index-C-PmdhXA.js} +1 -1
  67. data/public/railwatch/assets/{index-DCAho97K.js → index-C3A_9imx.js} +1 -1
  68. data/public/railwatch/assets/{index-BsrCnQqg.js → index-C7OtLq_3.js} +1 -1
  69. data/public/railwatch/assets/{index-Bot7kHMA.js → index-CFFpnzIS.js} +1 -1
  70. data/public/railwatch/assets/{index-Z1c4nFL0.js → index-CFRLPs4J.js} +1 -1
  71. data/public/railwatch/assets/{index-BAkZNNEh.js → index-CGs4m_fa.js} +1 -1
  72. data/public/railwatch/assets/{index-CIUl9POV.js → index-CICUIFHL.js} +1 -1
  73. data/public/railwatch/assets/{index-wOj5j0KC.js → index-C_upSl_k.js} +1 -1
  74. data/public/railwatch/assets/{index-Cj3_jD1E.js → index-CiPo4Gob.js} +1 -1
  75. data/public/railwatch/assets/{index-EGU__DOB.js → index-CpkI015n.js} +1 -1
  76. data/public/railwatch/assets/{index-DEfu_YvU.js → index-CrZ3vHDL.js} +1 -1
  77. data/public/railwatch/assets/{index-CAzQkw-h.js → index-CsoN51vW.js} +1 -1
  78. data/public/railwatch/assets/{index-CeqMDByC.js → index-DDI_Zx5V.js} +1 -1
  79. data/public/railwatch/assets/index-DSvlZVWG.js +1 -0
  80. data/public/railwatch/assets/{index-CU6yhEyt.js → index-DW2CBbxU.js} +1 -1
  81. data/public/railwatch/assets/{index-uHBVO8Lh.js → index-Dh4IRLFI.js} +1 -1
  82. data/public/railwatch/assets/{index-HCJmFcZu.js → index-DqTFTP8p.js} +1 -1
  83. data/public/railwatch/assets/index-DrcKVG2f.js +1 -0
  84. data/public/railwatch/assets/{index-QyHE9WsU.js → index-DtHmuB9Q.js} +1 -1
  85. data/public/railwatch/assets/{index-CBJZQgHA.js → index-DvjY3dPD.js} +1 -1
  86. data/public/railwatch/assets/{index-Bo8hrjbs.js → index-FhUaPPab.js} +1 -1
  87. data/public/railwatch/assets/{index-BWSyQRZI.js → index-JdCVBrw8.js} +1 -1
  88. data/public/railwatch/assets/{index-DAEzO_vq.js → index-QpTtwFwu.js} +1 -1
  89. data/public/railwatch/assets/{index-D4968-TC.js → index-ZOGOB8SA.js} +1 -1
  90. data/public/railwatch/assets/{index-CP6jrrPT.js → index-ZSZg9rtq.js} +1 -1
  91. data/public/railwatch/assets/{index-DcItJxa5.js → index-r0tSIplE.js} +1 -1
  92. data/public/railwatch/assets/{index-CYYmZpZu.js → index-sTYvcbkh.js} +1 -1
  93. data/public/railwatch/assets/{index-B0VXzXYG.js → index-so4lRrRq.js} +1 -1
  94. data/public/railwatch/assets/{index-B-bSm1jB.js → index-tpz-OGUP.js} +1 -1
  95. data/public/railwatch/assets/{index-CX8Br7m9.js → index-umIAl-pL.js} +1 -1
  96. data/public/railwatch/assets/{inertia-Degv_G9N.js → inertia-DLew8ZNx.js} +3 -3
  97. data/public/railwatch/assets/{input-error-C6SqO_iU.js → input-error-cvM6_Jht.js} +1 -1
  98. data/public/railwatch/assets/{json-viewer-DcQIxT86.js → json-viewer-D922McGi.js} +1 -1
  99. data/public/railwatch/assets/{klass-WgQEfWpo.js → klass-CrwICqN8.js} +1 -1
  100. data/public/railwatch/assets/{label-B_vK19tR.js → label-GWl7I6sf.js} +1 -1
  101. data/public/railwatch/assets/{layout-C8q7GUlZ.js → layout-0ZAnD3zl.js} +1 -1
  102. data/public/railwatch/assets/{live-dot-DaXEbkXo.js → live-dot-D1n_BreY.js} +1 -1
  103. data/public/railwatch/assets/{nav-BnD5XArZ.js → nav-DPxr1NNC.js} +1 -1
  104. data/public/railwatch/assets/{new-BL9OqXHe.js → new-Cdl6pqST.js} +1 -1
  105. data/public/railwatch/assets/{new-uxMnBtoY.js → new-D-ZzUK9a.js} +1 -1
  106. data/public/railwatch/assets/{new-BqP0NhOF.js → new-DEVkYv-z.js} +1 -1
  107. data/public/railwatch/assets/{new-CWP3wtwt.js → new-DHAHDrN7.js} +1 -1
  108. data/public/railwatch/assets/{new-DIjwu2jF.js → new-Dz4lZf1L.js} +1 -1
  109. data/public/railwatch/assets/{new-BhRnQn4w.js → new-GMrRFurX.js} +1 -1
  110. data/public/railwatch/assets/{onboarding-C_Y3xTsH.js → onboarding-D1vwaHYT.js} +1 -1
  111. data/public/railwatch/assets/{origin-identity-CNhOnfBa.js → origin-identity-Bk9yHWZ1.js} +1 -1
  112. data/public/railwatch/assets/{percentile-picker-BujVEhP6.js → percentile-picker-DfSx9yJO.js} +1 -1
  113. data/public/railwatch/assets/{relative-time-6CoKr1lN.js → relative-time-CjIjb8Lg.js} +1 -1
  114. data/public/railwatch/assets/{release-health-1Qzc_XhT.js → release-health-4b3tivEf.js} +1 -1
  115. data/public/railwatch/assets/{route-xqgYjOwJ.js → route-C_5BUtHK.js} +1 -1
  116. data/public/railwatch/assets/{segmented-CVKq2Xpa.js → segmented-BgbT3wZa.js} +1 -1
  117. data/public/railwatch/assets/{select-CsmSXsJ4.js → select-DmunxCKE.js} +1 -1
  118. data/public/railwatch/assets/{separator-CrM10Oot.js → separator-BXzEdZ_8.js} +1 -1
  119. data/public/railwatch/assets/series-chart-Xf49v9cv.js +1 -0
  120. data/public/railwatch/assets/{show-zxVxhUel.js → show-B2zLAW83.js} +1 -1
  121. data/public/railwatch/assets/{show-BM-E2iYc.js → show-BLpWUHWD.js} +1 -1
  122. data/public/railwatch/assets/{show-CvNV23gH.js → show-CAl7xcex.js} +1 -1
  123. data/public/railwatch/assets/{show-BlNR3ZOZ.js → show-DI8IhNUH.js} +1 -1
  124. data/public/railwatch/assets/{show-B0nHy31L.js → show-DSP9Cq_C.js} +2 -2
  125. data/public/railwatch/assets/{show-rysBbvt-.js → show-DXs4deaC.js} +1 -1
  126. data/public/railwatch/assets/{show-DUwQlohF.js → show-DYskfl3-.js} +1 -1
  127. data/public/railwatch/assets/{show-ixK2r2f1.js → show-DcpTFiLi.js} +1 -1
  128. data/public/railwatch/assets/{show-CQ4Ze-Un.js → show-Dily73Xk.js} +1 -1
  129. data/public/railwatch/assets/{show-B3izvRdU.js → show-DlRVS18-.js} +1 -1
  130. data/public/railwatch/assets/{show-8H9WAGav.js → show-Dn-GwFZL.js} +1 -1
  131. data/public/railwatch/assets/{show-DZwcZZrz.js → show-DnR1Dnjd.js} +1 -1
  132. data/public/railwatch/assets/{show-CMisHNAI.js → show-SHwZjXb7.js} +1 -1
  133. data/public/railwatch/assets/{show-B7NhcWgp.js → show-SvLOcPrx.js} +1 -1
  134. data/public/railwatch/assets/{show-CJMeX75E.js → show-Y74rM0VT.js} +1 -1
  135. data/public/railwatch/assets/{show-DMZlwzL_.js → show-mU38uGTg.js} +1 -1
  136. data/public/railwatch/assets/{show-9jDeBNpc.js → show-vQ4bndYD.js} +1 -1
  137. data/public/railwatch/assets/{sort-header-CKj_7G3U.js → sort-header-Dcq9bzmo.js} +1 -1
  138. data/public/railwatch/assets/{sparkline-cell-Ckg29yVq.js → sparkline-cell-BON3qQUB.js} +1 -1
  139. data/public/railwatch/assets/{stat-TxvoBiC8.js → stat-DFEyFxkO.js} +1 -1
  140. data/public/railwatch/assets/{status-badge-5NCVJHjo.js → status-badge-BaUKP7Yo.js} +1 -1
  141. data/public/railwatch/assets/{tenant-path-B5ZRK4x3.js → tenant-path-DPZPc985.js} +1 -1
  142. data/public/railwatch/assets/{text-link-BPllRj0F.js → text-link-BO77t9Xk.js} +1 -1
  143. data/public/railwatch/assets/{textarea-DtD5yZp_.js → textarea-DTqrCiV0.js} +1 -1
  144. data/public/railwatch/assets/{timeline-DZz5ZnHN.js → timeline-D5rJ0es2.js} +1 -1
  145. data/public/railwatch/assets/{transition-Ds6Cakuc.js → transition-DMIrZVth.js} +1 -1
  146. data/public/railwatch/assets/{use-clipboard-BLjjYH8V.js → use-clipboard-ByoUGQqA.js} +1 -1
  147. data/public/railwatch/assets/{use-live-GKze1mNC.js → use-live-D7xKz2ma.js} +1 -1
  148. data/public/railwatch/manifest.json +1284 -1284
  149. metadata +119 -117
  150. data/public/railwatch/assets/app-layout-B5Fcdwfz.js +0 -1
  151. data/public/railwatch/assets/application-CwshqwM6.css +0 -1
  152. data/public/railwatch/assets/chart-panel-BYk68mJJ.js +0 -1
  153. data/public/railwatch/assets/env-layout-erSDpIXg.js +0 -1
  154. data/public/railwatch/assets/index-BoTOVM0v.js +0 -1
  155. data/public/railwatch/assets/index-DBTxQw77.js +0 -1
  156. 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: 56eb44eab9d0988d36c517995ad2ec8d42971ed9af60e3cf4c419c5df926007f
4
- data.tar.gz: 20f9bfed5d8044797a1e43b523668846b2b888756ff9fef59a1f54dc136d64c1
3
+ metadata.gz: 538efe4f9ba268dd88e694a7052542638a86d64f34978bffd45e5edb9e71dd18
4
+ data.tar.gz: 3f5d5b22228381e4f9d6657544c44fb3e337c754c9b7a9d683dada967d1592af
5
5
  SHA512:
6
- metadata.gz: 8bbd22e4636c6c3ba58dfff0242d3553db415af9b90041b524634ae7b95e65a0ff52f6584c7d6c4c61aee12274e177b308d06040508db41ee8db0af81ff1ab0d
7
- data.tar.gz: 398eea6fd06b824600b834f5da026f68b70fba6a5bcc1ec97c73995933ca9a8e569cca0d5135f27ee413563281bd91a941eb19ccb954f0b731cd25b2446ba86c
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 { |r| { execution_id: r.execution_id, name: r.name, exit_code: r.status, duration: r.duration_ms.round(1), occurred_at: r.occurred_at, server: r.server, exception_preview: r.exception_preview } } }
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: -> { window_key }
16
- inertia_share range: -> { from, to = window_range; { from: from.iso8601(6), to: to.iso8601(6) } }
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 window_key
29
- custom_range ? "custom" : (WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h")
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 custom_range
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
- def previous_window_range
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
- span = to - from
59
- [ from - span, from ]
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
- def series(record_type, group_hash: nil, name: nil)
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, name: name)
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 = previous_window_range
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
- .group(Arel.sql("strftime('%Y-%m-%dT%H:00:00Z', occurred_at)"), :handled)
44
- .count
45
- .each do |(bucket, handled), c|
46
- buckets[bucket][:count] += c
47
- buckets[bucket][:errors] += c unless handled
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, *previous_window_range) },
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
- anomaly_target_kinds: AnomalyRule::TARGET_KINDS, anomaly_metrics: AnomalyRule::METRICS }
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 = [/extensions\//i, /^chrome:\/\//i, /^moz-extension:\/\//i]
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 document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? ""
135
+ return (
136
+ document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
137
+ ?.content ?? ""
138
+ )
132
139
  }
133
140
 
134
- function post(payload: { visits: Visit[]; errors: JsError[]; session?: Session; tenant?: string }) {
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({ visits: queue.splice(0, queue.length), errors: errors.splice(0, errors.length), session: beat(ended), tenant: tenant() })
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 = Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
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) post({ visits: [], errors: [], session: { ...session }, tenant: tenant() })
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) taken.shift()
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 = typeof target.className === "string" ? target.className.trim() : ""
252
+ const className =
253
+ typeof target.className === "string" ? target.className.trim() : ""
227
254
  const classes = className ? `.${className.split(/\s+/).join(".")}` : ""
228
- const text = target instanceof HTMLInputElement ? "" : (target.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80)
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("click", (event) => crumb("click", describeTarget(event.target)), true)
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 (...args: unknown[]) => void
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 (ignoreErrors.some((pattern) => (typeof pattern === "string" ? message.includes(pattern) : pattern.test(message)))) return true
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(name: string, message: string, stack?: string, context?: Record<string, unknown>) {
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 (errors.some((e) => e.name === error.name && e.message === error.message && e.stack === error.stack)) return
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(value: unknown, fallback: string, context?: Record<string, unknown>) {
303
- if (value instanceof Error) capture(value.name, value.message, value.stack, context)
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(error, "Error", info.componentStack ? { componentStack: info.componentStack } : undefined)
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 (Array.isArray(value) || (typeof value === "object" && value !== null && value.toString === Object.prototype.toString)) {
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 (typeof reason === "object" && reason !== null && requestFailures.has(reason)) return
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) => captureInvalidResponse(detail.response))
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 = key in record ? (record as Record<string, unknown>)[key] : undefined
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 (foregroundRequest?.id !== undefined && idOf(detail, "visitId") === foregroundRequest.id) {
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(name: string, handler: (detail: Record<string, unknown>) => void) {
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(typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : {})
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 { status?: unknown; headers?: Record<string, unknown> }
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("InertiaInvalidResponse", `Inertia invalid response (${status})`, undefined, {
447
- status,
448
- ...(typeof contentType === "string" ? { content_type: contentType } : {}),
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(headers: Record<string, unknown> | undefined): boolean {
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((key) => key.toLowerCase() === "x-inertia" && headers[key])
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(type: string, callback: (entries: VitalEntry[]) => void, options: ObserveOptions = {}) {
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) => callback(list.getEntries()))
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[] = performance.getEntriesByType?.("navigation") ?? []
596
+ const entries: PerformanceEntry[] =
597
+ performance.getEntriesByType?.("navigation") ?? []
491
598
  const nav = entries[0]
492
- return nav?.entryType === "navigation" ? (nav as PerformanceNavigationTiming) : undefined
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 (sessionValue && entry.startTime - sessionLast < 1000 && entry.startTime - sessionFirst < 5000) {
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(document.getElementById("app")?.dataset.page ?? "{}") as { component?: string }
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((performance.timeOrigin ?? Date.now() - performance.now()) + nav.startTime),
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 = [ ...DEFAULT_IGNORE_ERRORS, ...(options.ignoreErrors ?? []) ]
591
- denyUrls = [ ...DEFAULT_DENY_URLS, ...(options.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) foregroundRequest = null
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)) foregroundVisits = Math.max(0, foregroundVisits - 1)
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"