railwatch 0.4.0 → 0.5.1

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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -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/requests_controller.rb +1 -19
  9. data/app/controllers/railwatch/tenants_controller.rb +2 -2
  10. data/app/controllers/railwatch/thresholds_controller.rb +8 -1
  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/execution_presenter.rb +1 -1
  14. data/app/models/railwatch/filter_query.rb +20 -0
  15. data/app/models/railwatch/saved_view.rb +1 -1
  16. data/app/models/railwatch/telemetry/aggregations.rb +154 -19
  17. data/app/models/railwatch/telemetry/exception.rb +7 -0
  18. data/app/models/railwatch/telemetry/execution.rb +18 -0
  19. data/app/models/railwatch/telemetry/log.rb +6 -0
  20. data/app/models/railwatch/telemetry/query.rb +7 -0
  21. data/app/models/railwatch/telemetry/release_health.rb +12 -6
  22. data/app/models/railwatch/telemetry/rollup.rb +17 -2
  23. data/app/models/railwatch/telemetry/tenant.rb +8 -23
  24. data/app/models/railwatch/telemetry_record.rb +8 -6
  25. data/app/models/railwatch/threshold.rb +41 -4
  26. data/app/models/railwatch/window.rb +66 -0
  27. data/docs/embedded.md +11 -0
  28. data/lib/railwatch/version.rb +1 -1
  29. data/lib/railwatch.rb +12 -0
  30. data/public/railwatch/assets/app-layout-DDyQa72H.js +1 -0
  31. data/public/railwatch/assets/{app-wordmark-DDNuuq5_.js → app-wordmark-o9CODKP0.js} +1 -1
  32. data/public/railwatch/assets/{appearance-C3LMdsDy.js → appearance-BwuCXabr.js} +1 -1
  33. data/public/railwatch/assets/application-B7h1MIhi.css +1 -0
  34. data/public/railwatch/assets/{arrow-up-DYTCBENO.js → arrow-up-C6PxDiY3.js} +1 -1
  35. data/public/railwatch/assets/{auth-layout-CzPRawuI.js → auth-layout-BRt8MGFD.js} +1 -1
  36. data/public/railwatch/assets/{badge-s7YDycYg.js → badge-CAxXV8za.js} +1 -1
  37. data/public/railwatch/assets/{braces-BFrg-bvp.js → braces-DgomTCNf.js} +1 -1
  38. data/public/railwatch/assets/{card-DiW-JhK3.js → card-cAtqCxWl.js} +1 -1
  39. data/public/railwatch/assets/{chart-nsdZV0Dv.js → chart-BBeBkkNa.js} +1 -1
  40. data/public/railwatch/assets/{chart-hover-DMWx5-Rs.js → chart-hover-B1M9jc0y.js} +1 -1
  41. data/public/railwatch/assets/chart-panel-DUQTz_C8.js +1 -0
  42. data/public/railwatch/assets/{checkbox-C2AqEZXX.js → checkbox-CmhMHWZO.js} +1 -1
  43. data/public/railwatch/assets/{code-DVJtYP7h.js → code-DESvxyTj.js} +1 -1
  44. data/public/railwatch/assets/{copy-block-D1myf7is.js → copy-block-BkSU5832.js} +1 -1
  45. data/public/railwatch/assets/{copy-id-CyRCMfdH.js → copy-id-D03GhN9F.js} +1 -1
  46. data/public/railwatch/assets/{cursor-load-more-JfSVBn9C.js → cursor-load-more-CRyuMeQb.js} +1 -1
  47. data/public/railwatch/assets/{data-table-CwtPkw_i.js → data-table-BIlt7Rtm.js} +1 -1
  48. data/public/railwatch/assets/{edit-fB83T9Yh.js → edit-Bb6MKoe4.js} +1 -1
  49. data/public/railwatch/assets/{edit-BvTugIpj.js → edit-DJ0D0wHN.js} +1 -1
  50. data/public/railwatch/assets/{edit-DvpDUM-t.js → edit-O0NSBWxo.js} +1 -1
  51. data/public/railwatch/assets/{empty-state-CshhuRUU.js → empty-state-C38il627.js} +1 -1
  52. data/public/railwatch/assets/env-layout-REF7OM4q.js +1 -0
  53. data/public/railwatch/assets/{execution-path-CdEerUGH.js → execution-path-FYLq1TwC.js} +1 -1
  54. data/public/railwatch/assets/{filter-bar-BMHf8xBb.js → filter-bar-CYog9Alp.js} +1 -1
  55. data/public/railwatch/assets/{flamegraph-uYWk1Yov.js → flamegraph-DSs69foN.js} +1 -1
  56. data/public/railwatch/assets/{frames-B5ZECkvt.js → frames-BUi2J5Mk.js} +1 -1
  57. data/public/railwatch/assets/{google-sign-in-button-CAvsXYb4.js → google-sign-in-button-BQiIKFdd.js} +1 -1
  58. data/public/railwatch/assets/{index-X13K9BbO.js → index-8-hnAhOD.js} +1 -1
  59. data/public/railwatch/assets/{index-Czq2bi1U.js → index-BaR1U9An.js} +1 -1
  60. data/public/railwatch/assets/{index-iVNm3uzI.js → index-BeOh2t_S.js} +1 -1
  61. data/public/railwatch/assets/{index-vC9KU2bt.js → index-BiiyMcA0.js} +1 -1
  62. data/public/railwatch/assets/{index-B-qRsj0W.js → index-BoUBioBP.js} +1 -1
  63. data/public/railwatch/assets/{index-B4IRsZXK.js → index-C-PmdhXA.js} +1 -1
  64. data/public/railwatch/assets/{index-fA7h22qS.js → index-C3A_9imx.js} +1 -1
  65. data/public/railwatch/assets/{index-DcuyMiuG.js → index-C7OtLq_3.js} +1 -1
  66. data/public/railwatch/assets/{index-BMgA9NNc.js → index-CFFpnzIS.js} +1 -1
  67. data/public/railwatch/assets/{index-CAkCcAty.js → index-CFRLPs4J.js} +1 -1
  68. data/public/railwatch/assets/{index-DMcsT2ES.js → index-CGs4m_fa.js} +1 -1
  69. data/public/railwatch/assets/{index-DuGGTdIJ.js → index-CICUIFHL.js} +1 -1
  70. data/public/railwatch/assets/{index-DVUwgfqP.js → index-C_upSl_k.js} +1 -1
  71. data/public/railwatch/assets/{index-CaJvo0Oc.js → index-CiPo4Gob.js} +1 -1
  72. data/public/railwatch/assets/{index-BIom4V2g.js → index-CpkI015n.js} +1 -1
  73. data/public/railwatch/assets/{index-Zf9xXwq7.js → index-CrZ3vHDL.js} +1 -1
  74. data/public/railwatch/assets/{index-ClJtZh5Q.js → index-CsoN51vW.js} +1 -1
  75. data/public/railwatch/assets/{index-1-78ZewP.js → index-DDI_Zx5V.js} +1 -1
  76. data/public/railwatch/assets/index-DSvlZVWG.js +1 -0
  77. data/public/railwatch/assets/{index-C7bU7oUF.js → index-DW2CBbxU.js} +1 -1
  78. data/public/railwatch/assets/{index-DzoUj38Q.js → index-Dh4IRLFI.js} +1 -1
  79. data/public/railwatch/assets/{index-CdM3F3jz.js → index-DqTFTP8p.js} +1 -1
  80. data/public/railwatch/assets/index-DrcKVG2f.js +1 -0
  81. data/public/railwatch/assets/{index-BIZsdfdM.js → index-DtHmuB9Q.js} +1 -1
  82. data/public/railwatch/assets/{index-QxAKxnHK.js → index-DvjY3dPD.js} +1 -1
  83. data/public/railwatch/assets/{index-dKn1tKSX.js → index-FhUaPPab.js} +1 -1
  84. data/public/railwatch/assets/{index-DYrF-A96.js → index-JdCVBrw8.js} +1 -1
  85. data/public/railwatch/assets/{index-DWMKU6mG.js → index-QpTtwFwu.js} +1 -1
  86. data/public/railwatch/assets/{index-Bw_ByIps.js → index-ZOGOB8SA.js} +1 -1
  87. data/public/railwatch/assets/{index-D-O1LUPq.js → index-ZSZg9rtq.js} +1 -1
  88. data/public/railwatch/assets/{index-ClG89uia.js → index-r0tSIplE.js} +1 -1
  89. data/public/railwatch/assets/{index-DJIXGkfB.js → index-sTYvcbkh.js} +1 -1
  90. data/public/railwatch/assets/{index-n16aviHx.js → index-so4lRrRq.js} +1 -1
  91. data/public/railwatch/assets/{index-CRShH-fR.js → index-tpz-OGUP.js} +1 -1
  92. data/public/railwatch/assets/{index-BpnfuXi-.js → index-umIAl-pL.js} +1 -1
  93. data/public/railwatch/assets/{inertia-CUTKXjxg.js → inertia-DLew8ZNx.js} +2 -2
  94. data/public/railwatch/assets/{input-error-C8ycyeD0.js → input-error-cvM6_Jht.js} +1 -1
  95. data/public/railwatch/assets/{json-viewer-CJECqZ0l.js → json-viewer-D922McGi.js} +1 -1
  96. data/public/railwatch/assets/{klass-Br9dng39.js → klass-CrwICqN8.js} +1 -1
  97. data/public/railwatch/assets/{label-B32Ks8n7.js → label-GWl7I6sf.js} +1 -1
  98. data/public/railwatch/assets/{layout-5ZhRtywp.js → layout-0ZAnD3zl.js} +1 -1
  99. data/public/railwatch/assets/{live-dot-Bb1UYT9F.js → live-dot-D1n_BreY.js} +1 -1
  100. data/public/railwatch/assets/{nav-XUcwnBso.js → nav-DPxr1NNC.js} +1 -1
  101. data/public/railwatch/assets/{new-0xyEF6NL.js → new-Cdl6pqST.js} +1 -1
  102. data/public/railwatch/assets/{new-GIkDYLt8.js → new-D-ZzUK9a.js} +1 -1
  103. data/public/railwatch/assets/{new-CdvlEBjJ.js → new-DEVkYv-z.js} +1 -1
  104. data/public/railwatch/assets/{new-YBWOo9_V.js → new-DHAHDrN7.js} +1 -1
  105. data/public/railwatch/assets/{new-CV011uLi.js → new-Dz4lZf1L.js} +1 -1
  106. data/public/railwatch/assets/{new-D9-ORiNW.js → new-GMrRFurX.js} +1 -1
  107. data/public/railwatch/assets/{onboarding-BqEuP-Ml.js → onboarding-D1vwaHYT.js} +1 -1
  108. data/public/railwatch/assets/{origin-identity-DrJ4D2TX.js → origin-identity-Bk9yHWZ1.js} +1 -1
  109. data/public/railwatch/assets/{percentile-picker-_wllqYoJ.js → percentile-picker-DfSx9yJO.js} +1 -1
  110. data/public/railwatch/assets/{relative-time-CV7V8aSP.js → relative-time-CjIjb8Lg.js} +1 -1
  111. data/public/railwatch/assets/{release-health-DRNLZtT7.js → release-health-4b3tivEf.js} +1 -1
  112. data/public/railwatch/assets/{route-D9slUz_a.js → route-C_5BUtHK.js} +1 -1
  113. data/public/railwatch/assets/{segmented-Bj70Htrs.js → segmented-BgbT3wZa.js} +1 -1
  114. data/public/railwatch/assets/{select-DJBZX2ky.js → select-DmunxCKE.js} +1 -1
  115. data/public/railwatch/assets/{separator-UUo0bkyc.js → separator-BXzEdZ_8.js} +1 -1
  116. data/public/railwatch/assets/series-chart-Xf49v9cv.js +1 -0
  117. data/public/railwatch/assets/{show-BQtEcTeD.js → show-B2zLAW83.js} +1 -1
  118. data/public/railwatch/assets/{show-DR6u4CR7.js → show-BLpWUHWD.js} +1 -1
  119. data/public/railwatch/assets/{show-Pu3Iehw5.js → show-CAl7xcex.js} +1 -1
  120. data/public/railwatch/assets/{show-DoCg0ifP.js → show-DI8IhNUH.js} +1 -1
  121. data/public/railwatch/assets/{show-BT3tKCgP.js → show-DSP9Cq_C.js} +2 -2
  122. data/public/railwatch/assets/{show-C0uC3gnA.js → show-DXs4deaC.js} +1 -1
  123. data/public/railwatch/assets/{show-D7tP881O.js → show-DYskfl3-.js} +1 -1
  124. data/public/railwatch/assets/{show-BpyCRL-I.js → show-DcpTFiLi.js} +1 -1
  125. data/public/railwatch/assets/{show-yQipmite.js → show-Dily73Xk.js} +1 -1
  126. data/public/railwatch/assets/{show-COfP8yWt.js → show-DlRVS18-.js} +1 -1
  127. data/public/railwatch/assets/{show-ChDJZjoj.js → show-Dn-GwFZL.js} +1 -1
  128. data/public/railwatch/assets/{show-BoG7jcwk.js → show-DnR1Dnjd.js} +1 -1
  129. data/public/railwatch/assets/{show-DnBCvlnd.js → show-SHwZjXb7.js} +1 -1
  130. data/public/railwatch/assets/{show-CefJwag7.js → show-SvLOcPrx.js} +1 -1
  131. data/public/railwatch/assets/{show-D7IL22Hy.js → show-Y74rM0VT.js} +1 -1
  132. data/public/railwatch/assets/{show-TTCk2Met.js → show-mU38uGTg.js} +1 -1
  133. data/public/railwatch/assets/{show-GruxlCov.js → show-vQ4bndYD.js} +1 -1
  134. data/public/railwatch/assets/{sort-header-CqQQhaxg.js → sort-header-Dcq9bzmo.js} +1 -1
  135. data/public/railwatch/assets/{sparkline-cell-BwdBz0yv.js → sparkline-cell-BON3qQUB.js} +1 -1
  136. data/public/railwatch/assets/{stat-IghK3WjD.js → stat-DFEyFxkO.js} +1 -1
  137. data/public/railwatch/assets/{status-badge-XYUN9gUA.js → status-badge-BaUKP7Yo.js} +1 -1
  138. data/public/railwatch/assets/{tenant-path-SxnZbfU1.js → tenant-path-DPZPc985.js} +1 -1
  139. data/public/railwatch/assets/{text-link-NCL9N5D5.js → text-link-BO77t9Xk.js} +1 -1
  140. data/public/railwatch/assets/{textarea-DhI1dmQa.js → textarea-DTqrCiV0.js} +1 -1
  141. data/public/railwatch/assets/{timeline-CLAwsAlo.js → timeline-D5rJ0es2.js} +1 -1
  142. data/public/railwatch/assets/{transition-DpTb9Yiv.js → transition-DMIrZVth.js} +1 -1
  143. data/public/railwatch/assets/{use-clipboard-D5i6e5kf.js → use-clipboard-ByoUGQqA.js} +1 -1
  144. data/public/railwatch/assets/{use-live-V-slSdFJ.js → use-live-D7xKz2ma.js} +1 -1
  145. data/public/railwatch/manifest.json +1288 -1288
  146. metadata +118 -117
  147. data/public/railwatch/assets/app-layout-DUk54qUM.js +0 -1
  148. data/public/railwatch/assets/application-CwshqwM6.css +0 -1
  149. data/public/railwatch/assets/chart-panel-BlirnMrQ.js +0 -1
  150. data/public/railwatch/assets/env-layout-Dkp_4NvM.js +0 -1
  151. data/public/railwatch/assets/index-CZWzIE8F.js +0 -1
  152. data/public/railwatch/assets/index-DpWQMikK.js +0 -1
  153. data/public/railwatch/assets/series-chart-C1YrNlID.js +0 -1
  154. /data/db/railwatch_telemetry_migrate/{20260919000000_create_export_queue.rb → 20260919000100_create_export_queue.rb} +0 -0
