rails-profiler 0.22.0 → 0.23.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: b7bf5cab3285946189ba9adee630f79ad7303ae591aa4364e0fe4d7af320bab9
4
- data.tar.gz: 4c2ad13691c8c6556355c2c4b2c5934ef2d6aab9d956e2f30d16d92098cd5997
3
+ metadata.gz: 893d3965287ddebc00378c605ff16d13734a4fd97c7016d2c613c0eb3c6b709b
4
+ data.tar.gz: 9574f00d346286e72d4d47abdab4d60520053b5f16b6f16c701ba178c24ab4ce
5
5
  SHA512:
6
- metadata.gz: 1c846fa98d00dd5e6c5cc5ef8ca443b6f2b02664b9fdee77479bdbf6013d38cdf359e892ba6f08721b54fa5fb13d9b8663b909d7f17f97a849402654c4bf3821
7
- data.tar.gz: 1697e1bfe8842df4d41aed3a1c5e84b8090d510fab6ee9622afc44e326553c13843a5252b74b34d5d6bb4195629f3d05330953b5527ef7a596aae89bf9a36b11
6
+ metadata.gz: 1e0f22f1aa90112910678954253843a3d4bb245029a829359e195f99dc9e5bbc87c0f43d0333959dfa00f7d612cd83fb111c81cdb63767435751a2de26eae4bd
7
+ data.tar.gz: '058f8f1228758f62939eb97ee0b6124b737c053d00634971df9905bc7732c61936ece855280c14da509cc1bee981ef6a7185bc3c72652d3a2b816b97f9628765'
@@ -1664,17 +1664,14 @@
1664
1664
  const [jobHasMore, setJobHasMore] = d2(false);
1665
1665
  const [jobLoadingMore, setJobLoadingMore] = d2(false);
1666
1666
  const [outboundRequests, setOutboundRequests] = d2([]);
1667
- const [loadingHttp, setLoadingHttp] = d2(true);
1668
- const [loadingJobs, setLoadingJobs] = d2(false);
1669
- const [loadingOutbound, setLoadingOutbound] = d2(false);
1670
- const [jobsLoaded, setJobsLoaded] = d2(false);
1671
- const [outboundLoaded, setOutboundLoaded] = d2(false);
1667
+ const [loadingHttp, setLoadingHttp] = d2(initialSection() === "http");
1668
+ const [loadingJobs, setLoadingJobs] = d2(initialSection() === "jobs");
1669
+ const [loadingOutbound, setLoadingOutbound] = d2(initialSection() === "outbound");
1672
1670
  const [error, setError] = d2(null);
1673
1671
  const [jobsError, setJobsError] = d2(null);
1674
1672
  const [outboundError, setOutboundError] = d2(null);
1675
1673
  const [envData, setEnvData] = d2(void 0);
1676
- const [loadingEnv, setLoadingEnv] = d2(false);
1677
- const [envLoaded, setEnvLoaded] = d2(false);
1674
+ const [loadingEnv, setLoadingEnv] = d2(initialSection() === "env");
1678
1675
  const [envError, setEnvError] = d2(null);
1679
1676
  const [copiedToken, setCopiedToken] = d2(null);
1680
1677
  const [httpSearch, setHttpSearch] = d2("");
@@ -1694,20 +1691,7 @@
1694
1691
  const [outboundMethod, setOutboundMethod] = d2("");
1695
1692
  const [outboundStatus, setOutboundStatus] = d2("");
1696
1693
  y2(() => {
1697
- fetch(`${BASE}/api/profiles?limit=50&offset=0`).then((res) => res.json()).then((data) => {
1698
- setProfiles(data.profiles);
1699
- setHttpOffset(data.profiles.length);
1700
- setHttpHasMore(data.has_more);
1701
- setLoadingHttp(false);
1702
- }).catch(() => {
1703
- setError("Failed to load profiles");
1704
- setLoadingHttp(false);
1705
- });
1706
- }, []);
1707
- y2(() => {
1708
- if (section === "jobs") loadJobs();
1709
- if (section === "outbound") loadOutbound();
1710
- if (section === "env") loadEnv();
1694
+ refreshSection(section);
1711
1695
  }, []);
