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 +4 -4
- data/app/assets/builds/profiler-toolbar.js +42 -0
- data/app/assets/builds/profiler.js +27 -26
- data/app/controllers/profiler/api/events_controller.rb +35 -0
- data/config/routes.rb +1 -0
- data/lib/profiler/configuration.rb +2 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +5 -3
- data/lib/profiler/sse/bus.rb +13 -0
- data/lib/profiler/sse/event_bus.rb +47 -0
- data/lib/profiler/sse/redis_event_bus.rb +79 -0
- data/lib/profiler/storage/base_store.rb +18 -1
- data/lib/profiler/storage/file_store.rb +1 -1
- data/lib/profiler/storage/memory_store.rb +1 -1
- data/lib/profiler/storage/redis_store.rb +3 -1
- data/lib/profiler/storage/sqlite_store.rb +1 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +3 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5388819e53342bdba701e37c877cddf9b2e6b888fa163198ef7760b689bc8796
|
|
4
|
+
data.tar.gz: ca44a3e2c623ab6d46fec1fef941e76fb7941a1c77e5261077f0585ab6e70b53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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, {
|
|
9232
|
-
activeTab === "timeline" && /* @__PURE__ */ u3(FlameGraphTab, {
|
|
9233
|
-
activeTab === "dump" && hasDumps && /* @__PURE__ */ u3(DumpsTab, {
|
|
9234
|
-
activeTab === "logs" && hasLogs && /* @__PURE__ */ u3(LogsTab, {
|
|
9235
|
-
activeTab === "exception" && hasException && /* @__PURE__ */ u3(ExceptionTab, {
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
21
|
+
def do_save(token, profile)
|
|
20
22
|
key = profile_key(token)
|
|
21
23
|
@redis.setex(key, @ttl, profile.to_json)
|
|
22
24
|
|
data/lib/profiler/version.rb
CHANGED
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.
|
|
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-
|
|
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
|