@@ -77,6 +77,12 @@ module Railwatch
77
77
  def quote(term) = %("#{term.gsub('"', '""')}")
78
78
  end
79
79
 
80
+ def as_row
81
+ { id: id, level: level, message: message.first(2000), tags: tags, occurred_at: occurred_at, deploy: deploy,
82
+ execution_id: execution_id, execution_source: execution_source, execution_preview: execution_preview,
83
+ source: source, tenant: app_tenant, user_ref: user_ref, context: context }
84
+ end
85
+
80
86
  def timeline_label
81
87
  message.to_s.first(120)
82
88
  end
@@ -42,6 +42,13 @@ module Railwatch
42
42
  text
43
43
  end
44
44
 
45
+ def as_row
46
+ { id: id, group_hash: group_hash, sql: sql.first(2_000), name: name, duration: duration_ms.round(3), occurred_at: occurred_at,
47
+ deploy: deploy, execution_id: execution_id, execution_preview: execution_preview, source: source,
48
+ connection: connection, role: role, adapter: adapter, row_count: row_count, tenant: app_tenant, user_ref: user_ref,
49
+ explain: explain.present? }
50
+ end
51
+
45
52
  def timeline_label
46
53
  sql.to_s.first(120)
47
54
  end
@@ -36,13 +36,19 @@ module Railwatch
36
36
  }