1712
1696
  y2(() => {
1713
1697
  const url = new URL(window.location.href);
@@ -1747,21 +1731,6 @@
1747
1731
  setHttpLoadingMore(false);
1748
1732
  }).catch(() => setHttpLoadingMore(false));
1749
1733
  };
1750
- const loadJobs = () => {
1751
- if (jobsLoaded) return;
1752
- setLoadingJobs(true);
1753
- fetch(`${BASE}/api/jobs?limit=50&offset=0`).then((res) => res.json()).then((data) => {
1754
- setJobs(data.profiles);
1755
- setJobOffset(data.profiles.length);
1756
- setJobHasMore(data.has_more);
1757
- setLoadingJobs(false);
1758
- setJobsLoaded(true);
1759
- }).catch(() => {
1760
- setJobsError("Failed to load job profiles");
1761
- setLoadingJobs(false);
1762
- setJobsLoaded(true);
1763
- });
1764
- };
1765
1734
  const loadMoreJobs = () => {
1766
1735
  setJobLoadingMore(true);
1767
1736
  fetch(`${BASE}/api/jobs?limit=50&offset=${jobOffset}`).then((res) => res.json()).then((data) => {
@@ -1771,40 +1740,16 @@
1771
1740
  setJobLoadingMore(false);
1772
1741
  }).catch(() => setJobLoadingMore(false));
1773
1742
  };
1774
- const loadOutbound = () => {
1775
- if (outboundLoaded) return;
1776
- setLoadingOutbound(true);
1777
- fetch(`${BASE}/api/outbound_http`).then((res) => res.json()).then((data) => {
1778
- setOutboundRequests(data);
1779
- setLoadingOutbound(false);
1780
- setOutboundLoaded(true);
1781
- }).catch(() => {
1782
- setOutboundError("Failed to load outbound HTTP requests");
1783
- setLoadingOutbound(false);
1784
- setOutboundLoaded(true);
1785
- });
1786
- };
1787
- const loadEnv = () => {
1788
- if (envLoaded) return;
1789
- setLoadingEnv(true);
1790
- fetch(`${BASE}/api/env_vars`).then((res) => res.json()).then((data) => {
1791
- setEnvData(data);
1792
- setLoadingEnv(false);
1793
- setEnvLoaded(true);
1794
- }).catch(() => {
1795
- setEnvError("Failed to load environment variables");
1796
- setLoadingEnv(false);
1797
- setEnvLoaded(true);
1798
- });
1799
- };
1800
1743
  const handleSectionChange = (s3) => {
1744
+ if (s3 === section) {
1745
+ refreshSection(s3);
1746
+ return;
1747
+ }
1801
1748
  setSection(s3);
1802
1749
  const url = new URL(window.location.href);
1803
1750
  url.searchParams.set("section", s3);
1804
1751
  history.pushState(null, "", url.toString());
1805
- if (s3 === "jobs") loadJobs();
1806
- if (s3 === "outbound") loadOutbound();
1807
- if (s3 === "env") loadEnv();
1752
+ refreshSection(s3);
1808
1753
  setHttpSearch("");
1809
1754
  setHttpMethod("");
1810
1755
  setHttpStatus("");
@@ -1836,17 +1781,29 @@
1836
1781
  });
1837
1782
  };
1838
1783
  const clearProfiles = () => {
1784
+ if (!window.confirm("Delete all HTTP profiles?")) return;
1839
1785
  fetch(`${BASE}/api/profiles/clear`, { method: "DELETE" }).then(() => {
1840
1786
  setProfiles([]);
1841
1787
  });
1842
1788
  };
1843
1789
  const clearJobs = () => {
1790
+ if (!window.confirm("Delete all job profiles?")) return;
1844
1791
  fetch(`${BASE}/api/jobs/clear`, { method: "DELETE" }).then(() => {
1845
1792
  setJobs([]);
1846
1793
  });
1847
1794
  };
