rails-profiler 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler-toolbar.js +57 -0
- data/app/assets/builds/profiler.js +62 -2
- data/lib/profiler/collectors/i18n_collector.rb +100 -0
- data/lib/profiler/instrumentation/active_job_instrumentation.rb +1 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +7 -0
- data/lib/profiler/mcp/server.rb +11 -10
- data/lib/profiler/mcp/tools/analyze_queries.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +23 -1
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_http.rb +48 -7
- data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
- data/lib/profiler/models/profile.rb +8 -0
- data/lib/profiler/railtie.rb +2 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cc5830bdf15d65e477e4d7acc5df46262d83737e76962dc4df5af36f6f113f8
|
|
4
|
+
data.tar.gz: 81abb0423f31141e915776ebcc77819b2d50e48aa13ec44c4ea11a906ad07ccf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1be16066652bfa11550b5d277d3370eec2457d08dfbb0ceab46ebafe12a484cc342c61892133b030ab4b6d7d1506bcbe53ff8118eaa9ee4ee759c36012a0058e
|
|
7
|
+
data.tar.gz: a72957818f9d2a7dbdd914f57167ffd0bcfb6eef7df3dfb721a7a651f3acd89536d11cec4dbfa7c47f72f3331862b3279eb0312a7c66a0fbb47bb36e35c1640c
|
|
@@ -946,6 +946,46 @@
|
|
|
946
946
|
] });
|
|
947
947
|
}
|
|
948
948
|
|
|
949
|
+
// app/assets/typescript/profiler/components/toolbar/panels/I18nPanel.tsx
|
|
950
|
+
function I18nPanel({ i18nData }) {
|
|
951
|
+
const missing = i18nData.lookups.filter((l3) => l3.missing);
|
|
952
|
+
const ok = i18nData.lookups.filter((l3) => !l3.missing);
|
|
953
|
+
const prioritized = [...missing, ...ok].slice(0, 5);
|
|
954
|
+
const remaining = i18nData.total - 5;
|
|
955
|
+
return /* @__PURE__ */ u3(k, { children: [
|
|
956
|
+
/* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-header", children: [
|
|
957
|
+
"I18n",
|
|
958
|
+
/* @__PURE__ */ u3("span", { style: "margin-left:6px;font-weight:400;color:var(--profiler-muted,#6b7280);", children: [
|
|
959
|
+
"[",
|
|
960
|
+
i18nData.locale,
|
|
961
|
+
"]"
|
|
962
|
+
] }),
|
|
963
|
+
i18nData.missing_count > 0 && /* @__PURE__ */ u3("span", { style: "color:var(--profiler-error,#ef4444);margin-left:8px;", children: [
|
|
964
|
+
i18nData.missing_count,
|
|
965
|
+
" missing"
|
|
966
|
+
] })
|
|
967
|
+
] }),
|
|
968
|
+
/* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-content", children: [
|
|
969
|
+
prioritized.map((entry, i3) => /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", style: "align-items:flex-start;gap:6px;", children: [
|
|
970
|
+
/* @__PURE__ */ u3(
|
|
971
|
+
"span",
|
|
972
|
+
{
|
|
973
|
+
class: "profiler-text--xs profiler-text--truncate",
|
|
974
|
+
style: `flex:1;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;${entry.missing ? "color:var(--profiler-error,#ef4444);" : ""}`,
|
|
975
|
+
children: entry.key
|
|
976
|
+
}
|
|
977
|
+
),
|
|
978
|
+
entry.missing && /* @__PURE__ */ u3("span", { class: "profiler-text--xs", style: "color:var(--profiler-error,#ef4444);font-weight:600;", children: "\u26A0" })
|
|
979
|
+
] }, i3)),
|
|
980
|
+
remaining > 0 && /* @__PURE__ */ u3("div", { class: "profiler-more", children: [
|
|
981
|
+
"+ ",
|
|
982
|
+
remaining,
|
|
983
|
+
" more keys"
|
|
984
|
+
] })
|
|
985
|
+
] })
|
|
986
|
+
] });
|
|
987
|
+
}
|
|
988
|
+
|
|
949
989
|
// app/assets/typescript/profiler/components/toolbar/ToolbarApp.tsx
|
|
950
990
|
function statusClass2(status) {
|
|
951
991
|
if (status >= 200 && status < 300) return "profiler-text--success";
|
|
@@ -979,6 +1019,7 @@
|
|
|
979
1019
|
const logData = cd["logs"];
|
|
980
1020
|
const exceptionData = cd["exception"];
|
|
981
1021
|
const routesData = cd["routes"];
|
|
1022
|
+
const i18nData = cd["i18n"];
|
|
982
1023
|
const reqClass = statusClass2(profile.status);
|
|
983
1024
|
const durClass = durationClass(profile.duration);
|
|
984
1025
|
const dbClass = (dbData?.slow_queries ?? 0) > 0 ? "profiler-text--error" : "profiler-text--success";
|
|
@@ -1186,6 +1227,22 @@
|
|
|
1186
1227
|
]
|
|
1187
1228
|
}
|
|
1188
1229
|
),
|
|
1230
|
+
i18nData && i18nData.total > 0 && /* @__PURE__ */ u3(
|
|
1231
|
+
ToolbarItem,
|
|
1232
|
+
{
|
|
1233
|
+
href: `/_profiler/profiles/${token}?tab=i18n`,
|
|
1234
|
+
className: i18nData.missing_count > 0 ? "profiler-text--error" : "profiler-text--muted",
|
|
1235
|
+
panel: /* @__PURE__ */ u3(I18nPanel, { i18nData }),
|
|
1236
|
+
children: [
|
|
1237
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "I18N" }),
|
|
1238
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--mono", children: i18nData.locale }),
|
|
1239
|
+
i18nData.missing_count > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--error profiler-text--xs", children: [
|
|
1240
|
+
"\u26A0 ",
|
|
1241
|
+
i18nData.missing_count
|
|
1242
|
+
] })
|
|
1243
|
+
]
|
|
1244
|
+
}
|
|
1245
|
+
),
|
|
1189
1246
|
/* @__PURE__ */ u3("a", { href: "/_profiler", class: "profiler-toolbar-item", children: "\u2B21 Profiler" })
|
|
1190
1247
|
] });
|
|
1191
1248
|
}
|
|
@@ -2419,6 +2419,63 @@
|
|
|
2419
2419
|
] });
|
|
2420
2420
|
}
|
|
2421
2421
|
|
|
2422
|
+
// app/assets/typescript/profiler/components/dashboard/tabs/I18nTab.tsx
|
|
2423
|
+
function I18nTab({ i18nData }) {
|
|
2424
|
+
const [filter, setFilter] = d2("ALL");
|
|
2425
|
+
if (!i18nData?.lookups?.length) {
|
|
2426
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
|
|
2427
|
+
/* @__PURE__ */ u3("div", { class: "profiler-empty__icon", children: "\u{1F310}" }),
|
|
2428
|
+
/* @__PURE__ */ u3("h3", { class: "profiler-empty__title", children: "No translation lookups captured" }),
|
|
2429
|
+
/* @__PURE__ */ u3("div", { class: "profiler-empty__description", children: /* @__PURE__ */ u3("p", { children: [
|
|
2430
|
+
"Calls to ",
|
|
2431
|
+
/* @__PURE__ */ u3("code", { children: "I18n.t" }),
|
|
2432
|
+
" during this request will appear here."
|
|
2433
|
+
] }) })
|
|
2434
|
+
] });
|
|
2435
|
+
}
|
|
2436
|
+
const filtered = filter === "MISSING" ? i18nData.lookups.filter((l3) => l3.missing) : i18nData.lookups;
|
|
2437
|
+
return /* @__PURE__ */ u3(k, { children: [
|
|
2438
|
+
/* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: [
|
|
2439
|
+
"I18n Lookups (",
|
|
2440
|
+
i18nData.total,
|
|
2441
|
+
")"
|
|
2442
|
+
] }),
|
|
2443
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-4 profiler-mb-4 profiler-text--sm", children: [
|
|
2444
|
+
/* @__PURE__ */ u3("span", { children: [
|
|
2445
|
+
"Locale: ",
|
|
2446
|
+
/* @__PURE__ */ u3("strong", { children: i18nData.locale })
|
|
2447
|
+
] }),
|
|
2448
|
+
i18nData.missing_count > 0 && /* @__PURE__ */ u3("span", { children: [
|
|
2449
|
+
"Missing: ",
|
|
2450
|
+
/* @__PURE__ */ u3("strong", { class: "profiler-text--error", children: i18nData.missing_count })
|
|
2451
|
+
] })
|
|
2452
|
+
] }),
|
|
2453
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2 profiler-mb-4", children: ["ALL", "MISSING"].map((f4) => /* @__PURE__ */ u3(
|
|
2454
|
+
"button",
|
|
2455
|
+
{
|
|
2456
|
+
onClick: () => setFilter(f4),
|
|
2457
|
+
class: `btn btn-sm ${filter === f4 ? "btn-primary" : "btn-secondary"}`,
|
|
2458
|
+
children: f4
|
|
2459
|
+
},
|
|
2460
|
+
f4
|
|
2461
|
+
)) }),
|
|
2462
|
+
filtered.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-text--muted profiler-text--sm", children: "No missing translations." }) : /* @__PURE__ */ u3("table", { class: "profiler-table", children: [
|
|
2463
|
+
/* @__PURE__ */ u3("thead", { children: /* @__PURE__ */ u3("tr", { children: [
|
|
2464
|
+
/* @__PURE__ */ u3("th", { children: "Key" }),
|
|
2465
|
+
/* @__PURE__ */ u3("th", { children: "Locale" }),
|
|
2466
|
+
/* @__PURE__ */ u3("th", { children: "Value" }),
|
|
2467
|
+
/* @__PURE__ */ u3("th", { children: "Status" })
|
|
2468
|
+
] }) }),
|
|
2469
|
+
/* @__PURE__ */ u3("tbody", { children: filtered.map((entry, index) => /* @__PURE__ */ u3("tr", { style: entry.missing ? "background:var(--profiler-error-bg,rgba(239,68,68,0.08));" : "", children: [
|
|
2470
|
+
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("code", { class: `profiler-text--xs profiler-text--mono${entry.missing ? " profiler-text--error" : ""}`, children: entry.key }) }),
|
|
2471
|
+
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: entry.locale }) }),
|
|
2472
|
+
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: `profiler-text--xs${entry.missing ? " profiler-text--error" : ""}`, children: entry.value }) }),
|
|
2473
|
+
/* @__PURE__ */ u3("td", { children: entry.missing ? /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--error", style: "font-weight:600;", children: "\u26A0 missing" }) : /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--success", children: "\u2713" }) })
|
|
2474
|
+
] }, index)) })
|
|
2475
|
+
] })
|
|
2476
|
+
] });
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2422
2479
|
// app/assets/typescript/profiler/components/dashboard/ProfileDashboard.tsx
|
|
2423
2480
|
function ProfileDashboard({ profile, initialTab, embedded }) {
|
|
2424
2481
|
const cd = profile.collectors_data || {};
|
|
@@ -2427,6 +2484,7 @@
|
|
|
2427
2484
|
const hasException = !!cd["exception"]?.exception_class;
|
|
2428
2485
|
const hasLogs = (cd["logs"]?.count ?? 0) > 0;
|
|
2429
2486
|
const hasRoutes = (cd["routes"]?.total ?? 0) > 0;
|
|
2487
|
+
const hasI18n = (cd["i18n"]?.total ?? 0) > 0;
|
|
2430
2488
|
const [activeTab, setActiveTab] = d2(hasException ? "exception" : initialTab);
|
|
2431
2489
|
const handleTabClick = (tab) => (e3) => {
|
|
2432
2490
|
e3.preventDefault();
|
|
@@ -2480,7 +2538,8 @@
|
|
|
2480
2538
|
/* @__PURE__ */ u3("a", { href: "#", class: tabClass("views"), onClick: handleTabClick("views"), children: "Views" }),
|
|
2481
2539
|
/* @__PURE__ */ u3("a", { href: "#", class: tabClass("cache"), onClick: handleTabClick("cache"), children: "Cache" }),
|
|
2482
2540
|
hasLogs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("logs"), onClick: handleTabClick("logs"), children: "Logs" }),
|
|
2483
|
-
hasRoutes && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("routes"), onClick: handleTabClick("routes"), children: "Routes" })
|
|
2541
|
+
hasRoutes && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("routes"), onClick: handleTabClick("routes"), children: "Routes" }),
|
|
2542
|
+
hasI18n && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("i18n"), onClick: handleTabClick("i18n"), children: "I18n" })
|
|
2484
2543
|
] }),
|
|
2485
2544
|
/* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
|
|
2486
2545
|
activeTab === "exception" && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
|
|
@@ -2493,7 +2552,8 @@
|
|
|
2493
2552
|
activeTab === "views" && /* @__PURE__ */ u3(ViewsTab, { viewData: cd["view"] }),
|
|
2494
2553
|
activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
|
|
2495
2554
|
activeTab === "logs" && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
|
|
2496
|
-
activeTab === "routes" && /* @__PURE__ */ u3(RoutesTab, { routesData: cd["routes"] })
|
|
2555
|
+
activeTab === "routes" && /* @__PURE__ */ u3(RoutesTab, { routesData: cd["routes"] }),
|
|
2556
|
+
activeTab === "i18n" && /* @__PURE__ */ u3(I18nTab, { i18nData: cd["i18n"] })
|
|
2497
2557
|
] })
|
|
2498
2558
|
] }),
|
|
2499
2559
|
!embedded && /* @__PURE__ */ u3("div", { class: "profiler-mt-6", children: /* @__PURE__ */ u3("a", { href: "/_profiler", style: "color: var(--profiler-accent);", children: "\u2190 Back to profiles" }) })
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module I18nLookupTracker
|
|
7
|
+
def translate(key, **options)
|
|
8
|
+
result = super
|
|
9
|
+
if (collector = Thread.current[:profiler_i18n_collector])
|
|
10
|
+
missing = result.is_a?(String) && result.downcase.include?("translation missing:")
|
|
11
|
+
collector.record_lookup(key, options[:locale] || I18n.locale, result, missing)
|
|
12
|
+
end
|
|
13
|
+
result
|
|
14
|
+
rescue I18n::MissingTranslationData => e
|
|
15
|
+
if (collector = Thread.current[:profiler_i18n_collector])
|
|
16
|
+
collector.record_lookup(key, options[:locale] || I18n.locale, nil, true)
|
|
17
|
+
end
|
|
18
|
+
raise
|
|
19
|
+
end
|
|
20
|
+
alias_method :t, :translate
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Collectors
|
|
24
|
+
class I18nCollector < BaseCollector
|
|
25
|
+
def initialize(profile)
|
|
26
|
+
super
|
|
27
|
+
@lookups = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def icon
|
|
31
|
+
"🌐"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def priority
|
|
35
|
+
45
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tab_config
|
|
39
|
+
{
|
|
40
|
+
key: "i18n",
|
|
41
|
+
label: "I18n",
|
|
42
|
+
icon: icon,
|
|
43
|
+
priority: priority,
|
|
44
|
+
enabled: true,
|
|
45
|
+
default_active: false
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def subscribe
|
|
50
|
+
return unless defined?(I18n)
|
|
51
|
+
|
|
52
|
+
unless I18n.singleton_class.ancestors.include?(Profiler::I18nLookupTracker)
|
|
53
|
+
I18n.singleton_class.prepend(Profiler::I18nLookupTracker)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Thread.current[:profiler_i18n_collector] = self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def collect
|
|
60
|
+
Thread.current[:profiler_i18n_collector] = nil
|
|
61
|
+
|
|
62
|
+
missing_count = @lookups.count { |l| l[:missing] }
|
|
63
|
+
|
|
64
|
+
store_data(
|
|
65
|
+
locale: I18n.locale.to_s,
|
|
66
|
+
total: @lookups.size,
|
|
67
|
+
missing_count: missing_count,
|
|
68
|
+
lookups: @lookups
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def record_lookup(key, locale, result, missing = false)
|
|
73
|
+
value = missing ? "[missing]" : truncate(result.to_s)
|
|
74
|
+
|
|
75
|
+
@lookups << {
|
|
76
|
+
key: key.to_s,
|
|
77
|
+
locale: locale.to_s,
|
|
78
|
+
value: value,
|
|
79
|
+
missing: missing
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def toolbar_summary
|
|
84
|
+
missing = @lookups.count { |l| l[:missing] }
|
|
85
|
+
locale = I18n.locale.to_s
|
|
86
|
+
total = @lookups.size
|
|
87
|
+
|
|
88
|
+
color = missing > 0 ? "red" : "green"
|
|
89
|
+
|
|
90
|
+
{ text: "#{locale} · #{total} keys", color: color }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def truncate(str, max = 100)
|
|
96
|
+
str.length > max ? "#{str[0, max]}…" : str
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "base64"
|
|
5
5
|
require "zlib"
|
|
6
6
|
require "stringio"
|
|
7
|
+
require "securerandom"
|
|
7
8
|
|
|
8
9
|
module Profiler
|
|
9
10
|
module Instrumentation
|
|
@@ -22,6 +23,8 @@ module Profiler
|
|
|
22
23
|
url = build_url(host, port, req.path, use_ssl?)
|
|
23
24
|
req_body = req.body.to_s
|
|
24
25
|
req_headers = req.to_hash.transform_values { |v| v.join(", ") }
|
|
26
|
+
request_id = SecureRandom.hex(8)
|
|
27
|
+
started_at = Time.now.iso8601(3)
|
|
25
28
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
29
|
Thread.current[:profiler_http_recording] = true
|
|
27
30
|
|
|
@@ -40,6 +43,8 @@ module Profiler
|
|
|
40
43
|
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
44
|
|
|
42
45
|
collector.record_request(
|
|
46
|
+
id: request_id,
|
|
47
|
+
started_at: started_at,
|
|
43
48
|
url: url,
|
|
44
49
|
method: req.method,
|
|
45
50
|
status: response.code.to_i,
|
|
@@ -63,6 +68,8 @@ module Profiler
|
|
|
63
68
|
if defined?(t0) && t0
|
|
64
69
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
65
70
|
collector&.record_request(
|
|
71
|
+
id: defined?(request_id) ? request_id : SecureRandom.hex(8),
|
|
72
|
+
started_at: defined?(started_at) ? started_at : Time.now.iso8601(3),
|
|
66
73
|
url: url,
|
|
67
74
|
method: req.method,
|
|
68
75
|
status: 0,
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -85,10 +85,10 @@ module Profiler
|
|
|
85
85
|
),
|
|
86
86
|
define_tool(
|
|
87
87
|
name: "get_profile",
|
|
88
|
-
description: "Get detailed profile data by token",
|
|
88
|
+
description: "Get detailed profile data by token. Use 'latest' as token to get the most recent profile.",
|
|
89
89
|
input_schema: {
|
|
90
90
|
properties: {
|
|
91
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
91
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
92
92
|
},
|
|
93
93
|
required: ["token"]
|
|
94
94
|
},
|
|
@@ -96,10 +96,10 @@ module Profiler
|
|
|
96
96
|
),
|
|
97
97
|
define_tool(
|
|
98
98
|
name: "analyze_queries",
|
|
99
|
-
description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries",
|
|
99
|
+
description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries. Use 'latest' as token to analyze the most recent profile.",
|
|
100
100
|
input_schema: {
|
|
101
101
|
properties: {
|
|
102
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
102
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
103
103
|
},
|
|
104
104
|
required: ["token"]
|
|
105
105
|
},
|
|
@@ -107,10 +107,10 @@ module Profiler
|
|
|
107
107
|
),
|
|
108
108
|
define_tool(
|
|
109
109
|
name: "get_profile_ajax",
|
|
110
|
-
description: "Get detailed AJAX sub-request breakdown for a profile",
|
|
110
|
+
description: "Get detailed AJAX sub-request breakdown for a profile. Use 'latest' as token to get the most recent profile.",
|
|
111
111
|
input_schema: {
|
|
112
112
|
properties: {
|
|
113
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
113
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
114
114
|
},
|
|
115
115
|
required: ["token"]
|
|
116
116
|
},
|
|
@@ -118,10 +118,10 @@ module Profiler
|
|
|
118
118
|
),
|
|
119
119
|
define_tool(
|
|
120
120
|
name: "get_profile_dumps",
|
|
121
|
-
description: "Get variable dumps captured during a profile",
|
|
121
|
+
description: "Get variable dumps captured during a profile. Use 'latest' as token to get the most recent profile.",
|
|
122
122
|
input_schema: {
|
|
123
123
|
properties: {
|
|
124
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
124
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
125
125
|
},
|
|
126
126
|
required: ["token"]
|
|
127
127
|
},
|
|
@@ -129,10 +129,11 @@ module Profiler
|
|
|
129
129
|
),
|
|
130
130
|
define_tool(
|
|
131
131
|
name: "get_profile_http",
|
|
132
|
-
description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request)",
|
|
132
|
+
description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request). Use 'latest' as token to get the most recent profile.",
|
|
133
133
|
input_schema: {
|
|
134
134
|
properties: {
|
|
135
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
135
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
136
|
+
domain: { type: "string", description: "Filter outbound requests by domain (partial match on host)" }
|
|
136
137
|
},
|
|
137
138
|
required: ["token"]
|
|
138
139
|
},
|
|
@@ -10,7 +10,11 @@ module Profiler
|
|
|
10
10
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
profile =
|
|
13
|
+
profile = if token == "latest"
|
|
14
|
+
Profiler.storage.list(limit: 1).first
|
|
15
|
+
else
|
|
16
|
+
Profiler.storage.load(token)
|
|
17
|
+
end
|
|
14
18
|
unless profile
|
|
15
19
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
20
|
end
|
|
@@ -18,7 +18,11 @@ module Profiler
|
|
|
18
18
|
]
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
profile =
|
|
21
|
+
profile = if token == "latest"
|
|
22
|
+
Profiler.storage.list(limit: 1).first
|
|
23
|
+
else
|
|
24
|
+
Profiler.storage.load(token)
|
|
25
|
+
end
|
|
22
26
|
unless profile
|
|
23
27
|
return [
|
|
24
28
|
{
|
|
@@ -49,6 +53,24 @@ module Profiler
|
|
|
49
53
|
lines << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
|
|
50
54
|
lines << "**Time:** #{profile.started_at}\n"
|
|
51
55
|
|
|
56
|
+
# Exception section
|
|
57
|
+
exception_data = profile.collector_data("exception")
|
|
58
|
+
if exception_data && exception_data["exception_class"]
|
|
59
|
+
lines << "## Exception"
|
|
60
|
+
lines << "**Class:** #{exception_data['exception_class']}"
|
|
61
|
+
lines << "**Message:** #{exception_data['message']}\n"
|
|
62
|
+
|
|
63
|
+
backtrace = exception_data["backtrace"]
|
|
64
|
+
if backtrace && !backtrace.empty?
|
|
65
|
+
lines << "### Backtrace"
|
|
66
|
+
backtrace.first(20).each do |frame|
|
|
67
|
+
marker = frame["app_frame"] ? "★ " : " "
|
|
68
|
+
lines << "#{marker}#{frame['location']}"
|
|
69
|
+
end
|
|
70
|
+
lines << ""
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
52
74
|
# Job section
|
|
53
75
|
job_data = profile.collector_data("job")
|
|
54
76
|
if job_data && job_data["job_class"]
|
|
@@ -10,7 +10,11 @@ module Profiler
|
|
|
10
10
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
profile =
|
|
13
|
+
profile = if token == "latest"
|
|
14
|
+
Profiler.storage.list(limit: 1).first
|
|
15
|
+
else
|
|
16
|
+
Profiler.storage.load(token)
|
|
17
|
+
end
|
|
14
18
|
unless profile
|
|
15
19
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
20
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
3
6
|
module Profiler
|
|
4
7
|
module MCP
|
|
5
8
|
module Tools
|
|
@@ -10,7 +13,11 @@ module Profiler
|
|
|
10
13
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
14
|
end
|
|
12
15
|
|
|
13
|
-
profile =
|
|
16
|
+
profile = if token == "latest"
|
|
17
|
+
Profiler.storage.list(limit: 1).first
|
|
18
|
+
else
|
|
19
|
+
Profiler.storage.load(token)
|
|
20
|
+
end
|
|
14
21
|
unless profile
|
|
15
22
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
23
|
end
|
|
@@ -20,39 +27,57 @@ module Profiler
|
|
|
20
27
|
return [{ type: "text", text: "No outbound HTTP requests found in this profile" }]
|
|
21
28
|
end
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
domain_filter = params["domain"]
|
|
31
|
+
[{ type: "text", text: format_http(profile, http_data, domain_filter) }]
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
private
|
|
27
35
|
|
|
28
|
-
def self.format_http(profile, http_data)
|
|
36
|
+
def self.format_http(profile, http_data, domain_filter)
|
|
29
37
|
threshold = Profiler.configuration.slow_http_threshold
|
|
38
|
+
requests = http_data["requests"] || []
|
|
39
|
+
|
|
40
|
+
if domain_filter && !domain_filter.empty?
|
|
41
|
+
requests = requests.select do |req|
|
|
42
|
+
host = begin
|
|
43
|
+
URI.parse(req["url"]).host.to_s
|
|
44
|
+
rescue URI::InvalidURIError
|
|
45
|
+
""
|
|
46
|
+
end
|
|
47
|
+
host.include?(domain_filter)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
30
51
|
lines = []
|
|
31
52
|
lines << "# Outbound HTTP Analysis: #{profile.token}\n"
|
|
32
53
|
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
54
|
+
lines << "**Started at:** #{profile.started_at&.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
33
55
|
lines << "**Total Outbound Requests:** #{http_data['total_requests']}"
|
|
56
|
+
lines << "**Showing:** #{requests.size} request(s)#{domain_filter ? " (filtered by domain: #{domain_filter})" : ""}"
|
|
34
57
|
lines << "**Total Duration:** #{http_data['total_duration'].round(2)} ms"
|
|
35
58
|
lines << "**Slow Requests (>#{threshold}ms):** #{http_data['slow_requests']}"
|
|
36
59
|
lines << "**Error Requests:** #{http_data['error_requests']}\n"
|
|
37
60
|
|
|
38
|
-
if http_data["by_host"] && !http_data["by_host"].empty?
|
|
61
|
+
if http_data["by_host"] && !http_data["by_host"].empty? && !domain_filter
|
|
39
62
|
lines << "## By Host"
|
|
40
63
|
http_data["by_host"].each { |host, count| lines << "- **#{host}**: #{count}" }
|
|
41
64
|
lines << ""
|
|
42
65
|
end
|
|
43
66
|
|
|
44
|
-
if http_data["by_status"] && !http_data["by_status"].empty?
|
|
67
|
+
if http_data["by_status"] && !http_data["by_status"].empty? && !domain_filter
|
|
45
68
|
lines << "## By Status"
|
|
46
69
|
http_data["by_status"].each { |status, count| lines << "- **#{status}**: #{count}" }
|
|
47
70
|
lines << ""
|
|
48
71
|
end
|
|
49
72
|
|
|
50
|
-
if
|
|
73
|
+
if requests && !requests.empty?
|
|
51
74
|
lines << "## Request Details"
|
|
52
|
-
|
|
75
|
+
requests.each_with_index do |req, i|
|
|
53
76
|
slow_flag = req["duration"] >= threshold ? " [SLOW]" : ""
|
|
54
77
|
err_flag = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
|
|
55
78
|
lines << "\n### Request #{i + 1}#{slow_flag}#{err_flag}"
|
|
79
|
+
lines << "- **ID:** #{req['id']}" if req["id"]
|
|
80
|
+
lines << "- **Started at:** #{req['started_at']}" if req["started_at"]
|
|
56
81
|
lines << "- **Method:** #{req['method']}"
|
|
57
82
|
lines << "- **URL:** #{req['url']}"
|
|
58
83
|
lines << "- **Status:** #{req['status'] == 0 ? 'connection error' : req['status']}"
|
|
@@ -92,12 +117,28 @@ module Profiler
|
|
|
92
117
|
lines << "- **Called from:**"
|
|
93
118
|
req["backtrace"].first(3).each { |frame| lines << " - #{frame}" }
|
|
94
119
|
end
|
|
120
|
+
lines << "- **Curl:**"
|
|
121
|
+
lines << " ```bash"
|
|
122
|
+
lines << " #{generate_curl_for_outbound(req)}"
|
|
123
|
+
lines << " ```"
|
|
95
124
|
end
|
|
96
125
|
lines << ""
|
|
97
126
|
end
|
|
98
127
|
|
|
99
128
|
lines.join("\n")
|
|
100
129
|
end
|
|
130
|
+
|
|
131
|
+
def self.generate_curl_for_outbound(req)
|
|
132
|
+
parts = ["curl -X #{req['method']}"]
|
|
133
|
+
req["request_headers"]
|
|
134
|
+
&.reject { |k, _| k.downcase == "user-agent" }
|
|
135
|
+
&.each { |k, v| parts << " -H #{Shellwords.shellescape("#{k}: #{v}")}" }
|
|
136
|
+
if req["request_body"] && !req["request_body"].empty? && req["request_body_encoding"] != "base64"
|
|
137
|
+
parts << " -d #{Shellwords.shellescape(req['request_body'])}"
|
|
138
|
+
end
|
|
139
|
+
parts << " #{Shellwords.shellescape(req['url'])}"
|
|
140
|
+
parts.join(" \\\n")
|
|
141
|
+
end
|
|
101
142
|
end
|
|
102
143
|
end
|
|
103
144
|
end
|
|
@@ -49,7 +49,7 @@ module Profiler
|
|
|
49
49
|
job_class = job_data["job_class"] || profile.path
|
|
50
50
|
queue = job_data["queue"] || "-"
|
|
51
51
|
status = job_data["status"] || "-"
|
|
52
|
-
lines << "| #{profile.started_at.strftime('%H:%M:%S')} | #{job_class} | #{queue} | #{status} | #{profile.duration.round(2)}ms | #{profile.token} |"
|
|
52
|
+
lines << "| #{profile.started_at.strftime('%Y-%m-%d %H:%M:%S')} | #{job_class} | #{queue} | #{status} | #{profile.duration.round(2)}ms | #{profile.token} |"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
lines.join("\n")
|
|
@@ -55,7 +55,7 @@ module Profiler
|
|
|
55
55
|
query_count = db_data ? db_data["total_queries"] : 0
|
|
56
56
|
type = profile.profile_type || "http"
|
|
57
57
|
|
|
58
|
-
lines << "| #{profile.started_at.strftime('%H:%M:%S')} | #{type} | #{profile.method} | #{profile.path} | #{profile.duration.round(2)}ms | #{query_count} | #{profile.status} | #{profile.token} |"
|
|
58
|
+
lines << "| #{profile.started_at.strftime('%Y-%m-%d %H:%M:%S')} | #{type} | #{profile.method} | #{profile.path} | #{profile.duration.round(2)}ms | #{query_count} | #{profile.status} | #{profile.token} |"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
lines.join("\n")
|
|
@@ -173,9 +173,17 @@ module Profiler
|
|
|
173
173
|
params.to_h.except("password", "password_confirmation", "token", "secret")
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
ALLOWED_HEADERS = %w[
|
|
177
|
+
Accept Accept-Charset Accept-Encoding Accept-Language
|
|
178
|
+
Authorization Cache-Control Connection Content-Length Content-Type
|
|
179
|
+
Cookie Host If-Modified-Since If-None-Match Origin
|
|
180
|
+
Referer User-Agent
|
|
181
|
+
].freeze
|
|
182
|
+
|
|
176
183
|
def extract_headers(env)
|
|
177
184
|
env.select { |k, _| k.start_with?("HTTP_") }
|
|
178
185
|
.transform_keys { |k| k.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-") }
|
|
186
|
+
.select { |k, _| ALLOWED_HEADERS.include?(k) }
|
|
179
187
|
end
|
|
180
188
|
end
|
|
181
189
|
end
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -45,7 +45,8 @@ module Profiler
|
|
|
45
45
|
Profiler::Collectors::HttpCollector,
|
|
46
46
|
Profiler::Collectors::FlameGraphCollector,
|
|
47
47
|
Profiler::Collectors::LogCollector,
|
|
48
|
-
Profiler::Collectors::RoutesCollector
|
|
48
|
+
Profiler::Collectors::RoutesCollector,
|
|
49
|
+
Profiler::Collectors::I18nCollector
|
|
49
50
|
]
|
|
50
51
|
end
|
|
51
52
|
end
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -66,6 +66,7 @@ require_relative "profiler/collectors/flamegraph_collector"
|
|
|
66
66
|
require_relative "profiler/collectors/log_collector"
|
|
67
67
|
require_relative "profiler/collectors/exception_collector"
|
|
68
68
|
require_relative "profiler/collectors/routes_collector"
|
|
69
|
+
require_relative "profiler/collectors/i18n_collector"
|
|
69
70
|
|
|
70
71
|
require_relative "profiler/railtie" if defined?(Rails::Railtie)
|
|
71
72
|
require_relative "profiler/engine" if defined?(Rails::Engine)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -129,6 +129,7 @@ files:
|
|
|
129
129
|
- lib/profiler/collectors/exception_collector.rb
|
|
130
130
|
- lib/profiler/collectors/flamegraph_collector.rb
|
|
131
131
|
- lib/profiler/collectors/http_collector.rb
|
|
132
|
+
- lib/profiler/collectors/i18n_collector.rb
|
|
132
133
|
- lib/profiler/collectors/job_collector.rb
|
|
133
134
|
- lib/profiler/collectors/log_collector.rb
|
|
134
135
|
- lib/profiler/collectors/performance_collector.rb
|