37
37
  end
38
38
 
39
- # Hourly points for the stacked sessions-by-status chart.
40
- def self.series(deploy, from, to)
41
- between(from, to).for_release(deploy).group(:bucket).order(:bucket)
42
- .pluck(:bucket, Arel.sql("SUM(sessions)"), Arel.sql("SUM(sessions_errored)"), Arel.sql("SUM(sessions_crashed)"))
43
- .map { |bucket, sessions, errored, crashed|
44
- { t: bucket, sessions: sessions, ok: sessions - errored - crashed, errored: errored, crashed: crashed }
39
+ # Points for the stacked sessions-by-status chart, one per `step` across
40
+ # the window. The table is hourly and a session is counted once per hour
41
+ # it was seen, so a step under an hour is drawn at an hour: raw session
42
+ # rows would count a live session on every flush.
43
+ def self.series(deploy, from, to, step: nil)
44
+ step = [ Telemetry::Aggregations::STEPS.fetch(step || Telemetry::Aggregations.default_step(from, to)), 1.hour ].max
45
+ bucket = Telemetry::Aggregations.bucket_sql("bucket", step)
46
+ points = between(from, to).for_release(deploy).group(bucket).order(bucket)
47
+ .pluck(bucket, Arel.sql("SUM(sessions)"), Arel.sql("SUM(sessions_errored)"), Arel.sql("SUM(sessions_crashed)"))
48
+ .map { |b, sessions, errored, crashed|
49
+ { t: Time.at(b).utc, sessions: sessions, ok: sessions - errored - crashed, errored: errored, crashed: crashed }
45
50
  }
51
+ Telemetry::Aggregations.fill(points, from, to, step) { |t| { t: t, sessions: 0, ok: 0, errored: 0, crashed: 0 } }
46
52
  end
47
53
 
48
54
  # Each release's share of the window's sessions, as a percentage -- how
@@ -54,7 +54,7 @@ module Railwatch
54
54
  # Aggregate a relation of rollups into one summary with merged percentiles.
55
55
  def self.summarize(relation)
56
56
  rows = relation.to_a
57
- return { count: 0, errors: 0, client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0, max: 0 } if rows.empty?
57
+ return { count: 0, errors: 0, client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0, max: 0, extra: {} } if rows.empty?
58
58
  # merge! pushes the row's centroids into one accumulating digest.
59
59
  # `+` built a brand-new digest from both operands' centroids on every
60
60
  # row, so merging N rows re-pushed every earlier centroid N times:
@@ -70,9 +70,24 @@ module Railwatch
70
70
  p50: merged.percentile(0.5).to_i,
71
71
  p95: merged.percentile(0.95).to_i,
72
72
  p99: merged.percentile(0.99).to_i,
73
- max: rows.map(&:duration_max).max
73
+ max: rows.map(&:duration_max).max,
74
+ # Everything a type puts in `extra` -- llm_call's cost_nanos and
75
+ # token counts, cache_event's hits and misses -- merged the way
76
+ # absorb! merges it: numbers add up, anything else is last-wins.
77
+ # Spend is a sum over a window rather than a percentile of
78
+ # durations, so a rule about money has nowhere else to read from.
79
+ extra: merge_extras(rows)
74
80
  }