1848
- const refresh = () => {
1849
- if (section === "http") {
1795
+ const clearAll = () => {
1796
+ if (!window.confirm("Delete all HTTP and job profiles?")) return;
1797
+ Promise.all([
1798
+ fetch(`${BASE}/api/profiles/clear`, { method: "DELETE" }),
1799
+ fetch(`${BASE}/api/jobs/clear`, { method: "DELETE" })
1800
+ ]).then(() => {
1801
+ setProfiles([]);
1802
+ setJobs([]);
1803
+ });
1804
+ };
1805
+ const refreshSection = (s3) => {
1806
+ if (s3 === "http") {
1850
1807
  setLoadingHttp(true);
1851
1808
  fetch(`${BASE}/api/profiles?limit=50&offset=0`).then((res) => res.json()).then((data) => {
1852
1809
  setProfiles(data.profiles);
@@ -1857,7 +1814,7 @@
1857
1814
  setError("Failed to load profiles");
1858
1815
  setLoadingHttp(false);
1859
1816
  });
1860
- } else if (section === "jobs") {
1817
+ } else if (s3 === "jobs") {
1861
1818
  setLoadingJobs(true);
1862
1819
  fetch(`${BASE}/api/jobs?limit=50&offset=0`).then((res) => res.json()).then((data) => {
1863
1820
  setJobs(data.profiles);
@@ -1868,7 +1825,7 @@
1868
1825
  setJobsError("Failed to load job profiles");
1869
1826
  setLoadingJobs(false);
1870
1827
  });
1871
- } else if (section === "outbound") {
1828
+ } else if (s3 === "outbound") {
1872
1829
  setLoadingOutbound(true);
1873
1830
  fetch(`${BASE}/api/outbound_http`).then((res) => res.json()).then((data) => {
1874
1831
  setOutboundRequests(data);
@@ -1888,6 +1845,7 @@
1888
1845
  });
1889
1846
  }
1890
1847
  };
1848
+ const refresh = () => refreshSection(section);
1891
1849
  const tabClass = (s3) => `tab${section === s3 ? " active" : ""}`;
1892
1850
  const filteredProfiles = profiles.filter((p3) => {
1893
1851
  if (httpSearch && !p3.path.toLowerCase().includes(httpSearch.toLowerCase())) return false;
@@ -2069,7 +2027,8 @@
2069
2027
  profiles.length
2070
2028
  ] }),
2071
2029
  /* @__PURE__ */ u3("button", { class: `btn-refresh${loadingHttp ? " btn-refresh--spinning" : ""}`, onClick: refresh, disabled: loadingHttp, title: "Refresh", children: "\u21BA" }),
2072
- /* @__PURE__ */ u3("button", { class: "btn btn-danger btn-sm", onClick: clearProfiles, children: "Clear All" })
2030
+ /* @__PURE__ */ u3("button", { class: "btn btn-danger btn-sm", onClick: clearProfiles, title: "Delete HTTP profiles", children: "Clear" }),
2031
+ /* @__PURE__ */ u3("button", { class: "btn btn-danger btn-sm", onClick: clearAll, title: "Delete all HTTP and job profiles", children: "Clear All" })
2073
2032
  ] })
2074
2033
  ] }),
2075
2034
  filteredProfiles.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No results match filters" }) }) : /* @__PURE__ */ u3("table", { children: [
@@ -2154,7 +2113,8 @@
2154
2113
  jobs.length
2155
2114
  ] }),
2156
2115
  /* @__PURE__ */ u3("button", { class: `btn-refresh${loadingJobs ? " btn-refresh--spinning" : ""}`, onClick: refresh, disabled: loadingJobs, title: "Refresh", children: "\u21BA" }),
2157
- /* @__PURE__ */ u3("button", { class: "btn btn-danger btn-sm", onClick: clearJobs, children: "Clear All" })
2116
+ /* @__PURE__ */ u3("button", { class: "btn btn-danger btn-sm", onClick: clearJobs, title: "Delete job profiles", children: "Clear" }),
2117
+ /* @__PURE__ */ u3("button", { class: "btn btn-danger btn-sm", onClick: clearAll, title: "Delete all HTTP and job profiles", children: "Clear All" })
2158
2118
  ] })
