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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -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/requests_controller.rb +1 -19
- data/app/controllers/railwatch/tenants_controller.rb +2 -2
- data/app/controllers/railwatch/thresholds_controller.rb +8 -1
- data/app/jobs/railwatch/detect_performance_issues_job.rb +12 -2
- data/app/models/railwatch/anomaly_rule.rb +20 -2
- data/app/models/railwatch/execution_presenter.rb +1 -1
- data/app/models/railwatch/filter_query.rb +20 -0
- data/app/models/railwatch/saved_view.rb +1 -1
- 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/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +12 -0
- data/public/railwatch/assets/app-layout-DDyQa72H.js +1 -0
- data/public/railwatch/assets/{app-wordmark-DDNuuq5_.js → app-wordmark-o9CODKP0.js} +1 -1
- data/public/railwatch/assets/{appearance-C3LMdsDy.js → appearance-BwuCXabr.js} +1 -1
- data/public/railwatch/assets/application-B7h1MIhi.css +1 -0
- data/public/railwatch/assets/{arrow-up-DYTCBENO.js → arrow-up-C6PxDiY3.js} +1 -1
- data/public/railwatch/assets/{auth-layout-CzPRawuI.js → auth-layout-BRt8MGFD.js} +1 -1
- data/public/railwatch/assets/{badge-s7YDycYg.js → badge-CAxXV8za.js} +1 -1
- data/public/railwatch/assets/{braces-BFrg-bvp.js → braces-DgomTCNf.js} +1 -1
- data/public/railwatch/assets/{card-DiW-JhK3.js → card-cAtqCxWl.js} +1 -1
- data/public/railwatch/assets/{chart-nsdZV0Dv.js → chart-BBeBkkNa.js} +1 -1
- data/public/railwatch/assets/{chart-hover-DMWx5-Rs.js → chart-hover-B1M9jc0y.js} +1 -1
- data/public/railwatch/assets/chart-panel-DUQTz_C8.js +1 -0
- data/public/railwatch/assets/{checkbox-C2AqEZXX.js → checkbox-CmhMHWZO.js} +1 -1
- data/public/railwatch/assets/{code-DVJtYP7h.js → code-DESvxyTj.js} +1 -1
- data/public/railwatch/assets/{copy-block-D1myf7is.js → copy-block-BkSU5832.js} +1 -1
- data/public/railwatch/assets/{copy-id-CyRCMfdH.js → copy-id-D03GhN9F.js} +1 -1
- data/public/railwatch/assets/{cursor-load-more-JfSVBn9C.js → cursor-load-more-CRyuMeQb.js} +1 -1
- data/public/railwatch/assets/{data-table-CwtPkw_i.js → data-table-BIlt7Rtm.js} +1 -1
- data/public/railwatch/assets/{edit-fB83T9Yh.js → edit-Bb6MKoe4.js} +1 -1
- data/public/railwatch/assets/{edit-BvTugIpj.js → edit-DJ0D0wHN.js} +1 -1
- data/public/railwatch/assets/{edit-DvpDUM-t.js → edit-O0NSBWxo.js} +1 -1
- data/public/railwatch/assets/{empty-state-CshhuRUU.js → empty-state-C38il627.js} +1 -1
- data/public/railwatch/assets/env-layout-REF7OM4q.js +1 -0
- data/public/railwatch/assets/{execution-path-CdEerUGH.js → execution-path-FYLq1TwC.js} +1 -1
- data/public/railwatch/assets/{filter-bar-BMHf8xBb.js → filter-bar-CYog9Alp.js} +1 -1
- data/public/railwatch/assets/{flamegraph-uYWk1Yov.js → flamegraph-DSs69foN.js} +1 -1
- data/public/railwatch/assets/{frames-B5ZECkvt.js → frames-BUi2J5Mk.js} +1 -1
- data/public/railwatch/assets/{google-sign-in-button-CAvsXYb4.js → google-sign-in-button-BQiIKFdd.js} +1 -1
- data/public/railwatch/assets/{index-X13K9BbO.js → index-8-hnAhOD.js} +1 -1
- data/public/railwatch/assets/{index-Czq2bi1U.js → index-BaR1U9An.js} +1 -1
- data/public/railwatch/assets/{index-iVNm3uzI.js → index-BeOh2t_S.js} +1 -1
- data/public/railwatch/assets/{index-vC9KU2bt.js → index-BiiyMcA0.js} +1 -1
- data/public/railwatch/assets/{index-B-qRsj0W.js → index-BoUBioBP.js} +1 -1
- data/public/railwatch/assets/{index-B4IRsZXK.js → index-C-PmdhXA.js} +1 -1
- data/public/railwatch/assets/{index-fA7h22qS.js → index-C3A_9imx.js} +1 -1
- data/public/railwatch/assets/{index-DcuyMiuG.js → index-C7OtLq_3.js} +1 -1
- data/public/railwatch/assets/{index-BMgA9NNc.js → index-CFFpnzIS.js} +1 -1
- data/public/railwatch/assets/{index-CAkCcAty.js → index-CFRLPs4J.js} +1 -1
- data/public/railwatch/assets/{index-DMcsT2ES.js → index-CGs4m_fa.js} +1 -1
- data/public/railwatch/assets/{index-DuGGTdIJ.js → index-CICUIFHL.js} +1 -1
- data/public/railwatch/assets/{index-DVUwgfqP.js → index-C_upSl_k.js} +1 -1
- data/public/railwatch/assets/{index-CaJvo0Oc.js → index-CiPo4Gob.js} +1 -1
- data/public/railwatch/assets/{index-BIom4V2g.js → index-CpkI015n.js} +1 -1
- data/public/railwatch/assets/{index-Zf9xXwq7.js → index-CrZ3vHDL.js} +1 -1
- data/public/railwatch/assets/{index-ClJtZh5Q.js → index-CsoN51vW.js} +1 -1
- data/public/railwatch/assets/{index-1-78ZewP.js → index-DDI_Zx5V.js} +1 -1
- data/public/railwatch/assets/index-DSvlZVWG.js +1 -0
- data/public/railwatch/assets/{index-C7bU7oUF.js → index-DW2CBbxU.js} +1 -1
- data/public/railwatch/assets/{index-DzoUj38Q.js → index-Dh4IRLFI.js} +1 -1
- data/public/railwatch/assets/{index-CdM3F3jz.js → index-DqTFTP8p.js} +1 -1
- data/public/railwatch/assets/index-DrcKVG2f.js +1 -0
- data/public/railwatch/assets/{index-BIZsdfdM.js → index-DtHmuB9Q.js} +1 -1
- data/public/railwatch/assets/{index-QxAKxnHK.js → index-DvjY3dPD.js} +1 -1
- data/public/railwatch/assets/{index-dKn1tKSX.js → index-FhUaPPab.js} +1 -1
- data/public/railwatch/assets/{index-DYrF-A96.js → index-JdCVBrw8.js} +1 -1
- data/public/railwatch/assets/{index-DWMKU6mG.js → index-QpTtwFwu.js} +1 -1
- data/public/railwatch/assets/{index-Bw_ByIps.js → index-ZOGOB8SA.js} +1 -1
- data/public/railwatch/assets/{index-D-O1LUPq.js → index-ZSZg9rtq.js} +1 -1
- data/public/railwatch/assets/{index-ClG89uia.js → index-r0tSIplE.js} +1 -1
- data/public/railwatch/assets/{index-DJIXGkfB.js → index-sTYvcbkh.js} +1 -1
- data/public/railwatch/assets/{index-n16aviHx.js → index-so4lRrRq.js} +1 -1
- data/public/railwatch/assets/{index-CRShH-fR.js → index-tpz-OGUP.js} +1 -1
- data/public/railwatch/assets/{index-BpnfuXi-.js → index-umIAl-pL.js} +1 -1
- data/public/railwatch/assets/{inertia-CUTKXjxg.js → inertia-DLew8ZNx.js} +2 -2
- data/public/railwatch/assets/{input-error-C8ycyeD0.js → input-error-cvM6_Jht.js} +1 -1
- data/public/railwatch/assets/{json-viewer-CJECqZ0l.js → json-viewer-D922McGi.js} +1 -1
- data/public/railwatch/assets/{klass-Br9dng39.js → klass-CrwICqN8.js} +1 -1
- data/public/railwatch/assets/{label-B32Ks8n7.js → label-GWl7I6sf.js} +1 -1
- data/public/railwatch/assets/{layout-5ZhRtywp.js → layout-0ZAnD3zl.js} +1 -1
- data/public/railwatch/assets/{live-dot-Bb1UYT9F.js → live-dot-D1n_BreY.js} +1 -1
- data/public/railwatch/assets/{nav-XUcwnBso.js → nav-DPxr1NNC.js} +1 -1
- data/public/railwatch/assets/{new-0xyEF6NL.js → new-Cdl6pqST.js} +1 -1
- data/public/railwatch/assets/{new-GIkDYLt8.js → new-D-ZzUK9a.js} +1 -1
- data/public/railwatch/assets/{new-CdvlEBjJ.js → new-DEVkYv-z.js} +1 -1
- data/public/railwatch/assets/{new-YBWOo9_V.js → new-DHAHDrN7.js} +1 -1
- data/public/railwatch/assets/{new-CV011uLi.js → new-Dz4lZf1L.js} +1 -1
- data/public/railwatch/assets/{new-D9-ORiNW.js → new-GMrRFurX.js} +1 -1
- data/public/railwatch/assets/{onboarding-BqEuP-Ml.js → onboarding-D1vwaHYT.js} +1 -1
- data/public/railwatch/assets/{origin-identity-DrJ4D2TX.js → origin-identity-Bk9yHWZ1.js} +1 -1
- data/public/railwatch/assets/{percentile-picker-_wllqYoJ.js → percentile-picker-DfSx9yJO.js} +1 -1
- data/public/railwatch/assets/{relative-time-CV7V8aSP.js → relative-time-CjIjb8Lg.js} +1 -1
- data/public/railwatch/assets/{release-health-DRNLZtT7.js → release-health-4b3tivEf.js} +1 -1
- data/public/railwatch/assets/{route-D9slUz_a.js → route-C_5BUtHK.js} +1 -1
- data/public/railwatch/assets/{segmented-Bj70Htrs.js → segmented-BgbT3wZa.js} +1 -1
- data/public/railwatch/assets/{select-DJBZX2ky.js → select-DmunxCKE.js} +1 -1
- data/public/railwatch/assets/{separator-UUo0bkyc.js → separator-BXzEdZ_8.js} +1 -1
- data/public/railwatch/assets/series-chart-Xf49v9cv.js +1 -0
- data/public/railwatch/assets/{show-BQtEcTeD.js → show-B2zLAW83.js} +1 -1
- data/public/railwatch/assets/{show-DR6u4CR7.js → show-BLpWUHWD.js} +1 -1
- data/public/railwatch/assets/{show-Pu3Iehw5.js → show-CAl7xcex.js} +1 -1
- data/public/railwatch/assets/{show-DoCg0ifP.js → show-DI8IhNUH.js} +1 -1
- data/public/railwatch/assets/{show-BT3tKCgP.js → show-DSP9Cq_C.js} +2 -2
- data/public/railwatch/assets/{show-C0uC3gnA.js → show-DXs4deaC.js} +1 -1
- data/public/railwatch/assets/{show-D7tP881O.js → show-DYskfl3-.js} +1 -1
- data/public/railwatch/assets/{show-BpyCRL-I.js → show-DcpTFiLi.js} +1 -1
- data/public/railwatch/assets/{show-yQipmite.js → show-Dily73Xk.js} +1 -1
- data/public/railwatch/assets/{show-COfP8yWt.js → show-DlRVS18-.js} +1 -1
- data/public/railwatch/assets/{show-ChDJZjoj.js → show-Dn-GwFZL.js} +1 -1
- data/public/railwatch/assets/{show-BoG7jcwk.js → show-DnR1Dnjd.js} +1 -1
- data/public/railwatch/assets/{show-DnBCvlnd.js → show-SHwZjXb7.js} +1 -1
- data/public/railwatch/assets/{show-CefJwag7.js → show-SvLOcPrx.js} +1 -1
- data/public/railwatch/assets/{show-D7IL22Hy.js → show-Y74rM0VT.js} +1 -1
- data/public/railwatch/assets/{show-TTCk2Met.js → show-mU38uGTg.js} +1 -1
- data/public/railwatch/assets/{show-GruxlCov.js → show-vQ4bndYD.js} +1 -1
- data/public/railwatch/assets/{sort-header-CqQQhaxg.js → sort-header-Dcq9bzmo.js} +1 -1
- data/public/railwatch/assets/{sparkline-cell-BwdBz0yv.js → sparkline-cell-BON3qQUB.js} +1 -1
- data/public/railwatch/assets/{stat-IghK3WjD.js → stat-DFEyFxkO.js} +1 -1
- data/public/railwatch/assets/{status-badge-XYUN9gUA.js → status-badge-BaUKP7Yo.js} +1 -1
- data/public/railwatch/assets/{tenant-path-SxnZbfU1.js → tenant-path-DPZPc985.js} +1 -1
- data/public/railwatch/assets/{text-link-NCL9N5D5.js → text-link-BO77t9Xk.js} +1 -1
- data/public/railwatch/assets/{textarea-DhI1dmQa.js → textarea-DTqrCiV0.js} +1 -1
- data/public/railwatch/assets/{timeline-CLAwsAlo.js → timeline-D5rJ0es2.js} +1 -1
- data/public/railwatch/assets/{transition-DpTb9Yiv.js → transition-DMIrZVth.js} +1 -1
- data/public/railwatch/assets/{use-clipboard-D5i6e5kf.js → use-clipboard-ByoUGQqA.js} +1 -1
- data/public/railwatch/assets/{use-live-V-slSdFJ.js → use-live-D7xKz2ma.js} +1 -1
- data/public/railwatch/manifest.json +1288 -1288
- metadata +118 -117
- data/public/railwatch/assets/app-layout-DUk54qUM.js +0 -1
- data/public/railwatch/assets/application-CwshqwM6.css +0 -1
- data/public/railwatch/assets/chart-panel-BlirnMrQ.js +0 -1
- data/public/railwatch/assets/env-layout-Dkp_4NvM.js +0 -1
- data/public/railwatch/assets/index-CZWzIE8F.js +0 -1
- data/public/railwatch/assets/index-DpWQMikK.js +0 -1
- data/public/railwatch/assets/series-chart-C1YrNlID.js +0 -1
- /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
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
75
|
-
# so the same charts render it.
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
data/lib/railwatch/version.rb
CHANGED
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
|