75
81
  end
82
+
83
+ def self.merge_extras(rows)
84
+ rows.each_with_object({}) do |row, out|
85
+ row.extra.each do |key, value|
86
+ existing = out[key]
87
+ out[key] = existing.is_a?(Numeric) && value.is_a?(Numeric) ? existing + value : value
88
+ end
89
+ end
90
+ end
76
91
  end
77
92
  end
78
93
  end
@@ -18,7 +18,6 @@ module Railwatch
18
18
  # aggregate), so only the busiest tenants get one; the rest report 0.
19
19
  P95_TENANTS = 50
20
20
  SPARKLINE_BUCKETS = 12
21
- SERIES_BUCKETS = 48
22
21
  SORTS = { "requests" => :requests, "errors" => :errors, "p95" => :p95, "users" => :users }.freeze
23
22
 
24
23
  ERRORS_SQL = Arel.sql("SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END)")
@@ -71,21 +70,12 @@ module Railwatch
71
70
  }
72
71
  end
73
72
 
74
- # Request volume and duration bucketed over the window, SeriesPoint-shaped
75
- # so the same charts render it. Percentiles are approximations: these
76
- # buckets come from raw rows, without the per-bucket t-digest
77
- # Telemetry::Rollup keeps, so p50 is the bucket's average and p95/p99 its
78
- # max.
79
- def self.series(tenant, from, to)
80
- width = bucket_width(from, to, SERIES_BUCKETS)
81
- bucket = bucket_sql(from, width)
82
- Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).group(bucket)
83
- .pluck(bucket, COUNT_SQL, ERRORS_SQL, CLIENT_ERRORS_SQL, Arel.sql("SUM(duration)"), Arel.sql("MAX(duration)"))
84
- .map { |index, count, errors, client_errors, sum, max|
85
- avg = ms(sum / count)
86
- { t: bucket_at(from, width, index, SERIES_BUCKETS).iso8601, count: count, errors: errors, client_errors: client_errors,
87
- avg: avg, p50: avg, p95: ms(max), p99: ms(max) }
88
- }.sort_by { |point| point[:t] }
73
+ # Request volume and duration bucketed over the window at `step`,
74
+ # SeriesPoint-shaped so the same charts render it. Rollups carry no tenant,
75
+ # so every step reads raw rows (Telemetry::Aggregations.points does that
76
+ # for a tenant), with exact percentiles per bucket.
77
+ def self.series(tenant, from, to, step: nil)
78
+ Telemetry::Aggregations.points("request", from: from, to: to, step: step, tenant: tenant)
89
79
  end
