rails-profiler 0.27.1 → 0.29.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: 979833a1b2c4ee8e9a831541dfdd8441415d8226c84bc8cfcb5d99b719c68626
4
- data.tar.gz: 7df581a3d434892acca1f640b943fc3435fee501dbb0e9b656b404ba73b858cb
3
+ metadata.gz: 5388819e53342bdba701e37c877cddf9b2e6b888fa163198ef7760b689bc8796
4
+ data.tar.gz: ca44a3e2c623ab6d46fec1fef941e76fb7941a1c77e5261077f0585ab6e70b53
5
5
  SHA512:
6
- metadata.gz: 51b42dbe0a1ff3b867d8cbf61dadd9c20e534936ff53cba67171db20a726f591dd200188fed64825c7d2d1e83aba034251e50c99d6ed1572810b460278e6c23d
7
- data.tar.gz: 2f837c25d831afe1fd55253093bea8b94f7320515c00f25d58d1206c3fc14bad7ad6e304739a898ca6c19e93a3bfce857df5262aad0bade745bf3ce696373ad7
6
+ metadata.gz: 5f01b92b17838b1730e5e571299ba0de4abfcc51fe56921e343a35ffe3aeaa38ebff0f95565d334f0b9a78123b3fd489dc96accf4244a9bb346fdd1536e45ce4
7
+ data.tar.gz: 1e8960c6060b7f9a070497def307c9e04f4e57ac61f28bf6e9d48aac136ff48655c0df2bd3481c0628537290efbc093db6df9cdc68eaa3cc8bf75785fb6d98bf
@@ -4192,6 +4192,45 @@
4192
4192
  return query;
4193
4193
  }
4194
4194
 
4195
+ // app/assets/typescript/profiler/hooks/useProfileEvents.ts
4196
+ var BASE = "/_profiler";
4197
+ function useProfileEvents(token, collectors, onUpdate) {
4198
+ const [connected, setConnected] = d2(false);
4199
+ const fallbackRef = A2(null);
4200
+ y2(() => {
4201
+ if (!token) return;
4202
+ const clearFallback = () => {
4203
+ if (fallbackRef.current !== null) {
4204
+ clearInterval(fallbackRef.current);
4205
+ fallbackRef.current = null;
4206
+ }
4207
+ };
4208
+ const params = new URLSearchParams();
4209
+ collectors.forEach((c3) => params.append("collectors[]", c3));
4210
+ const url = `${BASE}/api/events/${token}?${params.toString()}`;
4211
+ const es = new EventSource(url);
4212
+ es.addEventListener("profile_update", () => {
4213
+ onUpdate();
4214
+ });
4215
+ es.onopen = () => {
4216
+ setConnected(true);
4217
+ clearFallback();
4218
+ };
4219
+ es.onerror = () => {
4220
+ es.close();
4221
+ setConnected(false);
4222
+ if (fallbackRef.current === null) {
4223
+ fallbackRef.current = setInterval(() => onUpdate(), 1e4);
4224
+ }
4225
+ };
4226
+ return () => {
4227
+ es.close();
4228
+ clearFallback();
4229
+ };
4230
+ }, [token]);
4231
+ return { connected };
4232
+ }
4233
+
4195
4234
  // app/assets/typescript/profiler/toolbar-bundle.tsx
4196
4235
  var ANIM_MS = 280;
4197
4236
  function applyTheme(elements) {
@@ -4209,6 +4248,9 @@
4209
4248
  refetch();
4210
4249
  };
4211
4250
  }, [refetch]);
4251
+ useProfileEvents(token, [], () => {
4252
+ refetch();
4253
+ });
4212
4254
  const profile = data?.profile ?? null;
4213
4255
  if (!profile) return null;
4214
4256
  return /* @__PURE__ */ u3(ToolbarApp, { profile, token });
@@ -4390,8 +4390,8 @@
4390
4390
  open && /* @__PURE__ */ u3(
4391
4391
  HttpReqRespDetail,
4392
4392
  {
4393
- request: { headers: req.request_headers || {}, body: req.request_body, body_encoding: req.request_body_encoding },
4394
- response: { headers: req.response_headers || {}, body: req.response_body, body_encoding: req.response_body_encoding },
4393
+ request: { headers: req.request_headers || {}, body: req.request_body ?? void 0, body_encoding: req.request_body_encoding },
4394
+ response: { headers: req.response_headers || {}, body: req.response_body ?? void 0, body_encoding: req.response_body_encoding },
4395
4395
  backtrace: req.backtrace
4396
4396
  }
4397
4397
  )
