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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84a94ffe07e5c055045a375cc92f69638618f23a169717b532cadcecc98ac95f
4
- data.tar.gz: c756cfb2bd7eff713976aaadc7e0d19375fc6747b47c6c421753dec3cc4bccdd
3
+ metadata.gz: 0cc5830bdf15d65e477e4d7acc5df46262d83737e76962dc4df5af36f6f113f8
4
+ data.tar.gz: 81abb0423f31141e915776ebcc77819b2d50e48aa13ec44c4ea11a906ad07ccf
5
5
  SHA512:
6
- metadata.gz: e971cfd7febeafd91af85f6dbf922b0d092f76bb3c77223b9d00d1b682af6c33a4e1fa50905d426dd9326b8e1d13e22e09da3c9c32e05a194c9d9e539969a206
7
- data.tar.gz: f1a8b324dcae79a81bc63fa8a319f815c0610dbaa606c5db1c41c4d5226a69812a4946cb85987aa7e7a452a098478d3f61c61b17178b2dc396d745c7e77b26f1
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
@@ -12,7 +12,7 @@ module Profiler
12
12
  job_id: job.job_id,
13
13
  queue: job.queue_name,
14
14
  arguments: job.arguments,
15
- executions: job.executions,
15
+ executions: job.executions - 1,
16
16
  &block
17
17
  )
18
18
  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,
@@ -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
  },
@@ -15,7 +15,11 @@ module Profiler
15
15
  ]
16
16
  end
17
17
 
18
- profile = Profiler.storage.load(token)
18
+ profile = if token == "latest"
19
+ Profiler.storage.list(limit: 1).first
20
+ else
21
+ Profiler.storage.load(token)
22
+ end
19
23
  unless profile
20
24
  return [
21
25
  {
@@ -10,7 +10,11 @@ module Profiler
10
10
  return [{ type: "text", text: "Error: token parameter is required" }]
11
11
  end
12
12
 
13
- profile = Profiler.storage.load(token)
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 = Profiler.storage.load(token)
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 = Profiler.storage.load(token)
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 = Profiler.storage.load(token)
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
- [{ type: "text", text: format_http(profile, http_data) }]
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 http_data["requests"] && !http_data["requests"].empty?
73
+ if requests && !requests.empty?
51
74
  lines << "## Request Details"
52
- http_data["requests"].each_with_index do |req, i|
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.3.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-01 00:00:00.000000000 Z
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