90
80
 
91
81
  def self.routes(tenant, from, to, limit: 20)
@@ -109,14 +99,9 @@ module Railwatch
109
99
  end
110
100
  end
111
101
 
112
- # Same field shape as RequestsController#execution_row, for the tenant
113
- # page's "Recent requests" table.
102
+ # The tenant page's "Recent requests" table.
114
103
  def self.recent_requests(tenant, from, to, limit: 50)
115
- Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).recent.limit(limit).map do |r|
116
- { execution_id: r.execution_id, name: r.name, status: r.status, duration: r.duration_ms.round(2), occurred_at: r.occurred_at,
117
- user_ref: r.user_ref, tenant: r.app_tenant, exception_preview: r.exception_preview, inertia_component: r.inertia_component,
118
- queries: r.counters["queries"], deploy: r.deploy }
119
- end
104
+ Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).recent.limit(limit).map(&:as_row)
120
105
  end
121
106
 
122
107
  # -- Aggregation steps -----------------------------------------------------
@@ -2,12 +2,14 @@
2
2
 
3
3
  module Railwatch
4
4
  # Base class for everything the engine stores about the host application.
5
- # The hosted platform keeps one SQLite file per monitored environment through
6
- # activerecord-tenanted; an embedded install monitors exactly one application,
7
- # so this is a plain second database (`railwatch_telemetry` in the host's
8
- # database.yml), separate from the host's own primary and from the engine's
9
- # meta tables. Every telemetry query still runs inside
10
- # Environment#with_telemetry so the code path matches the platform's.
5
+ # An embedded install monitors exactly one application, so this is a plain
6
+ # second database (`railwatch_telemetry` in the host's database.yml),
7
+ # separate from the host's own primary and from the engine's meta tables.
8
+ # The hosted platform keeps one SQLite file per monitored environment
9
+ # instead: it calls `Railwatch::TelemetryRecord.tenanted("telemetry")`
10
+ # (activerecord-tenanted) from an initializer, which replaces the
11
+ # connection below with a per-tenant one, and every telemetry query runs
12
+ # inside Environment#with_telemetry so the code path is the same in both.
11
13
  class TelemetryRecord < ActiveRecord::Base