@@ -4626,7 +4626,7 @@
4626
4626
  if (!entries.length) return;
4627
4627
  setSaving(true);
4628
4628
  try {
4629
- await Promise.all(entries.map(([k3, v3]) => patchEnvVar({ key: k3, value: v3 })));
4629
+ await Promise.all(entries.map(([k3, v3]) => patchEnvVar({ data: { key: k3, value: v3 } })));
4630
4630
  setVariables((prev) => ({ ...prev, ...importPreview }));
4631
4631
  setImportContent("");
4632
4632
  setShowImport(false);
@@ -4654,7 +4654,7 @@
4654
4654
  }
4655
4655
  setSaving(true);
4656
4656
  try {
4657
- const data = await patchEnvVar({ key, value: editValue });
4657
+ const data = await patchEnvVar({ data: { key, value: editValue } });
4658
4658
  setVariables((prev) => ({ ...prev, [key]: editValue }));
4659
4659
  setOverrides((prev) => {
4660
4660
  const n2 = { ...prev };
@@ -4676,7 +4676,7 @@
4676
4676
  const deleteVar = async (key) => {
4677
4677
  setSaving(true);
4678
4678
  try {
4679
- const data = await patchEnvVar({ key, value: null });
4679
+ const data = await patchEnvVar({ data: { key, value: null } });
4680
4680
  setVariables((prev) => {
4681
4681
  const n2 = { ...prev };
4682
4682
  delete n2[key];
@@ -4708,7 +4708,7 @@
4708
4708
  const next = /^(true|yes)$/i.test(current) ? "false" : "true";
4709
4709
  setSaving(true);
4710
4710
  try {
4711
- const data = await patchEnvVar({ key, value: next });
4711
+ const data = await patchEnvVar({ data: { key, value: next } });
4712
4712
  setVariables((prev) => ({ ...prev, [key]: next }));
4713
4713
  setOverrides((prev) => {
4714
4714
  const n2 = { ...prev };
@@ -4730,7 +4730,7 @@
4730
4730
  if (!key) return;
4731
4731
  setSaving(true);
4732
4732
  try {
4733
- const data = await patchEnvVar({ key, value: newValue });
4733
+ const data = await patchEnvVar({ data: { key, value: newValue } });
4734
4734
  setVariables((prev) => ({ ...prev, [key]: newValue }));
4735
4735
  setOverrides((prev) => {
4736
4736
  const n2 = { ...prev };
@@ -4757,7 +4757,7 @@
4757
4757
  const resetVar = async (key) => {
4758
4758
  setSaving(true);
4759
4759
  try {
4760
- const data = await resetEnvVar2({ key });
4760
+ const data = await resetEnvVar2({ params: { key } });
4761
4761
  setOverrides((prev) => {
4762
4762
  const n2 = { ...prev };
4763
4763
  delete n2[key];
@@ -4765,7 +4765,7 @@
4765
4765
  });
4766
4766
  const updater = (prev) => {
4767
4767
  const n2 = { ...prev };
4768
- if (data.value === null) {
4768
+ if (data.value === null || data.value === void 0) {
4769
4769
  delete n2[key];
4770
4770
  } else {
4771
4771
  n2[key] = data.value;
@@ -4784,7 +4784,7 @@
4784
4784
  const doResetAll = async () => {
4785
4785
  setSaving(true);
4786
4786
  try {
4787
- await resetAllEnvVars2({});
4787
+ await resetAllEnvVars2();
4788
4788
  setOverrides({});
4789
4789
  const data = await refresh();
4790
4790
  if (data) setInitial(data.variables);
@@ -5397,7 +5397,7 @@
5397
5397
  if (selected.size === 0 || isRunning) return;
5398
5398
  setError(null);
5399
5399
  try {
5400
- const data = await startTestRun({ files: Array.from(selected), framework });
5400
+ const data = await startTestRun({ data: { files: Array.from(selected), framework } });
5401
5401
  setCurrentRun(data);
5402
5402
  setIsRunning(true);
5403
5403
  } catch {
@@ -6004,7 +6004,7 @@
6004
6004
  " ms"
6005
6005
  ] }) }),
6006
6006
  /* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "\u2014" }),
6007
- /* @__PURE__ */ u3("td", { children: formatMemory(p3.memory) }),
6007
+ /* @__PURE__ */ u3("td", { children: formatMemory(p3.memory ?? void 0) }),
6008
6008
  /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: statusClass(p3.status), children: p3.status }) }),
6009
6009
  /* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
6010
6010
  /* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("button", { class: "btn-row-delete", onClick: () => deleteProfile2(p3.token), title: "Delete", children: "\xD7" }) })
@@ -6376,13 +6376,13 @@
6376
6376
  {
6377
6377
  request: {
6378
6378
  headers: profile.headers ?? {},
6379
- body: profile.request_body,
6379
+ body: profile.request_body ?? void 0,
6380
6380
  body_encoding: profile.request_body_encoding,
6381
6381
  params: hasParams ? profile.params : void 0
6382
6382
  },
6383
6383
  response: {
6384
6384
  headers: profile.response_headers ?? {},
6385
- body: profile.response_body,
6385
+ body: profile.response_body ?? void 0,
6386
6386
  body_encoding: profile.response_body_encoding
6387
6387
  }
6388
6388
  }
@@ -6525,7 +6525,7 @@
6525
6525
  const runExplain = async (queryIndex) => {
6526
6526
  setExplainState({ open: true, loading: true, result: null, format: "text", adapter: "", error: null });
6527
6527
  try {
6528
- const data = await runExplainMutation({ token, query_index: queryIndex });
6528
+ const data = await runExplainMutation({ data: { token, query_index: queryIndex } });
6529
6529
  setExplainState((s3) => ({
6530
6530
  ...s3,
6531
6531
  loading: false,
@@ -7155,7 +7155,8 @@
7155
7155
  if (node.payload && category !== "method") {
7156
7156
  let payloadText = null;
7157
7157
  if (category === "sql" && node.payload.sql) {
7158
- payloadText = node.payload.sql.length > 200 ? node.payload.sql.slice(0, 200) + "..." : node.payload.sql;
7158
+ const sql = node.payload.sql;
7159
+ payloadText = sql.length > 200 ? sql.slice(0, 200) + "..." : sql;
7159
7160
  } else if (category === "cache" && node.payload.key) {
7160
7161
  payloadText = `Key: ${node.payload.key}`;
7161
7162
  } else if (category === "http" && node.payload.url) {
@@ -7283,7 +7284,7 @@
7283
7284
  const toggleFunctionProfiling = async () => {
7284
7285
  setFnToggling(true);
7285
7286
  try {
7286
- const json = await patchFunctionProfiling({ enabled: !fnEnabled });
7287
+ const json = await patchFunctionProfiling({ data: { enabled: !fnEnabled } });
7287
7288
  setFnEnabled(json.enabled);
7288
7289
  } finally {
7289
7290
  setFnToggling(false);
@@ -7292,7 +7293,7 @@
7292
7293
  const updateMaxFrames = async (value) => {
7293
7294
  setFnMaxFramesUpdating(true);
7294
7295
  try {
7295
- const json = await patchFunctionProfiling({ max_frames: value });
7296
+ const json = await patchFunctionProfiling({ data: { max_frames: value } });
7296
7297
  setFnMaxFrames(json.max_frames);
7297
7298
  } finally {
7298
7299
  setFnMaxFramesUpdating(false);
@@ -7302,7 +7303,7 @@
7302
7303
  setFnMode(value);
7303
7304
  setFnModeUpdating(true);
7304
7305
  try {
7305
- const json = await patchFunctionProfiling({ mode: value });
7306
+ const json = await patchFunctionProfiling({ data: { mode: value } });
7306
7307
  setFnMode(json.mode ?? value);
7307
7308
  } finally {
7308
7309
  setFnModeUpdating(false);
@@ -7311,7 +7312,7 @@
7311
7312
  const updateClock = async (value) => {
7312
7313
  setFnClockUpdating(true);
7313
7314
  try {
7314
- const json = await patchFunctionProfiling({ clock: value });
7315
+ const json = await patchFunctionProfiling({ data: { clock: value } });
7315
7316
  setFnClock(json.clock ?? value);
7316
7317
  } finally {
7317
7318
  setFnClockUpdating(false);
@@ -8518,7 +8519,7 @@
8518
8519
  mode === "preview" && hasHtml && /* @__PURE__ */ u3(
8519
8520
  "iframe",
8520
8521
  {
8521
- srcdoc: email.body_html,
8522
+ srcdoc: email.body_html ?? void 0,
8522
8523
  sandbox: "allow-same-origin",
8523
8524
  style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
8524
8525
  }
@@ -9228,11 +9229,11 @@
9228
9229
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
9229
9230
  activeTab === "test" && testData && /* @__PURE__ */ u3(TestTab, { testData }),
9230
9231
  activeTab === "database" && /* @__PURE__ */ u3(DatabaseTab, { dbData: cd["database"], token: profile.token }),
9231
- activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { data: cd["cache"] }),
9232
- activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { data: cd["flamegraph"] }),
9233
- activeTab === "dump" && hasDumps && /* @__PURE__ */ u3(DumpsTab, { data: cd["dump"] }),
9234
- activeTab === "logs" && hasLogs && /* @__PURE__ */ u3(LogsTab, { data: cd["logs"] }),
9235
- activeTab === "exception" && hasException && /* @__PURE__ */ u3(ExceptionTab, { data: cd["exception"] }),
9232
+ activeTab === "cache" && /* @__PURE__ */ u3(CacheTab, { cacheData: cd["cache"] }),
9233
+ activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, { flamegraphData: cd["flamegraph"], perfData: cd["performance"], functionProfileData: cd["function_profile"] }),
9234
+ activeTab === "dump" && hasDumps && /* @__PURE__ */ u3(DumpsTab, { dumpData: cd["dump"] }),
9235
+ activeTab === "logs" && hasLogs && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
9236
+ activeTab === "exception" && hasException && /* @__PURE__ */ u3(ExceptionTab, { exceptionData: cd["exception"] }),
9236
9237
  activeTab === "env" && /* @__PURE__ */ u3(EnvTab, { envData: cd["env"] })
9237
9238
  ] })
9238
9239
  ] })
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class EventsController < Profiler::ApplicationController
6
+ include ActionController::Live
7
+ skip_before_action :verify_authenticity_token
8
+
9
+ def subscribe
10
+ response.headers["Content-Type"] = "text/event-stream"
11
+ response.headers["Cache-Control"] = "no-cache"
12
+ response.headers["X-Accel-Buffering"] = "no"
13
+
14
+ sse = ActionController::Live::SSE.new(response.stream, retry: 3000, event: "profile_update")
15
+ id = Profiler::SSE.current.subscribe(params[:token], Array(params[:collectors]))
16
+
17
+ begin
18
+ loop do
19
+ event = Profiler::SSE.current.wait_for_event(id, timeout: 30)
20
+ if event
21
+ sse.write(event)
22
+ else
23
+ sse.write({}, event: "heartbeat")
24
+ end
25
+ end
26
+ rescue ActionController::Live::ClientDisconnected, IOError
27
+ # Client disconnected — normal exit
28
+ ensure
29
+ Profiler::SSE.current.unsubscribe(id)
30
+ sse.close
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
data/config/routes.rb CHANGED
@@ -50,5 +50,6 @@ Profiler::Engine.routes.draw do
50
50
  get "test_runner/runs/:id", to: "test_runner#show", as: :test_runner_run
51
51
  get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
52
52
  delete "test_runner/runs/:id", to: "test_runner#destroy"
53
+ get "events/:token", to: "events#subscribe", as: :profile_events
53
54
  end
54
55
  end
@@ -9,7 +9,7 @@ module Profiler
9
9
  :authorization_mode, :max_profiles, :extension_cors_enabled,
10
10
  :cors_allowed_origins,
11
11
  :track_ajax, :ajax_skip_paths,
12
- :track_http, :slow_http_threshold, :http_skip_hosts,
12
+ :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
13
13
  :track_jobs,
14
14
  :track_console,
15
15
  :track_tests,
@@ -43,6 +43,7 @@ module Profiler
43
43
  @track_http = true
44
44
  @slow_http_threshold = 500 # milliseconds
45
45
  @http_skip_hosts = []
46
+ @http_backtrace_depth = 40
46
47
  @track_jobs = true
47
48
  @track_console = true
48
49
  @track_tests = false
@@ -157,9 +157,11 @@ module Profiler
157
157
  end
158
158
 
159
159
  def self.extract_backtrace
160
- caller_locations(5, 40)
161
- .reject { |l| l.path.to_s.include?("net/http") || l.path.to_s.include?("profiler/instrumentation") }
162
- .map { |l| "#{l.path}:#{l.lineno}:in `#{l.label}`" }
160
+ depth = Profiler.configuration.http_backtrace_depth
161
+ frames = caller_locations(5, depth || 1000)
162
+ .reject { |l| l.path.to_s.include?("net/http") || l.path.to_s.include?("profiler/instrumentation") }
163
+ .map { |l| "#{l.path}:#{l.lineno}:in `#{l.label}`" }
164
+ depth ? frames.first(depth) : frames
163
165
  end
164
166
  end
165
167
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module SSE
5
+ def self.current
6
+ if defined?(Profiler::Storage::RedisStore) && Profiler.storage.is_a?(Profiler::Storage::RedisStore)
7
+ RedisEventBus.instance
8
+ else
9
+ EventBus.instance
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "securerandom"
5
+ require "timeout"
6
+ require "set"
7
+ require "concurrent"
8
+
9
+ module Profiler
10
+ module SSE
11
+ class EventBus
12
+ include Singleton
13
+
14
+ def initialize
15
+ @subscriptions = Concurrent::Hash.new
16
+ end
17
+
18
+ def subscribe(token, collectors)
19
+ id = SecureRandom.uuid
20
+ @subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
21
+ id
22
+ end
23
+
24
+ def unsubscribe(id)
25
+ @subscriptions.delete(id)
26
+ end
27
+
28
+ def broadcast(token, collectors)
29
+ changed = Set.new(collectors.map(&:to_s))
30
+ @subscriptions.each_value do |sub|
31
+ next unless sub[:token] == token
32
+ # Empty collector set means "match all"; non-empty set filters by intersection.
33
+ next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
34
+ sub[:queue] << { token: token, collectors: changed.to_a, timestamp: Time.now.to_f }
35
+ end
36
+ end
37
+
38
+ def wait_for_event(id, timeout: 30)
39
+ sub = @subscriptions[id]
40
+ return nil unless sub
41
+ Timeout.timeout(timeout) { sub[:queue].pop }
42
+ rescue Timeout::Error
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "securerandom"
5
+ require "timeout"
6
+ require "set"
7
+ require "json"
8
+ require "concurrent"
9
+
10
+ module Profiler
11
+ module SSE
12
+ class RedisEventBus
13
+ include Singleton
14
+
15
+ CHANNEL_PREFIX = "profiler:events"
16
+
17
+ def initialize
18
+ @subscriptions = Concurrent::Hash.new
19
+ @listener_thread = nil
20
+ @listener_mutex = Mutex.new
21
+ end
22
+
23
+ def subscribe(token, collectors)
24
+ id = SecureRandom.uuid
25
+ @subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
26
+ ensure_listener_running
27
+ id
28
+ end
29
+
30
+ def unsubscribe(id)
31
+ @subscriptions.delete(id)
32
+ end
33
+
34
+ def broadcast(token, collectors)
35
+ payload = { token: token, collectors: collectors.map(&:to_s), timestamp: Time.now.to_f }.to_json
36
+ publish_redis_client.publish("#{CHANNEL_PREFIX}:#{token}", payload)
37
+ end
38
+
39
+ def wait_for_event(id, timeout: 30)
40
+ sub = @subscriptions[id]
41
+ return nil unless sub
42
+ Timeout.timeout(timeout) { sub[:queue].pop }
43
+ rescue Timeout::Error
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def publish_redis_client
50
+ Profiler.storage.redis
51
+ end
52
+
53
+ def ensure_listener_running
54
+ @listener_mutex.synchronize do
55
+ return if @listener_thread&.alive?
56
+
57
+ @listener_thread = Thread.new do
58
+ # Use a dedicated connection for blocking psubscribe
59
+ Profiler.storage.redis.dup.psubscribe("#{CHANNEL_PREFIX}:*") do |on|
60
+ on.pmessage do |_pattern, _channel, message|
61
+ payload = JSON.parse(message, symbolize_names: true)
62
+ token = payload[:token]
63
+ changed = Set.new(payload[:collectors].map(&:to_s))
64
+
65
+ @subscriptions.each_value do |sub|
66
+ next unless sub[:token] == token
67
+ next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
68
+ sub[:queue] << { token: token, collectors: changed.to_a, timestamp: payload[:timestamp] }
69
+ end
70
+ end
71
+ end
72
+ rescue StandardError
73
+ # Thread restarts on the next subscribe call
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -3,8 +3,16 @@
3
3
  module Profiler
4
4
  module Storage
5
5
  class BaseStore
6
+ # Public interface: persists the profile then fires an SSE broadcast.
7
+ # Subclasses implement #do_save, not #save.
6
8
  def save(token, profile)
7
- raise NotImplementedError, "#{self.class} must implement #save"
9
+ result = do_save(token, profile)
10
+ broadcast_event(token, profile)
11
+ result
12
+ end
13
+
14
+ def do_save(token, profile)
15
+ raise NotImplementedError, "#{self.class} must implement #do_save"
8
16
  end
9
17
 
10
18
  def load(token)
@@ -36,6 +44,15 @@ module Profiler
36
44
  def clear(type: nil)
37
45
  raise NotImplementedError, "#{self.class} must implement #clear"
38
46
  end
47
+
48
+ private
49
+
50
+ def broadcast_event(token, profile)
51
+ collectors = profile.collectors_data.keys
52
+ Profiler::SSE.current.broadcast(token, collectors)
53
+ rescue StandardError
54
+ # Never let a broadcast failure prevent profile persistence
55
+ end
39
56
  end
40
57
  end
41
58
  end
@@ -17,7 +17,7 @@ module Profiler
17
17
  ensure_directory_exists
18
18
  end
19
19
 
20
- def save(token, profile)
20
+ def do_save(token, profile)
21
21
  file_path = profile_file_path(token)
22
22
  File.write(file_path, profile.to_json)
23
23
  cleanup_if_needed
@@ -12,7 +12,7 @@ module Profiler
12
12
  @max_profiles = options[:max_profiles] || Profiler.configuration.max_profiles || 100
13
13
  end
14
14
 
15
- def save(token, profile)
15
+ def do_save(token, profile)
16
16
  cleanup_if_needed
17
17
  @profiles[token] = serialize_profile(profile)
18
18
  token
@@ -10,13 +10,15 @@ module Profiler
10
10
  class RedisStore < BaseStore
11
11
  DEFAULT_TTL = 24 * 60 * 60 # 24 hours
12
12
 
13
+ attr_reader :redis
14
+
13
15
  def initialize(options = {})
14
16
  @redis = options[:redis] || build_redis_client(options)
15
17
  @ttl = options[:ttl] || DEFAULT_TTL
16
18
  @key_prefix = options[:key_prefix] || "profiler"
17
19
  end
18
20
 
19
- def save(token, profile)
21
+ def do_save(token, profile)
20
22
  key = profile_key(token)
21
23
  @redis.setex(key, @ttl, profile.to_json)
22
24
 
@@ -28,7 +28,7 @@ module Profiler
28
28
  migrate!
29
29
  end
30
30
 
31
- def save(token, profile)
31
+ def do_save(token, profile)
32
32
  data = profile.to_h
33
33
 
34
34
  collectors_meta = {}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.27.1"
4
+ VERSION = "0.29.0"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -107,5 +107,8 @@ require_relative "profiler/collectors/mailer_collector"
107
107
 
108
108
  require_relative "profiler/env_override_store"
109
109
  require_relative "profiler/instrumentation/thread_context_propagation"
110
+ require_relative "profiler/sse/event_bus"
111
+ require_relative "profiler/sse/redis_event_bus"
112
+ require_relative "profiler/sse/bus"
110
113
  require_relative "profiler/railtie" if defined?(Rails::Railtie)
111
114
  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.27.1
4
+ version: 0.29.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-06-06 00:00:00.000000000 Z
11
+ date: 2026-06-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -109,6 +109,7 @@ files:
109
109
  - app/controllers/profiler/api/ajax_controller.rb
110
110
  - app/controllers/profiler/api/console_controller.rb
111
111
  - app/controllers/profiler/api/env_vars_controller.rb
112
+ - app/controllers/profiler/api/events_controller.rb
112
113
  - app/controllers/profiler/api/explain_controller.rb
113
114
  - app/controllers/profiler/api/function_profiling_controller.rb
114
115
  - app/controllers/profiler/api/jobs_controller.rb
@@ -198,6 +199,9 @@ files:
198
199
  - lib/profiler/models/sql_query.rb
199
200
  - lib/profiler/models/timeline_event.rb
200
201
  - lib/profiler/railtie.rb
202
+ - lib/profiler/sse/bus.rb
203
+ - lib/profiler/sse/event_bus.rb
204
+ - lib/profiler/sse/redis_event_bus.rb
201
205
  - lib/profiler/storage/base_store.rb
202
206
  - lib/profiler/storage/blob_store.rb
203
207
  - lib/profiler/storage/file_store.rb