rails-profiler 0.21.0 → 0.22.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 +81 -0
- data/app/assets/builds/profiler.js +244 -1
- data/lib/profiler/collectors/mailer_collector.rb +315 -0
- data/lib/profiler/configuration.rb +5 -0
- data/lib/profiler/job_profiler.rb +6 -1
- data/lib/profiler/mcp/server.rb +18 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +51 -0
- data/lib/profiler/mcp/tools/query_mailers.rb +114 -0
- data/lib/profiler/railtie.rb +2 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7bf5cab3285946189ba9adee630f79ad7303ae591aa4364e0fe4d7af320bab9
|
|
4
|
+
data.tar.gz: 4c2ad13691c8c6556355c2c4b2c5934ef2d6aab9d956e2f30d16d92098cd5997
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c846fa98d00dd5e6c5cc5ef8ca443b6f2b02664b9fdee77479bdbf6013d38cdf359e892ba6f08721b54fa5fb13d9b8663b909d7f17f97a849402654c4bf3821
|
|
7
|
+
data.tar.gz: 1697e1bfe8842df4d41aed3a1c5e84b8090d510fab6ee9622afc44e326553c13843a5252b74b34d5d6bb4195629f3d05330953b5527ef7a596aae89bf9a36b11
|
|
@@ -1010,6 +1010,60 @@
|
|
|
1010
1010
|
] });
|
|
1011
1011
|
}
|
|
1012
1012
|
|
|
1013
|
+
// app/assets/typescript/profiler/components/toolbar/panels/MailerPanel.tsx
|
|
1014
|
+
function MailerPanel({ mailerData }) {
|
|
1015
|
+
const hasErrors = mailerData.failed > 0 || mailerData.loop_warnings.length > 0;
|
|
1016
|
+
return /* @__PURE__ */ u3(k, { children: [
|
|
1017
|
+
/* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-header", children: [
|
|
1018
|
+
"Mailers",
|
|
1019
|
+
/* @__PURE__ */ u3("span", { class: "profiler-float-right", children: [
|
|
1020
|
+
mailerData.total,
|
|
1021
|
+
" email",
|
|
1022
|
+
mailerData.total !== 1 ? "s" : ""
|
|
1023
|
+
] })
|
|
1024
|
+
] }),
|
|
1025
|
+
/* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-content", children: [
|
|
1026
|
+
mailerData.deliver_now > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
|
|
1027
|
+
/* @__PURE__ */ u3("span", { children: "deliver_now" }),
|
|
1028
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.deliver_now })
|
|
1029
|
+
] }),
|
|
1030
|
+
mailerData.deliver_later > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
|
|
1031
|
+
/* @__PURE__ */ u3("span", { children: "deliver_later" }),
|
|
1032
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.deliver_later })
|
|
1033
|
+
] }),
|
|
1034
|
+
(mailerData.queued_count ?? 0) > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
|
|
1035
|
+
/* @__PURE__ */ u3("span", { children: "Queued" }),
|
|
1036
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.queued_count })
|
|
1037
|
+
] }),
|
|
1038
|
+
mailerData.multi_part_count > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
|
|
1039
|
+
/* @__PURE__ */ u3("span", { children: "Multi-part" }),
|
|
1040
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.multi_part_count })
|
|
1041
|
+
] }),
|
|
1042
|
+
mailerData.failed > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
|
|
1043
|
+
/* @__PURE__ */ u3("span", { children: "Errors" }),
|
|
1044
|
+
/* @__PURE__ */ u3("strong", { class: "profiler-text--error", children: mailerData.failed })
|
|
1045
|
+
] }),
|
|
1046
|
+
mailerData.loop_warnings.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
|
|
1047
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--warning", children: "\u26A0\uFE0F Loop detected" }),
|
|
1048
|
+
/* @__PURE__ */ u3("strong", { class: "profiler-text--warning", children: mailerData.loop_warnings.length })
|
|
1049
|
+
] }),
|
|
1050
|
+
[...mailerData.emails, ...mailerData.errors].slice(0, 3).map((email, i3) => /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row profiler-text--sm", children: [
|
|
1051
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1052
|
+
email.mailer_class,
|
|
1053
|
+
"#",
|
|
1054
|
+
email.action
|
|
1055
|
+
] }),
|
|
1056
|
+
/* @__PURE__ */ u3("span", { class: email.error ? "profiler-text--error" : "profiler-text--success", children: email.error ? "\u274C" : "\u2705" })
|
|
1057
|
+
] }, i3)),
|
|
1058
|
+
mailerData.total > 3 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row profiler-text--muted profiler-text--xs", children: [
|
|
1059
|
+
"+",
|
|
1060
|
+
mailerData.total - 3,
|
|
1061
|
+
" more\u2026"
|
|
1062
|
+
] })
|
|
1063
|
+
] })
|
|
1064
|
+
] });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1013
1067
|
// app/assets/typescript/profiler/components/toolbar/ToolbarApp.tsx
|
|
1014
1068
|
function statusClass2(status) {
|
|
1015
1069
|
if (status >= 200 && status < 300) return "profiler-text--success";
|
|
@@ -1045,6 +1099,7 @@
|
|
|
1045
1099
|
const routesData = cd["routes"];
|
|
1046
1100
|
const i18nData = cd["i18n"];
|
|
1047
1101
|
const envData = cd["env"];
|
|
1102
|
+
const mailerData = cd["mailer"];
|
|
1048
1103
|
const childJobs = profile.child_jobs ?? [];
|
|
1049
1104
|
const reqClass = statusClass2(profile.status);
|
|
1050
1105
|
const durClass = durationClass(profile.duration);
|
|
@@ -1283,6 +1338,32 @@
|
|
|
1283
1338
|
]
|
|
1284
1339
|
}
|
|
1285
1340
|
),
|
|
1341
|
+
mailerData && (mailerData.total > 0 || (mailerData.queued_count ?? 0) > 0) && /* @__PURE__ */ u3(
|
|
1342
|
+
ToolbarItem,
|
|
1343
|
+
{
|
|
1344
|
+
href: `/_profiler/profiles/${token}?tab=mailer`,
|
|
1345
|
+
className: mailerData.failed > 0 ? "profiler-text--error" : "profiler-text--success",
|
|
1346
|
+
panelLarge: true,
|
|
1347
|
+
panel: /* @__PURE__ */ u3(MailerPanel, { mailerData }),
|
|
1348
|
+
children: [
|
|
1349
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "MAIL" }),
|
|
1350
|
+
mailerData.total > 0 && /* @__PURE__ */ u3("span", { children: mailerData.total }),
|
|
1351
|
+
mailerData.total === 0 && (mailerData.queued_count ?? 0) > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
1352
|
+
mailerData.queued_count,
|
|
1353
|
+
"q"
|
|
1354
|
+
] }),
|
|
1355
|
+
mailerData.total > 0 && (mailerData.queued_count ?? 0) > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: [
|
|
1356
|
+
"+",
|
|
1357
|
+
mailerData.queued_count,
|
|
1358
|
+
"q"
|
|
1359
|
+
] }),
|
|
1360
|
+
mailerData.failed > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--error profiler-text--xs", children: [
|
|
1361
|
+
"\u25B2 ",
|
|
1362
|
+
mailerData.failed
|
|
1363
|
+
] })
|
|
1364
|
+
]
|
|
1365
|
+
}
|
|
1366
|
+
),
|
|
1286
1367
|
envData && envData.total > 0 && /* @__PURE__ */ u3(
|
|
1287
1368
|
ToolbarItem,
|
|
1288
1369
|
{
|
|
@@ -4449,6 +4449,243 @@
|
|
|
4449
4449
|
] });
|
|
4450
4450
|
}
|
|
4451
4451
|
|
|
4452
|
+
// app/assets/typescript/profiler/components/dashboard/tabs/MailerTab.tsx
|
|
4453
|
+
function BodyPreview({ email }) {
|
|
4454
|
+
const hasHtml = !!email.body_html;
|
|
4455
|
+
const hasText = !!email.body_text;
|
|
4456
|
+
if (!email.body_captured) {
|
|
4457
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-mt-3 profiler-text--xs profiler-text--muted", children: [
|
|
4458
|
+
"Body not captured \u2014 enable ",
|
|
4459
|
+
/* @__PURE__ */ u3("code", { children: "config.capture_mail_body = true" }),
|
|
4460
|
+
" in your profiler initializer"
|
|
4461
|
+
] });
|
|
4462
|
+
}
|
|
4463
|
+
if (!hasHtml && !hasText) return null;
|
|
4464
|
+
const [mode, setMode] = d2(hasHtml ? "preview" : "text");
|
|
4465
|
+
const switchMode = (m3) => (e3) => {
|
|
4466
|
+
e3.stopPropagation();
|
|
4467
|
+
setMode(m3);
|
|
4468
|
+
};
|
|
4469
|
+
const activeStyle = { borderColor: "var(--profiler-accent)", color: "var(--profiler-accent)" };
|
|
4470
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-mt-3", children: [
|
|
4471
|
+
/* @__PURE__ */ u3("div", { class: "profiler-mb-2 profiler-flex profiler-flex--gap-2", style: { alignItems: "center" }, children: [
|
|
4472
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "Body" }),
|
|
4473
|
+
hasHtml && /* @__PURE__ */ u3(k, { children: [
|
|
4474
|
+
/* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", style: mode === "preview" ? activeStyle : {}, onClick: switchMode("preview"), children: "HTML preview" }),
|
|
4475
|
+
/* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", style: mode === "source" ? activeStyle : {}, onClick: switchMode("source"), children: "HTML source" })
|
|
4476
|
+
] }),
|
|
4477
|
+
hasText && /* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", style: mode === "text" ? activeStyle : {}, onClick: switchMode("text"), children: "Plain text" })
|
|
4478
|
+
] }),
|
|
4479
|
+
mode === "preview" && hasHtml && /* @__PURE__ */ u3(
|
|
4480
|
+
"iframe",
|
|
4481
|
+
{
|
|
4482
|
+
srcdoc: email.body_html,
|
|
4483
|
+
sandbox: "allow-same-origin",
|
|
4484
|
+
style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
|
|
4485
|
+
}
|
|
4486
|
+
),
|
|
4487
|
+
(mode === "source" || mode === "text") && /* @__PURE__ */ u3("pre", { style: { maxHeight: "300px", overflow: "auto", background: "var(--profiler-bg-lighter, rgba(0,0,0,0.2))", padding: "12px", borderRadius: "var(--profiler-radius-md)", fontSize: "12px", margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-all", border: "1px solid var(--profiler-border)" }, children: mode === "source" ? email.body_html : email.body_text })
|
|
4488
|
+
] });
|
|
4489
|
+
}
|
|
4490
|
+
function AssignsSection({ assigns }) {
|
|
4491
|
+
const entries = Object.entries(assigns);
|
|
4492
|
+
if (entries.length === 0) return null;
|
|
4493
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-mt-3", children: [
|
|
4494
|
+
/* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mb-1", style: { textTransform: "uppercase", letterSpacing: "0.05em" }, children: "Variables" }),
|
|
4495
|
+
entries.map(([key, value]) => /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4496
|
+
/* @__PURE__ */ u3("span", { children: /* @__PURE__ */ u3("code", { children: [
|
|
4497
|
+
"@",
|
|
4498
|
+
key
|
|
4499
|
+
] }) }),
|
|
4500
|
+
/* @__PURE__ */ u3("code", { style: { textAlign: "right", wordBreak: "break-all", maxWidth: "60%" }, children: value })
|
|
4501
|
+
] }, key))
|
|
4502
|
+
] });
|
|
4503
|
+
}
|
|
4504
|
+
function EmailDetail({ email }) {
|
|
4505
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-mt-3", onClick: (e3) => e3.stopPropagation(), children: [
|
|
4506
|
+
email.subject && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4507
|
+
/* @__PURE__ */ u3("span", { children: "Subject" }),
|
|
4508
|
+
/* @__PURE__ */ u3("span", { children: email.subject })
|
|
4509
|
+
] }),
|
|
4510
|
+
email.to && email.to.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4511
|
+
/* @__PURE__ */ u3("span", { children: "To" }),
|
|
4512
|
+
/* @__PURE__ */ u3("span", { style: { textAlign: "right", wordBreak: "break-all" }, children: email.to.join(", ") })
|
|
4513
|
+
] }),
|
|
4514
|
+
email.from && email.from.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4515
|
+
/* @__PURE__ */ u3("span", { children: "From" }),
|
|
4516
|
+
/* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.from.join(", ") })
|
|
4517
|
+
] }),
|
|
4518
|
+
email.cc && email.cc.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4519
|
+
/* @__PURE__ */ u3("span", { children: "CC" }),
|
|
4520
|
+
/* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.cc.join(", ") })
|
|
4521
|
+
] }),
|
|
4522
|
+
email.bcc && email.bcc.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4523
|
+
/* @__PURE__ */ u3("span", { children: "BCC" }),
|
|
4524
|
+
/* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.bcc.join(", ") })
|
|
4525
|
+
] }),
|
|
4526
|
+
email.reply_to && email.reply_to.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4527
|
+
/* @__PURE__ */ u3("span", { children: "Reply-To" }),
|
|
4528
|
+
/* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.reply_to.join(", ") })
|
|
4529
|
+
] }),
|
|
4530
|
+
email.template && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4531
|
+
/* @__PURE__ */ u3("span", { children: "Template" }),
|
|
4532
|
+
/* @__PURE__ */ u3("code", { children: email.template })
|
|
4533
|
+
] }),
|
|
4534
|
+
email.parts && email.parts.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4535
|
+
/* @__PURE__ */ u3("span", { children: "Parts" }),
|
|
4536
|
+
/* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.parts.join(", ") })
|
|
4537
|
+
] }),
|
|
4538
|
+
email.attachments && email.attachments.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4539
|
+
/* @__PURE__ */ u3("span", { children: "Attachments" }),
|
|
4540
|
+
/* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.attachments.map((a3) => `${a3.filename} (${(a3.size / 1024).toFixed(1)} KB)`).join(", ") })
|
|
4541
|
+
] }),
|
|
4542
|
+
/* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4543
|
+
/* @__PURE__ */ u3("span", { children: "Render" }),
|
|
4544
|
+
/* @__PURE__ */ u3("span", { class: "profiler-query-card__duration", children: email.duration_ms != null ? `${email.duration_ms} ms` : "\u2014" })
|
|
4545
|
+
] }),
|
|
4546
|
+
email.delivery_ms != null && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4547
|
+
/* @__PURE__ */ u3("span", { children: "Delivery" }),
|
|
4548
|
+
/* @__PURE__ */ u3("span", { class: "profiler-query-card__duration", children: [
|
|
4549
|
+
email.delivery_ms,
|
|
4550
|
+
" ms"
|
|
4551
|
+
] })
|
|
4552
|
+
] }),
|
|
4553
|
+
email.message_id && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4554
|
+
/* @__PURE__ */ u3("span", { children: "Message-ID" }),
|
|
4555
|
+
/* @__PURE__ */ u3("code", { style: { fontSize: "11px", wordBreak: "break-all", maxWidth: "70%", textAlign: "right" }, children: email.message_id })
|
|
4556
|
+
] }),
|
|
4557
|
+
email.error && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
4558
|
+
/* @__PURE__ */ u3("span", { children: "Error" }),
|
|
4559
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--error", style: { textAlign: "right" }, children: email.error })
|
|
4560
|
+
] }),
|
|
4561
|
+
email.assigns && Object.keys(email.assigns).length > 0 && /* @__PURE__ */ u3(AssignsSection, { assigns: email.assigns }),
|
|
4562
|
+
/* @__PURE__ */ u3(BodyPreview, { email })
|
|
4563
|
+
] });
|
|
4564
|
+
}
|
|
4565
|
+
function EmailRow({ email, onClick, isExpanded }) {
|
|
4566
|
+
const cardClass = ["profiler-query-card", email.error ? "profiler-query-card--slow" : ""].filter(Boolean).join(" ");
|
|
4567
|
+
return /* @__PURE__ */ u3("div", { class: cardClass, style: { cursor: "pointer", marginBottom: "6px" }, onClick, children: [
|
|
4568
|
+
/* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
|
|
4569
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", style: { minWidth: 0, flex: 1 }, children: /* @__PURE__ */ u3("span", { class: "profiler-text--sm", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [
|
|
4570
|
+
/* @__PURE__ */ u3("strong", { children: email.mailer_class }),
|
|
4571
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
4572
|
+
"#",
|
|
4573
|
+
email.action
|
|
4574
|
+
] }),
|
|
4575
|
+
email.subject && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
4576
|
+
" \u2014 ",
|
|
4577
|
+
email.subject.slice(0, 60),
|
|
4578
|
+
email.subject.length > 60 ? "\u2026" : ""
|
|
4579
|
+
] })
|
|
4580
|
+
] }) }),
|
|
4581
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", style: { flexShrink: 0 }, children: [
|
|
4582
|
+
email.delivery_mode && /* @__PURE__ */ u3("span", { class: "badge-info profiler-text--xs", children: email.delivery_mode }),
|
|
4583
|
+
email.duration_ms != null && /* @__PURE__ */ u3("span", { class: "profiler-query-card__duration profiler-text--xs", children: [
|
|
4584
|
+
email.duration_ms,
|
|
4585
|
+
" ms"
|
|
4586
|
+
] }),
|
|
4587
|
+
email.error ? /* @__PURE__ */ u3("span", { class: "badge-error profiler-text--xs", children: "Error" }) : /* @__PURE__ */ u3("span", { class: "badge-success profiler-text--xs", children: "Sent" }),
|
|
4588
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: isExpanded ? "\u25B2" : "\u25BC" })
|
|
4589
|
+
] })
|
|
4590
|
+
] }),
|
|
4591
|
+
isExpanded && /* @__PURE__ */ u3(EmailDetail, { email })
|
|
4592
|
+
] });
|
|
4593
|
+
}
|
|
4594
|
+
function QueuedRow({ email }) {
|
|
4595
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-query-card", style: { marginBottom: "6px", borderLeft: "3px solid var(--profiler-info, #38bdf8)" }, children: [
|
|
4596
|
+
/* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
|
|
4597
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--sm", children: [
|
|
4598
|
+
/* @__PURE__ */ u3("strong", { children: email.mailer_class }),
|
|
4599
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
4600
|
+
"#",
|
|
4601
|
+
email.action
|
|
4602
|
+
] })
|
|
4603
|
+
] }),
|
|
4604
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", style: { flexShrink: 0 }, children: [
|
|
4605
|
+
/* @__PURE__ */ u3("span", { class: "badge-info profiler-text--xs", children: "queued" }),
|
|
4606
|
+
email.delivery_method && /* @__PURE__ */ u3("span", { class: "badge-default profiler-text--xs", children: email.delivery_method }),
|
|
4607
|
+
email.duration_ms != null && /* @__PURE__ */ u3("span", { class: "profiler-query-card__duration profiler-text--xs", children: [
|
|
4608
|
+
email.duration_ms,
|
|
4609
|
+
" ms"
|
|
4610
|
+
] })
|
|
4611
|
+
] })
|
|
4612
|
+
] }),
|
|
4613
|
+
email.assigns && Object.keys(email.assigns).length > 0 && /* @__PURE__ */ u3(AssignsSection, { assigns: email.assigns })
|
|
4614
|
+
] });
|
|
4615
|
+
}
|
|
4616
|
+
function MailerTab({ mailerData }) {
|
|
4617
|
+
const [expandedIndex, setExpandedIndex] = d2(null);
|
|
4618
|
+
const [expandedErrorIndex, setExpandedErrorIndex] = d2(null);
|
|
4619
|
+
const queuedCount = mailerData?.queued_count ?? 0;
|
|
4620
|
+
if (!mailerData || mailerData.total === 0 && queuedCount === 0) {
|
|
4621
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No emails sent during this request" }) });
|
|
4622
|
+
}
|
|
4623
|
+
const toggleRow = (i3) => setExpandedIndex(expandedIndex === i3 ? null : i3);
|
|
4624
|
+
const toggleErrorRow = (i3) => setExpandedErrorIndex(expandedErrorIndex === i3 ? null : i3);
|
|
4625
|
+
return /* @__PURE__ */ u3(k, { children: [
|
|
4626
|
+
/* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: "Mailer Deliveries" }),
|
|
4627
|
+
/* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-4 profiler-mb-4 profiler-text--sm", children: [
|
|
4628
|
+
/* @__PURE__ */ u3("span", { children: [
|
|
4629
|
+
"Total: ",
|
|
4630
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.total })
|
|
4631
|
+
] }),
|
|
4632
|
+
mailerData.deliver_now > 0 && /* @__PURE__ */ u3("span", { children: [
|
|
4633
|
+
"deliver_now: ",
|
|
4634
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.deliver_now })
|
|
4635
|
+
] }),
|
|
4636
|
+
mailerData.deliver_later > 0 && /* @__PURE__ */ u3("span", { children: [
|
|
4637
|
+
"deliver_later: ",
|
|
4638
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.deliver_later })
|
|
4639
|
+
] }),
|
|
4640
|
+
queuedCount > 0 && /* @__PURE__ */ u3("span", { children: [
|
|
4641
|
+
"queued: ",
|
|
4642
|
+
/* @__PURE__ */ u3("strong", { children: queuedCount })
|
|
4643
|
+
] }),
|
|
4644
|
+
mailerData.multi_part_count > 0 && /* @__PURE__ */ u3("span", { children: [
|
|
4645
|
+
"Multi-part: ",
|
|
4646
|
+
/* @__PURE__ */ u3("strong", { children: mailerData.multi_part_count })
|
|
4647
|
+
] }),
|
|
4648
|
+
mailerData.failed > 0 && /* @__PURE__ */ u3("span", { children: [
|
|
4649
|
+
"Errors: ",
|
|
4650
|
+
/* @__PURE__ */ u3("strong", { class: "profiler-text--error", children: mailerData.failed })
|
|
4651
|
+
] })
|
|
4652
|
+
] }),
|
|
4653
|
+
mailerData.loop_warnings.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
|
|
4654
|
+
/* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
|
|
4655
|
+
/* @__PURE__ */ u3("div", { style: { flex: 1 }, children: [
|
|
4656
|
+
/* @__PURE__ */ u3("strong", { children: "Send loop detected" }),
|
|
4657
|
+
" \u2014 ",
|
|
4658
|
+
mailerData.loop_warnings.length,
|
|
4659
|
+
" pattern",
|
|
4660
|
+
mailerData.loop_warnings.length > 1 ? "s" : "",
|
|
4661
|
+
mailerData.loop_warnings.map((w3, i3) => /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-1", children: w3.message }, i3))
|
|
4662
|
+
] })
|
|
4663
|
+
] }),
|
|
4664
|
+
mailerData.truncated && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
|
|
4665
|
+
/* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
|
|
4666
|
+
/* @__PURE__ */ u3("span", { children: "Showing first 50 emails only \u2014 additional emails were truncated" })
|
|
4667
|
+
] }),
|
|
4668
|
+
mailerData.emails.length > 0 && /* @__PURE__ */ u3(k, { children: [
|
|
4669
|
+
/* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-4 profiler-mb-3", children: [
|
|
4670
|
+
"Emails",
|
|
4671
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--sm", style: { fontWeight: "normal", marginLeft: "8px" }, children: "click to expand" })
|
|
4672
|
+
] }),
|
|
4673
|
+
mailerData.emails.map((email, i3) => /* @__PURE__ */ u3(EmailRow, { email, onClick: () => toggleRow(i3), isExpanded: expandedIndex === i3 }, i3))
|
|
4674
|
+
] }),
|
|
4675
|
+
mailerData.queued && mailerData.queued.length > 0 && /* @__PURE__ */ u3(k, { children: [
|
|
4676
|
+
/* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-4 profiler-mb-2", children: [
|
|
4677
|
+
"Queued",
|
|
4678
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--sm", style: { fontWeight: "normal", marginLeft: "8px" }, children: "deliver_later \u2014 will be sent in a background job" })
|
|
4679
|
+
] }),
|
|
4680
|
+
mailerData.queued.map((email, i3) => /* @__PURE__ */ u3(QueuedRow, { email }, `q-${i3}`))
|
|
4681
|
+
] }),
|
|
4682
|
+
mailerData.errors.length > 0 && /* @__PURE__ */ u3(k, { children: [
|
|
4683
|
+
/* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-6 profiler-mb-3 profiler-text--error", children: "Delivery Errors" }),
|
|
4684
|
+
mailerData.errors.map((email, i3) => /* @__PURE__ */ u3(EmailRow, { email, onClick: () => toggleErrorRow(i3), isExpanded: expandedErrorIndex === i3 }, `err-${i3}`))
|
|
4685
|
+
] })
|
|
4686
|
+
] });
|
|
4687
|
+
}
|
|
4688
|
+
|
|
4452
4689
|
// app/assets/typescript/profiler/components/dashboard/ProfileDashboard.tsx
|
|
4453
4690
|
function ProfileDashboard({ profile, initialTab, embedded }) {
|
|
4454
4691
|
const cd = profile.collectors_data || {};
|
|
@@ -4459,6 +4696,7 @@
|
|
|
4459
4696
|
const hasRoutes = (cd["routes"]?.total ?? 0) > 0;
|
|
4460
4697
|
const hasI18n = (cd["i18n"]?.total ?? 0) > 0;
|
|
4461
4698
|
const hasJobs = (profile.child_jobs?.length ?? 0) > 0;
|
|
4699
|
+
const hasMailers = (cd["mailer"]?.total ?? 0) > 0;
|
|
4462
4700
|
const [activeTab, setActiveTab] = d2(hasException ? "exception" : initialTab);
|
|
4463
4701
|
const handleTabClick = (tab) => (e3) => {
|
|
4464
4702
|
e3.preventDefault();
|
|
@@ -4530,6 +4768,7 @@
|
|
|
4530
4768
|
profile.child_jobs.length,
|
|
4531
4769
|
")"
|
|
4532
4770
|
] }),
|
|
4771
|
+
hasMailers && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("mailer"), onClick: handleTabClick("mailer"), children: "Mailers" }),
|
|
4533
4772
|
/* @__PURE__ */ u3("a", { href: "#", class: tabClass("env"), onClick: handleTabClick("env"), children: "Env" })
|
|
4534
4773
|
] }),
|
|
4535
4774
|
/* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
|
|
@@ -4546,6 +4785,7 @@
|
|
|
4546
4785
|
activeTab === "routes" && /* @__PURE__ */ u3(RoutesTab, { routesData: cd["routes"] }),
|
|
4547
4786
|
activeTab === "i18n" && /* @__PURE__ */ u3(I18nTab, { i18nData: cd["i18n"] }),
|
|
4548
4787
|
activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs }),
|
|
4788
|
+
activeTab === "mailer" && /* @__PURE__ */ u3(MailerTab, { mailerData: cd["mailer"] }),
|
|
4549
4789
|
activeTab === "env" && /* @__PURE__ */ u3(EnvTab, { envData: cd["env"], readOnly: true })
|
|
4550
4790
|
] })
|
|
4551
4791
|
] }),
|
|
@@ -4615,7 +4855,8 @@
|
|
|
4615
4855
|
const hasDumps = (cd["dump"]?.count ?? 0) > 0;
|
|
4616
4856
|
const hasLogs = (cd["logs"]?.total ?? 0) > 0;
|
|
4617
4857
|
const hasException = !!cd["exception"]?.exception_class;
|
|
4618
|
-
const
|
|
4858
|
+
const hasMailers = (cd["mailer"]?.total ?? 0) > 0;
|
|
4859
|
+
const validTabs = ["job", "database", "cache", "http", "jobs", "dump", "logs", "exception", "env", "timeline", "mailer"];
|
|
4619
4860
|
const defaultTab = validTabs.includes(initialTab) ? initialTab : "job";
|
|
4620
4861
|
const [activeTab, setActiveTab] = d2(hasException ? "exception" : defaultTab);
|
|
4621
4862
|
const jobData = cd["job"];
|
|
@@ -4706,6 +4947,7 @@
|
|
|
4706
4947
|
")"
|
|
4707
4948
|
] }),
|
|
4708
4949
|
hasLogs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("logs"), onClick: handleTabClick("logs"), children: "Logs" }),
|
|
4950
|
+
hasMailers && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("mailer"), onClick: handleTabClick("mailer"), children: "Mailers" }),
|
|
4709
4951
|
/* @__PURE__ */ u3("a", { href: "#", class: tabClass("env"), onClick: handleTabClick("env"), children: "Env" })
|
|
4710
4952
|
] }),
|
|
4711
4953
|
/* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
|
|
@@ -4718,6 +4960,7 @@
|
|
|
4718
4960
|
activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs }),
|
|
4719
4961
|
activeTab === "dump" && /* @__PURE__ */ u3(DumpsTab, { dumpData: cd["dump"] }),
|
|
4720
4962
|
activeTab === "logs" && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
|
|
4963
|
+
activeTab === "mailer" && /* @__PURE__ */ u3(MailerTab, { mailerData: cd["mailer"] }),
|
|
4721
4964
|
activeTab === "env" && /* @__PURE__ */ u3(EnvTab, { envData: cd["env"], readOnly: true })
|
|
4722
4965
|
] })
|
|
4723
4966
|
] }),
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Collectors
|
|
7
|
+
class MailerCollector < BaseCollector
|
|
8
|
+
MAX_EMAILS = 50
|
|
9
|
+
MAX_BODY_SIZE = 100 * 1024 # 100 KB
|
|
10
|
+
|
|
11
|
+
def initialize(profile)
|
|
12
|
+
super
|
|
13
|
+
@emails = []
|
|
14
|
+
@errors = []
|
|
15
|
+
@queued = []
|
|
16
|
+
@loop_warnings = []
|
|
17
|
+
@subscriptions = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def icon
|
|
21
|
+
"✉️"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def priority
|
|
25
|
+
45
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tab_config
|
|
29
|
+
{
|
|
30
|
+
key: "mailer",
|
|
31
|
+
label: "Mailers",
|
|
32
|
+
icon: icon,
|
|
33
|
+
priority: priority,
|
|
34
|
+
enabled: true,
|
|
35
|
+
default_active: false
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def subscribe
|
|
40
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
41
|
+
return unless Profiler.configuration.track_mailers
|
|
42
|
+
|
|
43
|
+
# Capture the subscriber thread so notifications from other threads (e.g. async
|
|
44
|
+
# job threads delivering mail enqueued via deliver_later) are ignored by this collector.
|
|
45
|
+
@subscriber_thread = Thread.current
|
|
46
|
+
|
|
47
|
+
# Use a stack so multiple deliver_later calls in the same request are all tracked.
|
|
48
|
+
Thread.current[:profiler_pending_processes] ||= []
|
|
49
|
+
|
|
50
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process.action_mailer") do |_name, started, finished, _id, payload|
|
|
51
|
+
next unless Thread.current.equal?(@subscriber_thread)
|
|
52
|
+
next if rails_preview_request?
|
|
53
|
+
|
|
54
|
+
mailer_class = payload[:mailer].to_s
|
|
55
|
+
action = payload[:action].to_s
|
|
56
|
+
(Thread.current[:profiler_pending_processes] ||= []) << {
|
|
57
|
+
mailer_class: mailer_class,
|
|
58
|
+
action: action,
|
|
59
|
+
duration_ms: ((finished - started) * 1000).round(2),
|
|
60
|
+
assigns: extract_assigns(mailer_class, action, payload[:args])
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("deliver.action_mailer") do |_name, started, finished, _id, payload|
|
|
65
|
+
next unless Thread.current.equal?(@subscriber_thread)
|
|
66
|
+
next unless payload[:perform_deliveries]
|
|
67
|
+
next if rails_preview_request?
|
|
68
|
+
|
|
69
|
+
delivery_ms = ((finished - started) * 1000).round(2)
|
|
70
|
+
process_info = (Thread.current[:profiler_pending_processes] ||= []).pop || {}
|
|
71
|
+
mail = payload[:mail]
|
|
72
|
+
|
|
73
|
+
mailer_class = process_info[:mailer_class] || payload[:mailer_class].to_s
|
|
74
|
+
action = process_info[:action].to_s
|
|
75
|
+
config = Profiler.configuration
|
|
76
|
+
next if config.mailer_skip_actions.any? { |a| a == "#{mailer_class}##{action}" || a == mailer_class }
|
|
77
|
+
|
|
78
|
+
delivery_mode = mail_delivery_job? ? "deliver_later" : "deliver_now"
|
|
79
|
+
email = build_email_record(payload, mail, process_info, delivery_ms, delivery_mode)
|
|
80
|
+
email[:error] ? @errors << email : @emails << email
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
@errors << { error: e.message, triggered_at: Time.now.utc.iso8601(3) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def collect
|
|
87
|
+
# Any remaining pending process entries had no matching deliver event:
|
|
88
|
+
# they were enqueued via deliver_later in the HTTP context.
|
|
89
|
+
pending = Thread.current[:profiler_pending_processes] || []
|
|
90
|
+
Thread.current[:profiler_pending_processes] = nil
|
|
91
|
+
|
|
92
|
+
pending.each do |info|
|
|
93
|
+
@queued << {
|
|
94
|
+
mailer_class: info[:mailer_class],
|
|
95
|
+
action: info[:action],
|
|
96
|
+
delivery_mode: "deliver_later",
|
|
97
|
+
delivery_method: extract_delivery_method,
|
|
98
|
+
duration_ms: info[:duration_ms],
|
|
99
|
+
assigns: info[:assigns],
|
|
100
|
+
body_captured: false,
|
|
101
|
+
triggered_at: Time.now.utc.iso8601(3)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
|
|
106
|
+
|
|
107
|
+
detect_loops
|
|
108
|
+
|
|
109
|
+
truncated = @emails.size > MAX_EMAILS
|
|
110
|
+
emails = @emails.first(MAX_EMAILS)
|
|
111
|
+
|
|
112
|
+
store_data(
|
|
113
|
+
emails: emails.map { |e| e.transform_keys(&:to_s) },
|
|
114
|
+
errors: @errors.map { |e| e.transform_keys(&:to_s) },
|
|
115
|
+
queued: @queued.map { |e| e.transform_keys(&:to_s) },
|
|
116
|
+
loop_warnings: @loop_warnings.map { |w| w.transform_keys(&:to_s) },
|
|
117
|
+
total: @emails.size + @errors.size,
|
|
118
|
+
queued_count: @queued.size,
|
|
119
|
+
deliver_now: @emails.count { |e| e[:delivery_mode] == "deliver_now" },
|
|
120
|
+
deliver_later: @emails.count { |e| e[:delivery_mode] == "deliver_later" },
|
|
121
|
+
multi_part_count: @emails.count { |e| e[:parts]&.size.to_i > 1 },
|
|
122
|
+
failed: @errors.size,
|
|
123
|
+
truncated: truncated
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def has_data?
|
|
128
|
+
@emails.any? || @errors.any? || @queued.any?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def toolbar_summary
|
|
132
|
+
total = @emails.size + @errors.size
|
|
133
|
+
queued = @queued.size
|
|
134
|
+
return { text: "0 emails", color: "gray" } if total == 0 && queued == 0
|
|
135
|
+
|
|
136
|
+
now_count = @emails.count { |e| e[:delivery_mode] == "deliver_now" }
|
|
137
|
+
later_count = @emails.count { |e| e[:delivery_mode] == "deliver_later" }
|
|
138
|
+
has_errors = @errors.any? || @loop_warnings.any?
|
|
139
|
+
|
|
140
|
+
parts = []
|
|
141
|
+
parts << "#{now_count} now" if now_count > 0
|
|
142
|
+
parts << "#{later_count} later" if later_count > 0
|
|
143
|
+
parts << "#{queued} queued" if queued > 0
|
|
144
|
+
detail = parts.any? ? " (#{parts.join(" · ")})" : ""
|
|
145
|
+
|
|
146
|
+
text = "#{total + queued} email#{(total + queued) > 1 ? "s" : ""}#{detail}"
|
|
147
|
+
text += " ⚠️ #{@errors.size} error#{@errors.size > 1 ? "s" : ""}" if @errors.any?
|
|
148
|
+
|
|
149
|
+
{ text: text, color: has_errors ? "red" : "green" }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def mail_delivery_job?
|
|
155
|
+
Thread.current[:profiler_current_job_class].to_s.end_with?("MailDeliveryJob")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_email_record(payload, mail, process_info, delivery_ms, delivery_mode = "deliver_now")
|
|
159
|
+
config = Profiler.configuration
|
|
160
|
+
mailer_class = process_info[:mailer_class] || payload[:mailer_class].to_s
|
|
161
|
+
action = process_info[:action]
|
|
162
|
+
|
|
163
|
+
# In Rails 7+, payload[:mail] is the encoded string (mail.encoded), not a Mail::Message.
|
|
164
|
+
# Parse it only for attributes not available in the payload (parts, attachments, reply_to, body).
|
|
165
|
+
mail_obj = parse_mail(mail)
|
|
166
|
+
|
|
167
|
+
to_list = sanitize_recipients(Array(payload[:to]), config)
|
|
168
|
+
body_html, body_text = config.capture_mail_body ? extract_body(mail_obj) : [nil, nil]
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
mailer_class: mailer_class,
|
|
172
|
+
action: action,
|
|
173
|
+
subject: payload[:subject],
|
|
174
|
+
to: to_list,
|
|
175
|
+
from: Array(payload[:from]),
|
|
176
|
+
cc: Array(payload[:cc]),
|
|
177
|
+
bcc: Array(payload[:bcc]),
|
|
178
|
+
reply_to: Array(mail_obj&.reply_to),
|
|
179
|
+
message_id: payload[:message_id],
|
|
180
|
+
delivery_method: extract_delivery_method,
|
|
181
|
+
delivery_mode: delivery_mode,
|
|
182
|
+
duration_ms: process_info[:duration_ms],
|
|
183
|
+
delivery_ms: delivery_ms,
|
|
184
|
+
parts: extract_parts(mail_obj),
|
|
185
|
+
attachments: extract_attachments(mail_obj),
|
|
186
|
+
template: action ? "#{to_path(mailer_class)}/#{action}" : nil,
|
|
187
|
+
assigns: process_info[:assigns] || {},
|
|
188
|
+
body_captured: !body_html.nil? || !body_text.nil?,
|
|
189
|
+
body_html: body_html,
|
|
190
|
+
body_text: body_text,
|
|
191
|
+
error: nil,
|
|
192
|
+
triggered_at: Time.now.utc.iso8601(3)
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def extract_assigns(mailer_class, action, args)
|
|
197
|
+
return {} if action.nil? || action.empty? || args.nil?
|
|
198
|
+
|
|
199
|
+
klass = Object.const_get(mailer_class)
|
|
200
|
+
params = klass.instance_method(action).parameters
|
|
201
|
+
params.each_with_index.each_with_object({}) do |((_, name), i), h|
|
|
202
|
+
h[name.to_s] = serialize_assign(args[i])
|
|
203
|
+
end
|
|
204
|
+
rescue StandardError
|
|
205
|
+
{}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def extract_body(mail)
|
|
209
|
+
return [nil, nil] unless mail.respond_to?(:multipart?)
|
|
210
|
+
|
|
211
|
+
html = text = nil
|
|
212
|
+
if mail.multipart?
|
|
213
|
+
html_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/html") }
|
|
214
|
+
text_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/plain") }
|
|
215
|
+
html = html_part&.body&.decoded&.then { |b| b[0, MAX_BODY_SIZE] }
|
|
216
|
+
text = text_part&.body&.decoded&.then { |b| b[0, MAX_BODY_SIZE] }
|
|
217
|
+
elsif mail.content_type.to_s.start_with?("text/html")
|
|
218
|
+
html = mail.body.decoded[0, MAX_BODY_SIZE]
|
|
219
|
+
else
|
|
220
|
+
text = mail.body.decoded[0, MAX_BODY_SIZE]
|
|
221
|
+
end
|
|
222
|
+
[html.nil? || html.empty? ? nil : html, text.nil? || text.empty? ? nil : text]
|
|
223
|
+
rescue StandardError
|
|
224
|
+
[nil, nil]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def serialize_assign(value)
|
|
228
|
+
return "nil" if value.nil?
|
|
229
|
+
|
|
230
|
+
inspected = value.inspect
|
|
231
|
+
inspected.length > 300 ? "#{inspected[0, 300]}…" : inspected
|
|
232
|
+
rescue StandardError
|
|
233
|
+
value.to_s
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def parse_mail(mail)
|
|
237
|
+
return mail if mail.respond_to?(:multipart?)
|
|
238
|
+
return nil unless mail.is_a?(String)
|
|
239
|
+
|
|
240
|
+
Mail.new(mail)
|
|
241
|
+
rescue StandardError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def sanitize_recipients(recipients, config)
|
|
246
|
+
return recipients unless config.respond_to?(:sanitize_mailer_recipients) && config.sanitize_mailer_recipients
|
|
247
|
+
|
|
248
|
+
recipients.map do |addr|
|
|
249
|
+
local, domain = addr.to_s.split("@", 2)
|
|
250
|
+
domain ? "#{local[0]}***@#{domain.gsub(/[^.]+(?=\.)/, "***")}" : addr
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def extract_delivery_method
|
|
255
|
+
return "unknown" unless defined?(ActionMailer::Base)
|
|
256
|
+
|
|
257
|
+
ActionMailer::Base.delivery_method.to_s
|
|
258
|
+
rescue StandardError
|
|
259
|
+
"unknown"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def extract_parts(mail)
|
|
263
|
+
return [] unless mail
|
|
264
|
+
|
|
265
|
+
if mail.multipart?
|
|
266
|
+
mail.parts.map { |p| p.content_type.to_s.split(";").first }
|
|
267
|
+
elsif mail.content_type
|
|
268
|
+
[mail.content_type.to_s.split(";").first]
|
|
269
|
+
else
|
|
270
|
+
[]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def extract_attachments(mail)
|
|
275
|
+
return [] unless mail
|
|
276
|
+
|
|
277
|
+
(mail.attachments || []).map do |att|
|
|
278
|
+
{
|
|
279
|
+
"filename" => att.filename.to_s,
|
|
280
|
+
"size" => att.body.decoded.bytesize
|
|
281
|
+
}
|
|
282
|
+
rescue StandardError
|
|
283
|
+
{ "filename" => att.filename.to_s, "size" => 0 }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def detect_loops
|
|
288
|
+
counts = Hash.new(0)
|
|
289
|
+
@emails.each { |e| counts["#{e[:mailer_class]}##{e[:action]}"] += 1 }
|
|
290
|
+
|
|
291
|
+
counts.each do |key, count|
|
|
292
|
+
next unless count > 3
|
|
293
|
+
|
|
294
|
+
@loop_warnings << {
|
|
295
|
+
key: key,
|
|
296
|
+
count: count,
|
|
297
|
+
message: "#{key} was called #{count} times in a single request — possible send loop"
|
|
298
|
+
}
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def rails_preview_request?
|
|
303
|
+
path = Thread.current[:profiler_request_path]
|
|
304
|
+
path&.start_with?("/rails/mailers")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def to_path(class_name)
|
|
308
|
+
class_name.to_s
|
|
309
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
310
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
311
|
+
.downcase
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -11,6 +11,7 @@ module Profiler
|
|
|
11
11
|
:track_ajax, :ajax_skip_paths,
|
|
12
12
|
:track_http, :slow_http_threshold, :http_skip_hosts,
|
|
13
13
|
:track_jobs,
|
|
14
|
+
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
14
15
|
:compress_bodies, :compress_body_threshold
|
|
15
16
|
|
|
16
17
|
attr_writer :tmp_path
|
|
@@ -41,6 +42,10 @@ module Profiler
|
|
|
41
42
|
@slow_http_threshold = 500 # milliseconds
|
|
42
43
|
@http_skip_hosts = []
|
|
43
44
|
@track_jobs = true
|
|
45
|
+
@track_mailers = true
|
|
46
|
+
@capture_mail_body = false
|
|
47
|
+
@sanitize_mailer_recipients = false
|
|
48
|
+
@mailer_skip_actions = []
|
|
44
49
|
@compress_bodies = true
|
|
45
50
|
@compress_body_threshold = 10 * 1024 # 10 KB
|
|
46
51
|
@tmp_path = nil
|
|
@@ -11,6 +11,7 @@ require_relative "collectors/log_collector"
|
|
|
11
11
|
require_relative "collectors/exception_collector"
|
|
12
12
|
require_relative "collectors/env_collector"
|
|
13
13
|
require_relative "collectors/flamegraph_collector"
|
|
14
|
+
require_relative "collectors/mailer_collector"
|
|
14
15
|
|
|
15
16
|
module Profiler
|
|
16
17
|
class JobProfiler
|
|
@@ -22,7 +23,8 @@ module Profiler
|
|
|
22
23
|
Collectors::LogCollector,
|
|
23
24
|
Collectors::ExceptionCollector,
|
|
24
25
|
Collectors::EnvCollector,
|
|
25
|
-
Collectors::FlameGraphCollector
|
|
26
|
+
Collectors::FlameGraphCollector,
|
|
27
|
+
Collectors::MailerCollector
|
|
26
28
|
].freeze
|
|
27
29
|
|
|
28
30
|
def self.profile(job_class:, job_id:, queue:, arguments:, executions:, parent_token: nil, &block)
|
|
@@ -74,7 +76,9 @@ module Profiler
|
|
|
74
76
|
error_message = nil
|
|
75
77
|
|
|
76
78
|
previous_token = Profiler::CurrentContext.token
|
|
79
|
+
previous_job_class = Thread.current[:profiler_current_job_class]
|
|
77
80
|
Profiler::CurrentContext.token = profile.token
|
|
81
|
+
Thread.current[:profiler_current_job_class] = @job_class
|
|
78
82
|
begin
|
|
79
83
|
result = block.call
|
|
80
84
|
result
|
|
@@ -84,6 +88,7 @@ module Profiler
|
|
|
84
88
|
exception_collector&.capture(e)
|
|
85
89
|
raise
|
|
86
90
|
ensure
|
|
91
|
+
Thread.current[:profiler_current_job_class] = previous_job_class
|
|
87
92
|
Profiler::CurrentContext.token = previous_token
|
|
88
93
|
if Profiler.configuration.track_memory
|
|
89
94
|
profile.memory = current_memory - memory_before
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -70,6 +70,7 @@ module Profiler
|
|
|
70
70
|
require_relative "tools/get_profile_dumps"
|
|
71
71
|
require_relative "tools/get_profile_http"
|
|
72
72
|
require_relative "tools/query_jobs"
|
|
73
|
+
require_relative "tools/query_mailers"
|
|
73
74
|
require_relative "tools/clear_profiles"
|
|
74
75
|
|
|
75
76
|
[
|
|
@@ -95,7 +96,7 @@ module Profiler
|
|
|
95
96
|
input_schema: {
|
|
96
97
|
properties: {
|
|
97
98
|
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
98
|
-
sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, routes, dumps. Omit for all." },
|
|
99
|
+
sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, mailers, routes, dumps. Omit for all." },
|
|
99
100
|
save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
|
|
100
101
|
max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
|
|
101
102
|
json_path: { type: "string", description: "JSONPath expression to extract from response body (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
|
|
@@ -181,6 +182,22 @@ module Profiler
|
|
|
181
182
|
},
|
|
182
183
|
handler: Tools::QueryJobs
|
|
183
184
|
),
|
|
185
|
+
define_tool(
|
|
186
|
+
name: "query_mailers",
|
|
187
|
+
description: "Search and filter ActionMailer deliveries across profiles. Returns emails sent via deliver_now or deliver_later.",
|
|
188
|
+
input_schema: {
|
|
189
|
+
properties: {
|
|
190
|
+
mailer_class: { type: "string", description: "Filter by mailer class name (partial match, e.g. 'UserMailer')" },
|
|
191
|
+
action: { type: "string", description: "Filter by mailer action name (partial match, e.g. 'welcome_email')" },
|
|
192
|
+
delivery_mode: { type: "string", description: "Filter by delivery mode: 'deliver_now' or 'deliver_later'" },
|
|
193
|
+
has_error: { type: "boolean", description: "Filter to only emails with delivery errors" },
|
|
194
|
+
limit: { type: "number", description: "Maximum number of results (default 20)" },
|
|
195
|
+
fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, profile, mailer, action, subject, to, mode, duration, status, token. Omit for all." },
|
|
196
|
+
cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
handler: Tools::QueryMailers
|
|
200
|
+
),
|
|
184
201
|
define_tool(
|
|
185
202
|
name: "clear_profiles",
|
|
186
203
|
description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job' to clear only requests or jobs.",
|
|
@@ -61,6 +61,7 @@ module Profiler
|
|
|
61
61
|
lines += section_cache(profile) if want.("cache")
|
|
62
62
|
lines += section_ajax(profile) if want.("ajax")
|
|
63
63
|
lines += section_http(profile, params) if want.("http")
|
|
64
|
+
lines += section_mailers(profile) if want.("mailers")
|
|
64
65
|
lines += section_routes(profile) if want.("routes")
|
|
65
66
|
lines += section_dumps(profile) if want.("dumps")
|
|
66
67
|
lines += section_related_jobs(profile) if want.("related_jobs")
|
|
@@ -385,6 +386,56 @@ module Profiler
|
|
|
385
386
|
lines
|
|
386
387
|
end
|
|
387
388
|
|
|
389
|
+
def self.section_mailers(profile)
|
|
390
|
+
lines = []
|
|
391
|
+
mailer_data = profile.collector_data("mailer")
|
|
392
|
+
return lines unless mailer_data && mailer_data["total"].to_i > 0
|
|
393
|
+
|
|
394
|
+
lines << "## Mailers"
|
|
395
|
+
lines << "- Total: #{mailer_data['total']}"
|
|
396
|
+
lines << "- deliver_now: #{mailer_data['deliver_now']}"
|
|
397
|
+
lines << "- deliver_later: #{mailer_data['deliver_later']}"
|
|
398
|
+
lines << "- Errors: #{mailer_data['failed']}"
|
|
399
|
+
lines << "- Multi-part: #{mailer_data['multi_part_count']}"
|
|
400
|
+
lines << "- Truncated: yes (showing first #{Profiler::Collectors::MailerCollector::MAX_EMAILS})" if mailer_data["truncated"]
|
|
401
|
+
|
|
402
|
+
warnings = mailer_data["loop_warnings"] || []
|
|
403
|
+
if warnings.any?
|
|
404
|
+
lines << ""
|
|
405
|
+
lines << "### ⚠️ Loop Warnings"
|
|
406
|
+
warnings.each { |w| lines << "- #{w['message']}" }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
emails = mailer_data["emails"] || []
|
|
410
|
+
if emails.any?
|
|
411
|
+
lines << ""
|
|
412
|
+
lines << "### Emails"
|
|
413
|
+
lines << ""
|
|
414
|
+
lines << "| Mailer | Action | Subject | To | Mode | Duration | Status |"
|
|
415
|
+
lines << "|--------|--------|---------|-----|------|----------|--------|"
|
|
416
|
+
emails.each do |email|
|
|
417
|
+
to_str = Array(email["to"]).first(2).join(", ")
|
|
418
|
+
to_str += ", …" if Array(email["to"]).size > 2
|
|
419
|
+
mode = email["delivery_mode"] || "-"
|
|
420
|
+
duration = email["duration_ms"] ? "#{email["duration_ms"]}ms" : "-"
|
|
421
|
+
status = email["error"] ? "❌ #{email["error"]}" : "✅"
|
|
422
|
+
lines << "| #{email["mailer_class"]} | #{email["action"]} | #{(email["subject"].to_s)[0, 30]} | #{to_str} | #{mode} | #{duration} | #{status} |"
|
|
423
|
+
end
|
|
424
|
+
lines << ""
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
errors = mailer_data["errors"] || []
|
|
428
|
+
if errors.any?
|
|
429
|
+
lines << "### Delivery Errors"
|
|
430
|
+
errors.each do |err|
|
|
431
|
+
lines << "- **#{err["mailer_class"]}##{err["action"]}**: #{err["error"]}"
|
|
432
|
+
end
|
|
433
|
+
lines << ""
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
lines
|
|
437
|
+
end
|
|
438
|
+
|
|
388
439
|
def self.section_routes(profile)
|
|
389
440
|
lines = []
|
|
390
441
|
routes_data = profile.collector_data("routes")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class QueryMailers
|
|
7
|
+
ALL_FIELDS = %w[time mailer action subject to mode duration status token].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call(params)
|
|
11
|
+
limit = params["limit"]&.to_i || 20
|
|
12
|
+
fetch_size = [limit * 10, 1000].min
|
|
13
|
+
profiles = Profiler.storage.list(limit: fetch_size)
|
|
14
|
+
|
|
15
|
+
emails = []
|
|
16
|
+
|
|
17
|
+
profiles.each do |profile|
|
|
18
|
+
mailer_data = profile.collector_data("mailer")
|
|
19
|
+
next unless mailer_data
|
|
20
|
+
|
|
21
|
+
all_emails = Array(mailer_data["emails"]) + Array(mailer_data["errors"])
|
|
22
|
+
all_emails.each do |email|
|
|
23
|
+
entry = email.merge("profile_token" => profile.token, "profile_started_at" => profile.started_at)
|
|
24
|
+
|
|
25
|
+
if params["mailer_class"]
|
|
26
|
+
next unless entry["mailer_class"].to_s.downcase.include?(params["mailer_class"].downcase)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if params["action"]
|
|
30
|
+
next unless entry["action"].to_s.downcase.include?(params["action"].downcase)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if params["delivery_mode"]
|
|
34
|
+
next unless entry["delivery_mode"] == params["delivery_mode"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if params["has_error"]
|
|
38
|
+
has_err = !entry["error"].nil?
|
|
39
|
+
next unless has_err == (params["has_error"] == true || params["has_error"] == "true")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
emails << entry
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
break if emails.size >= limit * 2
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if params["cursor"]
|
|
49
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
50
|
+
if cutoff
|
|
51
|
+
emails = emails.select do |e|
|
|
52
|
+
started = e["profile_started_at"]
|
|
53
|
+
started && started < cutoff
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
emails = emails.first(limit)
|
|
59
|
+
|
|
60
|
+
[{ type: "text", text: format_mailers_table(emails, params["fields"]&.map(&:to_s), limit) }]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def format_mailers_table(emails, fields, limit)
|
|
66
|
+
return "No mailer deliveries found matching the criteria." if emails.empty?
|
|
67
|
+
|
|
68
|
+
fields ||= ALL_FIELDS
|
|
69
|
+
fields = fields & ALL_FIELDS
|
|
70
|
+
|
|
71
|
+
lines = []
|
|
72
|
+
lines << "# Mailer Deliveries\n"
|
|
73
|
+
lines << "Found #{emails.size} email#{emails.size > 1 ? "s" : ""}:\n"
|
|
74
|
+
|
|
75
|
+
header = fields.map { |f| f.split("_").map(&:capitalize).join(" ") }.join(" | ")
|
|
76
|
+
separator = fields.map { "------" }.join("|")
|
|
77
|
+
lines << "| #{header} |"
|
|
78
|
+
lines << "|#{separator}|"
|
|
79
|
+
|
|
80
|
+
emails.each do |email|
|
|
81
|
+
started = email["profile_started_at"]
|
|
82
|
+
to_list = Array(email["to"]).first(2).join(", ")
|
|
83
|
+
to_list += ", …" if Array(email["to"]).size > 2
|
|
84
|
+
status = email["error"] ? "❌ Error" : "✅"
|
|
85
|
+
|
|
86
|
+
row = fields.map do |f|
|
|
87
|
+
case f
|
|
88
|
+
when "time" then started ? started.strftime("%H:%M:%S") : "-"
|
|
89
|
+
when "mailer" then email["mailer_class"].to_s
|
|
90
|
+
when "action" then email["action"].to_s
|
|
91
|
+
when "subject" then (email["subject"].to_s)[0, 40]
|
|
92
|
+
when "to" then to_list
|
|
93
|
+
when "mode" then email["delivery_mode"].to_s
|
|
94
|
+
when "duration" then email["duration_ms"] ? "#{email["duration_ms"]}ms" : "-"
|
|
95
|
+
when "status" then status
|
|
96
|
+
when "token" then email["profile_token"].to_s
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
lines << "| #{row.join(" | ")} |"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if emails.size == limit
|
|
103
|
+
last_started = emails.last["profile_started_at"]
|
|
104
|
+
lines << ""
|
|
105
|
+
lines << "*Next cursor: #{last_started.iso8601}*" if last_started
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
lines.join("\n")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -49,7 +49,8 @@ module Profiler
|
|
|
49
49
|
Profiler::Collectors::LogCollector,
|
|
50
50
|
Profiler::Collectors::RoutesCollector,
|
|
51
51
|
Profiler::Collectors::I18nCollector,
|
|
52
|
-
Profiler::Collectors::EnvCollector
|
|
52
|
+
Profiler::Collectors::EnvCollector,
|
|
53
|
+
Profiler::Collectors::MailerCollector
|
|
53
54
|
]
|
|
54
55
|
end
|
|
55
56
|
end
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -103,6 +103,7 @@ require_relative "profiler/collectors/exception_collector"
|
|
|
103
103
|
require_relative "profiler/collectors/routes_collector"
|
|
104
104
|
require_relative "profiler/collectors/i18n_collector"
|
|
105
105
|
require_relative "profiler/collectors/env_collector"
|
|
106
|
+
require_relative "profiler/collectors/mailer_collector"
|
|
106
107
|
|
|
107
108
|
require_relative "profiler/env_override_store"
|
|
108
109
|
require_relative "profiler/railtie" if defined?(Rails::Railtie)
|
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.22.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-
|
|
11
|
+
date: 2026-05-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- lib/profiler/collectors/i18n_collector.rb
|
|
138
138
|
- lib/profiler/collectors/job_collector.rb
|
|
139
139
|
- lib/profiler/collectors/log_collector.rb
|
|
140
|
+
- lib/profiler/collectors/mailer_collector.rb
|
|
140
141
|
- lib/profiler/collectors/request_collector.rb
|
|
141
142
|
- lib/profiler/collectors/routes_collector.rb
|
|
142
143
|
- lib/profiler/collectors/view_collector.rb
|
|
@@ -165,6 +166,7 @@ files:
|
|
|
165
166
|
- lib/profiler/mcp/tools/get_profile_dumps.rb
|
|
166
167
|
- lib/profiler/mcp/tools/get_profile_http.rb
|
|
167
168
|
- lib/profiler/mcp/tools/query_jobs.rb
|
|
169
|
+
- lib/profiler/mcp/tools/query_mailers.rb
|
|
168
170
|
- lib/profiler/mcp/tools/query_profiles.rb
|
|
169
171
|
- lib/profiler/middleware/cors_middleware.rb
|
|
170
172
|
- lib/profiler/middleware/profiler_middleware.rb
|