12
14
  self.abstract_class = true
13
15
  begin
@@ -7,8 +7,22 @@ module Railwatch
7
7
  self.table_name = "railwatch_thresholds"
8
8
  MAX_LIMIT = 1_000_000_000
9
9
  MAX_WINDOW_MINUTES = 1_440
10
- TARGET_KINDS = %w[requests jobs commands queries scheduled_tasks outgoing_requests].freeze
11
- METRICS = %w[p95 max avg error_rate failure_rate missed].freeze
10
+ TARGET_KINDS = %w[requests jobs commands queries scheduled_tasks outgoing_requests
11
+ llm_calls llm_tools].freeze
12
+ METRICS = %w[p95 max avg error_rate failure_rate missed spend tokens truncation_rate].freeze
13
+
14
+ # Duration and rate metrics read the same on any timed record, so they are
15
+ # allowed everywhere. Money and tokens only mean something where a record
16
+ # carries them: a spend rule on tool calls could never fire, and a rule
17
+ # that cannot fire is worse than no rule -- it reads as coverage.
18
+ SHARED_METRICS = %w[p95 max avg error_rate failure_rate missed].freeze
19
+ LLM_METRICS = %w[spend tokens truncation_rate].freeze
20
+
21
+ # Money leads with its symbol; milliseconds and percent follow the number.
22
+ # The old code assumed two units and branched on the metric name ending in
23
+ # "rate", which cannot express a dollar sign in front.
24
+ UNITS = { "spend" => "$", "tokens" => "", "truncation_rate" => "%",
25
+ "error_rate" => "%", "failure_rate" => "%" }.freeze
12
26
 