2159
2119
  ] }),
2160
2120
  filteredJobs.length === 0 ? /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("div", { class: "profiler-empty__title", children: "No results match filters" }) }) : /* @__PURE__ */ u3("table", { children: [
@@ -10,6 +10,7 @@ module Profiler
10
10
  super
11
11
  @events = []
12
12
  @subscriptions = []
13
+ @mutex = Mutex.new
13
14
  end
14
15
 
15
16
  def icon
@@ -38,7 +39,7 @@ module Profiler
38
39
 
39
40
  # Controller action
40
41
  @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
41
- @events << Models::TimelineEvent.new(
42
+ add_event Models::TimelineEvent.new(
42
43
  name: "#{payload[:controller]}##{payload[:action]}",
43
44
  started_at: started,
44
45
  finished_at: finished,
@@ -57,7 +58,7 @@ module Profiler
57
58
  # Template rendering
58
59
  @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_template.action_view") do |_name, started, finished, _unique_id, payload|
59
60
  identifier = short_identifier(payload[:identifier])
60
- @events << Models::TimelineEvent.new(
61
+ add_event Models::TimelineEvent.new(
61
62
  name: "Render: #{identifier}",
62
63
  started_at: started,
63
64
  finished_at: finished,
@@ -69,7 +70,7 @@ module Profiler
69
70
  # Partial rendering
70
71
  @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_partial.action_view") do |_name, started, finished, _unique_id, payload|
71
72
  identifier = short_identifier(payload[:identifier])
72
- @events << Models::TimelineEvent.new(
73
+ add_event Models::TimelineEvent.new(
73
74
  name: "Partial: #{identifier}",
74
75
  started_at: started,
75
76
  finished_at: finished,
@@ -84,7 +85,7 @@ module Profiler
84
85
  next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
85
86
 
86
87
  sql = payload[:sql].to_s
87
- @events << Models::TimelineEvent.new(
88
+ add_event Models::TimelineEvent.new(
88
89
  name: sql.length > 80 ? "#{sql[0, 80]}..." : sql,
89
90
  started_at: started,
90
91
  finished_at: finished,
@@ -98,7 +99,7 @@ module Profiler
98
99
  @subscriptions << ActiveSupport::Notifications.monotonic_subscribe(event_name) do |name, started, finished, _unique_id, payload|
99
100
  op = name.split(".").first.sub("cache_", "")
100
101
  key = payload[:key].to_s
101
- @events << Models::TimelineEvent.new(
102
+ add_event Models::TimelineEvent.new(
102
103
  name: "cache_#{op}: #{key.length > 60 ? "#{key[0, 60]}..." : key}",
103
104
  started_at: started,
104
105
  finished_at: finished,
@@ -111,7 +112,7 @@ module Profiler
111
112
 
112
113
  # Called by Profiler.measure to record custom instrumentation events
113
114
  def record_custom_event(label:, started_at:, finished_at:, metadata: {})
114
- @events << Models::TimelineEvent.new(
115
+ add_event Models::TimelineEvent.new(
115
116
  name: label,
116
117
  started_at: started_at,
117
118
  finished_at: finished_at,
@@ -122,7 +123,7 @@ module Profiler
122
123
 
123
124
  # Called by NetHttpInstrumentation to record outbound HTTP events
124
125
  def record_http_event(started_at:, finished_at:, url:, method:, status:)
125
- @events << Models::TimelineEvent.new(
126
+ add_event Models::TimelineEvent.new(
126
127
  name: "HTTP #{method} #{url}",
127
128
  started_at: started_at,
128
129
  finished_at: finished_at,
@@ -182,6 +183,10 @@ module Profiler
182
183
  roots
183
184
  end
184
185
 
186
+ def add_event(event)
187
+ @mutex.synchronize { @events << event }
188
+ end
189
+
185
190
  def short_identifier(identifier)
186
191
  return identifier.to_s unless identifier.to_s.include?("/")
187
192
 
@@ -9,6 +9,8 @@ module Profiler
9
9
  def initialize(profile)
10
10
  super
11
11
  @requests = []
12
+ @mutex = Mutex.new
13
+ @collected = false
12
14
  end
13
15
 
14
16
  def icon
@@ -40,47 +42,88 @@ module Profiler
40
42
  def collect
41
43
  Thread.current[:profiler_http_collector] = nil
42
44
 
43
- threshold = Profiler.configuration.slow_http_threshold
45
+ data = @mutex.synchronize do
46
+ @collected = true
47
+ build_data(@requests)
48
+ end
49
+ store_data(data)
50
+ end
44
51
 
45
- store_data(
46
- total_requests: @requests.size,
47
- total_duration: @requests.sum { |r| r[:duration] }.round(2),
48
- slow_requests: @requests.count { |r| r[:duration] >= threshold },
49
- error_requests: @requests.count { |r| r[:status] >= 400 || r[:status] == 0 },
50
- by_host: group_by_host,
51
- by_status: group_by_status,
52
- requests: @requests.map { |r| r.transform_keys(&:to_s) }
53
- )
52
+ # Called from NetHttpInstrumentation before the actual HTTP call.
53
+ # Returns the mutable entry so the caller can update it on completion.
54
+ def register_pending(payload)
55
+ entry = payload.merge(in_flight: true, status: 0, duration: nil,
56
+ response_headers: {}, response_body: nil,
57
+ response_body_encoding: "text", response_size: 0)
58
+ @mutex.synchronize { @requests << entry }
59
+ save_if_collected
60
+ entry
54
61
  end
55
62
 
56
- def record_request(payload)
57
- @requests << payload
63
+ # Called from NetHttpInstrumentation after the HTTP response is received.
64
+ def complete_request(entry, **data)
65
+ @mutex.synchronize { entry.merge!(data.merge(in_flight: false)) }
66
+ save_if_collected
67
+ end
68
+
69
+ # Called from NetHttpInstrumentation when the HTTP call raises.
70
+ def fail_request(entry, error:, duration:)
71
+ @mutex.synchronize { entry.merge!(in_flight: false, status: 0, duration: duration, error: error) }
72
+ save_if_collected
58
73
  end
59
74
 
60
75
  def toolbar_summary
61
- total = @requests.size
76
+ requests = @mutex.synchronize { @requests.dup }
77
+ total = requests.size
62
78
  return { text: "0 HTTP", color: "green" } if total == 0
63
79
 
64
80
  threshold = Profiler.configuration.slow_http_threshold
65
- errors = @requests.count { |r| r[:status] >= 400 || r[:status] == 0 }
66
- slow = @requests.count { |r| r[:duration] >= threshold }
67
- duration = @requests.sum { |r| r[:duration] }.round(2)
81
+ in_flight = requests.count { |r| r[:in_flight] }
82
+ errors = requests.count { |r| !r[:in_flight] && (r[:status] >= 400 || r[:status] == 0) }
83
+ slow = requests.count { |r| !r[:in_flight] && r[:duration] && r[:duration] >= threshold }
84
+ duration = requests.sum { |r| r[:duration].to_f }.round(2)
68
85
 
69
86
  color = if errors > 0 || slow > 0
70
87
  "red"
71
- elsif total > 10
88
+ elsif in_flight > 0 || total > 10
72
89
  "orange"
73
90
  else
74
91
  "green"
75
92
  end
76
93
 
77
- { text: "#{total} HTTP (#{duration}ms)", color: color }
94
+ text = in_flight > 0 ? "#{total} HTTP (#{in_flight} pending, #{duration}ms)" : "#{total} HTTP (#{duration}ms)"
95
+ { text: text, color: color }
78
96
  end
79
97
 
80
98
  private
81
99
 
82
- def group_by_host
83
- @requests.each_with_object(Hash.new(0)) do |req, h|
100
+ def build_data(requests)
101
+ threshold = Profiler.configuration.slow_http_threshold
102
+ {
103
+ total_requests: requests.size,
104
+ total_duration: requests.sum { |r| r[:duration].to_f }.round(2),
105
+ slow_requests: requests.count { |r| !r[:in_flight] && r[:duration] && r[:duration] >= threshold },
106
+ error_requests: requests.count { |r| !r[:in_flight] && (r[:status] >= 400 || r[:status] == 0) },
107
+ by_host: group_by_host(requests),
108
+ by_status: group_by_status(requests),
109
+ requests: requests.map { |r| r.transform_keys(&:to_s) }
110
+ }
111
+ end
112
+
113
+ # Rebuilds and persists HTTP data after collect has already run.
114
+ # Called when fire-and-forget threads register or complete requests post-collect.
115
+ def save_if_collected
116
+ data = @mutex.synchronize do
117
+ return unless @collected
118
+
119
+ build_data(@requests)
120
+ end
121
+ store_data(data)
122
+ Profiler.storage.save(@profile.token, @profile)
123
+ end
124
+
125
+ def group_by_host(requests)
126
+ requests.each_with_object(Hash.new(0)) do |req, h|
84
127
  host = begin
85
128
  URI.parse(req[:url]).host || "unknown"
86
129
  rescue URI::InvalidURIError
@@ -90,16 +133,17 @@ module Profiler
90
133
  end
91
134
  end
92
135
 
93
- def group_by_status
94
- @requests.each_with_object(Hash.new(0)) do |req, h|
95
- status = req[:status]
96
- key = if status == 0
136
+ def group_by_status(requests)
137
+ requests.each_with_object(Hash.new(0)) do |req, h|
138
+ key = if req[:in_flight]
139
+ "pending"
140
+ elsif req[:status] == 0
97
141
  "error"
98
- elsif status < 300
142
+ elsif req[:status] < 300
99
143
  "2xx"
100
- elsif status < 400
144
+ elsif req[:status] < 400
101
145
  "3xx"
102
- elsif status < 500
146
+ elsif req[:status] < 500
103
147
  "4xx"
104
148
  else
105
149
  "5xx"
@@ -44,9 +44,28 @@ module Profiler
44
44
  end
45
45
  end
46
46
  req_headers = req.to_hash.transform_values { |v| v.join(", ") }
47
+ req_content_type = req["content-type"].to_s
48
+ processed_req = req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
49
+
47
50
  request_id = SecureRandom.hex(8)
48
51
  started_at = Time.now.iso8601(3)
49
52
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
53
+
54
+ # Register the request as pending before the network call so that
55
+ # fire-and-forget threads appear in the UI immediately, even if
56
+ # collect() runs before this thread completes.
57
+ entry = collector.register_pending(
58
+ id: request_id,
59
+ started_at: started_at,
60
+ url: url,
61
+ method: req.method,
62
+ request_headers: req_headers,
63
+ request_body: processed_req[:body],
64
+ request_body_encoding: processed_req[:encoding],
65
+ request_size: req_body.bytesize,
66
+ backtrace: NetHttpInstrumentation.extract_backtrace
67
+ )
68
+
50
69
  Thread.current[:profiler_http_recording] = true
51
70
 
52
71
  response = super
@@ -56,29 +75,18 @@ module Profiler
56
75
  resp_content_encoding = response["content-encoding"].to_s.strip.downcase
57
76
  resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding)
58
77
  resp_content_type = response["content-type"].to_s
59
- req_content_type = req["content-type"].to_s
60
-
61
- processed_req = req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
62
78
  processed_resp = NetHttpInstrumentation.process_body(resp_body, resp_content_type)
63
79
 
64
80
  t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
81
 
66
- collector.record_request(
67
- id: request_id,
68
- started_at: started_at,
69
- url: url,
70
- method: req.method,
82
+ collector.complete_request(
83
+ entry,
71
84
  status: response.code.to_i,
72
85
  duration: duration,
73
- request_headers: req_headers,
74
- request_body: processed_req[:body],
75
- request_body_encoding: processed_req[:encoding],
76
- request_size: req_body.bytesize,
77
86
  response_headers: response.to_hash.transform_values { |v| v.join(", ") },
78
87
  response_body: processed_resp[:body],
79
88
  response_body_encoding: processed_resp[:encoding],
80
- response_size: resp_body_raw.bytesize,
81
- backtrace: NetHttpInstrumentation.extract_backtrace
89
+ response_size: resp_body_raw.bytesize
82
90
  )
83
91
 
84
92
  fg = Thread.current[:profiler_flamegraph_collector]
@@ -86,26 +94,9 @@ module Profiler
86
94
 
87
95
  response
88
96
  rescue => e
89
- if defined?(t0) && t0
90
- duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
91
- collector&.record_request(
92
- id: defined?(request_id) ? request_id : SecureRandom.hex(8),
93
- started_at: defined?(started_at) ? started_at : Time.now.iso8601(3),
94
- url: url,
95
- method: req.method,
96
- status: 0,
97
- duration: duration,
98
- request_headers: defined?(req_headers) ? req_headers : {},
99
- request_body: nil,
100
- request_body_encoding: "text",
101
- request_size: 0,
102
- response_headers: {},
103
- response_body: nil,
104
- response_body_encoding: "text",
105
- response_size: 0,
106
- backtrace: NetHttpInstrumentation.extract_backtrace,
107
- error: e.message
108
- )
97
+ duration = defined?(t0) && t0 ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2) : 0.0
98
+ if defined?(entry) && entry
99
+ collector.fail_request(entry, error: e.message, duration: duration)
109
100
  end
110
101
  raise
111
102
  ensure
@@ -166,9 +157,8 @@ module Profiler
166
157
  end
167
158
 
168
159
  def self.extract_backtrace
169
- caller_locations(5, 15)
160
+ caller_locations(5, 40)
170
161
  .reject { |l| l.path.to_s.include?("net/http") || l.path.to_s.include?("profiler/instrumentation") }
171
- .first(5)
172
162
  .map { |l| "#{l.path}:#{l.lineno}:in `#{l.label}`" }
173
163
  end
174
164
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Instrumentation
5
+ module ThreadContextPropagation
6
+ PROPAGATED_KEYS = %i[
7
+ profiler_http_collector
8
+ profiler_flamegraph_collector
9
+ ].freeze
10
+
11
+ def initialize(*args, &block)
12
+ parent_context = PROPAGATED_KEYS.filter_map do |key|
13
+ val = Thread.current[key]
14
+ [key, val] unless val.nil?
15
+ end.to_h
16
+
17
+ if parent_context.empty?
18
+ super
19
+ else
20
+ super(*args) do
21
+ parent_context.each { |k, v| Thread.current[k] = v }
22
+ begin
23
+ block&.call
24
+ ensure
25
+ PROPAGATED_KEYS.each { |k| Thread.current[k] = nil }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ Thread.prepend(Profiler::Instrumentation::ThreadContextPropagation)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.22.0"
4
+ VERSION = "0.23.0"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -106,5 +106,6 @@ require_relative "profiler/collectors/env_collector"
106
106
  require_relative "profiler/collectors/mailer_collector"
107
107
 
108
108
  require_relative "profiler/env_override_store"
109
+ require_relative "profiler/instrumentation/thread_context_propagation"
109
110
  require_relative "profiler/railtie" if defined?(Rails::Railtie)
110
111
  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.22.0
4
+ version: 0.23.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-05-04 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -149,6 +149,7 @@ files:
149
149
  - lib/profiler/instrumentation/active_job_instrumentation.rb
150
150
  - lib/profiler/instrumentation/net_http_instrumentation.rb
151
151
  - lib/profiler/instrumentation/sidekiq_middleware.rb
152
+ - lib/profiler/instrumentation/thread_context_propagation.rb
152
153
  - lib/profiler/job_profiler.rb
153
154
  - lib/profiler/mcp/body_formatter.rb
154
155
  - lib/profiler/mcp/file_cache.rb