13
27
  def environment = Environment.current
14
28
 
@@ -20,10 +34,33 @@ module Railwatch
20
34
  validates :target, presence: true, length: { maximum: 256 }
21
35
  validates :limit, numericality: { less_than_or_equal_to: 100 }, if: -> { metric&.end_with?("rate") }
22
36
  validates :target, uniqueness: { scope: %i[environment_id target_kind metric] }
37
+ validate :metric_applies_to_target_kind
38
+
39
+ def self.metrics_for(target_kind)
40
+ target_kind == "llm_calls" ? SHARED_METRICS + LLM_METRICS : SHARED_METRICS
41
+ end
42
+
43
+ def unit = UNITS.fetch(metric, "ms")
44
+
45
+ # "$4.50", "2000ms", "5%", "150000" -- the symbol's position is part of
46
+ # the unit, not something a caller should have to know.
47
+ def format_value(value)
48
+ return "$#{value.is_a?(Float) ? format('%.2f', value) : value}" if unit == "$"
49
+
50
+ "#{value}#{unit}"
51
+ end
23
52
 
24
53
  def description
25
- unit = metric.end_with?("rate") ? "%" : "ms"
26
- "#{target_kind} #{target == '*' ? 'all' : target}: #{metric} over #{limit}#{unit} in #{window_minutes}m"
54
+ "#{target_kind} #{target == '*' ? 'all' : target}: #{metric} over #{format_value(limit)} in #{window_minutes}m"
55
+ end
56
+
57
+ private
58
+
59
+ def metric_applies_to_target_kind
60
+ return if metric.blank? || target_kind.blank?
61
+ return if Threshold.metrics_for(target_kind).include?(metric)
62
+
63
+ errors.add(:metric, "is not available for #{target_kind}")
27
64
  end
28
65
  end
29
66
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # The time range a telemetry page, API call, or MCP tool looks at: one of the
5
+ # fixed presets ("1h" .. "30d") ending now, or a custom [from, to] pair. The
6
+ # dashboard, the JSON API, and MCP all resolve their ?window= / "window"
7
+ # argument through here so the presets and the default agree everywhere.
8
+ class Window
9
+ PRESETS = { "1h" => 1.hour, "6h" => 6.hours, "24h" => 24.hours, "7d" => 7.days, "30d" => 30.days }.freeze
10
+ DEFAULT = "24h"
11
+ MAX_CUSTOM_RANGE = 90.days
12
+
13
+ attr_reader :key, :from, :to
14
+
15
+ # A preset key, or anything else for the default.
16
+ def self.preset(key)
17
+ key = PRESETS.key?(key.to_s) ? key.to_s : DEFAULT
18
+ to = Time.current
19
+ new(key, to - PRESETS[key], to)
20
+ end
21
+
22
+ # A custom range from ?from=&to= when both parse, are in order, and span
23
+ # no more than MAX_CUSTOM_RANGE; otherwise the preset (or default).
24
+ def self.parse(window: nil, from: nil, to: nil)
25
+ custom(from, to) || preset(window)
26
+ end
27
+
28
+ def self.custom(from, to)
29
+ from = parse_time(from) or return
30
+ to = to.blank? ? Time.current : parse_time(to) or return
31
+ new("custom", from, to) if to > from && (to - from) <= MAX_CUSTOM_RANGE
32
+ end
33
+
34
+ def self.parse_time(value)
35
+ return if value.blank?
36
+ Time.zone.parse(value.to_s)
37
+ rescue ArgumentError, TypeError
38
+ nil
39
+ end
40
+ private_class_method :parse_time
41
+
42
+ def initialize(key, from, to)
43
+ @key = key
44
+ @from = from
45
+ @to = to
46
+ end
47
+
48
+ def custom? = key == "custom"
49
+ def range = [ from, to ]
50
+ def span = to - from
51
+
52
+ # The same length immediately before this one, for "vs. previous period".
53
+ def previous
54
+ self.class.new(key, from - span, from)
55
+ end
56
+
57
+ def to_h
58
+ { from: from.iso8601(6), to: to.iso8601(6) }
59
+ end
60
+
61
+ # Query params that reproduce this window on another page.
62
+ def to_params
63
+ custom? ? to_h : { window: key }
64
+ end
65
+ end
66
+ end
data/docs/embedded.md CHANGED
@@ -44,6 +44,17 @@ an embedded install that has one configured still sends nothing.
44
44
  fails loudly if you ask for it and it cannot work. `railwatch:export:status`
45
45
  shows what is queued.
46
46
 
47
+ ## Railwatch Cloud runs the same models
48
+
49
+ The hosted platform is this engine's telemetry layer plus what hosting adds
50
+ (accounts, tokens, quotas, alert routing). It uses `Railwatch::Telemetry::*`,
51
+ `Railwatch::Ingest::Batch`, `Railwatch::Telemetry::Aggregations` and the rest
52
+ directly, with two seams: it tenants `Railwatch::TelemetryRecord` so each
53
+ monitored environment gets its own database, and its `Environment` is an
54
+ account-scoped Active Record row rather than the embedded singleton. Anything
55
+ those models need from an environment is the surface `Railwatch::Environment`
56
+ documents: `id`, `slug`, `name`, `with_telemetry`, and the display attributes.
57
+
47
58
  ## Install
48
59
 
49
60
  ```sh
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/railwatch.rb CHANGED
@@ -599,6 +599,18 @@ module Railwatch
599
599
  File.expand_path("../db/#{database}_migrate", __dir__)
600
600
  end
601
601
 
602
+ # Where links the models build (a trace from an execution page, a saved
603
+ # view's page) resolve. The engine's own routes serve the embedded
604
+ # dashboard; a host that mounts these pages on its own routes, as
605
+ # Railwatch Cloud does, points this at them.
606
+ def self.url_helpers
607
+ @url_helpers || Engine.routes.url_helpers
608
+ end
609
+
610
+ def self.url_helpers=(helpers)
611
+ @url_helpers = helpers
612
+ end
613
+
602
614
  # The one browser client, relative to the gem root. The dashboard bundle
603
615
  # is built from it, the install generator copies it, the gem ships it (the
604
616
  # only app/frontend file it does), and a host with its own frontend build