simple_apm 1.1.1 → 1.1.2
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/README.md +4 -0
- data/app/assets/javascripts/simple_apm/application.js +94 -0
- data/app/assets/stylesheets/simple_apm/application.css +92 -7
- data/app/controllers/simple_apm/apm_controller.rb +22 -11
- data/app/helpers/simple_apm/application_helper.rb +24 -0
- data/app/models/simple_apm/action.rb +5 -5
- data/app/models/simple_apm/diagnostic_report.rb +203 -0
- data/app/models/simple_apm/sql.rb +1 -1
- data/app/views/layouts/simple_apm/application.html.erb +37 -58
- data/app/views/simple_apm/apm/_diagnostic_export.html.erb +17 -0
- data/app/views/simple_apm/apm/_timings.html.erb +18 -0
- data/app/views/simple_apm/apm/action_info.html.erb +36 -15
- data/app/views/simple_apm/apm/actions.html.erb +40 -21
- data/app/views/simple_apm/apm/dashboard.html.erb +38 -19
- data/app/views/simple_apm/apm/data.html.erb +59 -15
- data/app/views/simple_apm/apm/index.html.erb +45 -17
- data/app/views/simple_apm/apm/missing.html.erb +8 -0
- data/app/views/simple_apm/apm/show.html.erb +46 -21
- data/config/locales/simple_apm.en.yml +71 -2
- data/config/locales/simple_apm.zh-CN.yml +79 -10
- data/config/routes.rb +1 -1
- data/lib/simple_apm/version.rb +1 -1
- data/lib/simple_apm/worker.rb +4 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d98ff928f6478abea71e3375957adc7de6d5a3865019be549026b4d615b58fa0
|
|
4
|
+
data.tar.gz: ee6fbdf30feb8499da5ca323931272b6d270d41ccdf9cadc86213d7da5fd50ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b51b09db54530aac2a6e75058c815b6f7f1f3e7abd09c28b7791723722b699ab5e2a378170b074dc06f25a7baba0ea01c61ca5149e6778343d55a49495be81b
|
|
7
|
+
data.tar.gz: 81fec7c8dbb538bf21928426aa21bfc110300f28e19fd18de1b6cc5784fa0d4237765d50015c02f6985cd7516af1cc71f993fc3433f52eb33ca133c3f83b4fe5
|
data/README.md
CHANGED
|
@@ -13,6 +13,10 @@ SimpleApm records request performance by day:
|
|
|
13
13
|
|
|
14
14
|
The web UI supports English and Chinese. English is used by default, and users can switch languages from the top-right corner.
|
|
15
15
|
|
|
16
|
+
Action and request detail pages include **Copy for AI** with a JSON preview. Request reports include metadata, timings in seconds, memory in MB, retained SQL and external HTTP calls with code locations. Action reports add daily aggregates and the retained fastest, slowest and slow request samples, marking unavailable details explicitly. Common credential fields and SQL string literals are filtered; reports describe collection thresholds and sampling limits. Clipboard copying includes a fallback for HTTP deployments and manual selection when browser copying is unavailable.
|
|
17
|
+
|
|
18
|
+
Lists support search and precise duration sorting. Slow requests offer a sample limit and refresh controls, while date-aware links keep the selected day's context across pages. Data cleanup and collection controls use POST forms with confirmation for destructive operations.
|
|
19
|
+
|
|
16
20
|
## How It Works
|
|
17
21
|
|
|
18
22
|
SimpleApm records request-level data around Rack and uses Redis for storage and aggregation.
|
|
@@ -12,4 +12,98 @@
|
|
|
12
12
|
//
|
|
13
13
|
//= require_tree .
|
|
14
14
|
|
|
15
|
+
(function ($) {
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
var config = JSON.parse(document.getElementById('apm-ui-config').textContent);
|
|
19
|
+
window.SimpleApmUI = {
|
|
20
|
+
tableOptions: function (overrides) {
|
|
21
|
+
return $.extend(true, {language: config.table, pageLength: 25, processing: true}, overrides);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
$.extend($.fn.dataTableExt.oSort, {
|
|
26
|
+
'sec-pre': function (value) {
|
|
27
|
+
var exactSeconds = String(value).match(/data-seconds="([^"]+)"/);
|
|
28
|
+
if (exactSeconds) return parseFloat(exactSeconds[1]) || 0;
|
|
29
|
+
var text = String(value).replace(/<[^>]*>/g, '').replace(/&(?:amp;)?nbsp;/g, '').trim();
|
|
30
|
+
var number = parseFloat(text) || 0;
|
|
31
|
+
if (/ms\b/.test(text)) return number / 1000;
|
|
32
|
+
if (/min\b/.test(text)) return number * 60;
|
|
33
|
+
return number;
|
|
34
|
+
},
|
|
35
|
+
'sec-asc': function (a, b) { return a - b; },
|
|
36
|
+
'sec-desc': function (a, b) { return b - a; }
|
|
37
|
+
});
|
|
38
|
+
$.fn.dataTable.ext.errMode = 'none';
|
|
39
|
+
|
|
40
|
+
function fallbackCopy(text) {
|
|
41
|
+
var input = document.createElement('textarea');
|
|
42
|
+
input.value = text;
|
|
43
|
+
input.setAttribute('readonly', '');
|
|
44
|
+
input.style.position = 'fixed';
|
|
45
|
+
input.style.top = '0';
|
|
46
|
+
input.style.opacity = '0';
|
|
47
|
+
// Keep the temporary input inside a modal so Bootstrap's focus trap allows selection.
|
|
48
|
+
(document.querySelector('.modal.in .apm-modal-body') || document.body).appendChild(input);
|
|
49
|
+
input.select();
|
|
50
|
+
var copied = false;
|
|
51
|
+
try { copied = document.execCommand('copy'); } catch (error) { copied = false; }
|
|
52
|
+
input.remove();
|
|
53
|
+
return copied;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
$(function () {
|
|
57
|
+
$('.select-apm-date select').on('change', function () { this.form.submit(); });
|
|
58
|
+
$(document).on('submit', 'form[data-confirm]', function () {
|
|
59
|
+
return window.confirm(this.getAttribute('data-confirm'));
|
|
60
|
+
});
|
|
61
|
+
$(document).on('click', 'a[data-confirm]', function () {
|
|
62
|
+
return window.confirm(this.getAttribute('data-confirm'));
|
|
63
|
+
});
|
|
64
|
+
$(document).on('error.dt', 'table', function () {
|
|
65
|
+
$(this).closest('.apm-panel').find('.apm-table-feedback').text(config.load_error);
|
|
66
|
+
});
|
|
67
|
+
$(document).on('xhr.dt', 'table', function (event, settings, data) {
|
|
68
|
+
if (data) $(this).closest('.apm-panel').find('.apm-table-feedback').text('');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
$(document).on('click', '.sql', function () {
|
|
72
|
+
$('#sql-modal-text').text(this.textContent);
|
|
73
|
+
$('#sql-modal .apm-copy-status').text('');
|
|
74
|
+
$('#sql-modal').modal('show');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
$(document).on('click', '[data-copy-target]', function () {
|
|
78
|
+
var button = this;
|
|
79
|
+
var target = document.getElementById(button.getAttribute('data-copy-target'));
|
|
80
|
+
var text = target.value === undefined ? target.textContent : target.value;
|
|
81
|
+
var status = $(button).closest('.apm-panel, .apm-modal-panel').find('.apm-copy-status');
|
|
82
|
+
button.disabled = true;
|
|
83
|
+
|
|
84
|
+
function finish(copied) {
|
|
85
|
+
button.disabled = false;
|
|
86
|
+
status.text(copied ? config.copied : config.copy_failed);
|
|
87
|
+
if (copied) {
|
|
88
|
+
button.focus();
|
|
89
|
+
} else if (target.tagName === 'TEXTAREA') {
|
|
90
|
+
$(target).closest('details').prop('open', true);
|
|
91
|
+
target.focus();
|
|
92
|
+
target.select();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
97
|
+
navigator.clipboard.writeText(text).then(function () {
|
|
98
|
+
finish(true);
|
|
99
|
+
}, function () {
|
|
100
|
+
finish(fallbackCopy(text));
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
finish(fallbackCopy(text));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
})(jQuery);
|
|
108
|
+
|
|
15
109
|
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
--apm-brand-dark: #0b4f4a;
|
|
27
27
|
--apm-accent: #2563eb;
|
|
28
28
|
--apm-danger: #dc2626;
|
|
29
|
-
--apm-warning: #
|
|
29
|
+
--apm-warning: #a35b05;
|
|
30
30
|
--apm-success: #15803d;
|
|
31
|
-
--apm-shadow: 0
|
|
31
|
+
--apm-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
body.simple-apm {
|
|
@@ -37,6 +37,7 @@ body.simple-apm {
|
|
|
37
37
|
color: var(--apm-ink);
|
|
38
38
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
|
39
39
|
font-size: 14px;
|
|
40
|
+
line-height: 1.6;
|
|
40
41
|
letter-spacing: 0;
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -106,7 +107,7 @@ body.simple-apm {
|
|
|
106
107
|
.apm-nav .navbar-brand {
|
|
107
108
|
float: none;
|
|
108
109
|
height: auto;
|
|
109
|
-
padding:
|
|
110
|
+
padding: 13px 12px;
|
|
110
111
|
color: #cbd5e1;
|
|
111
112
|
font-size: 13px;
|
|
112
113
|
line-height: 18px;
|
|
@@ -200,6 +201,7 @@ body.simple-apm {
|
|
|
200
201
|
margin: 7px 0 0;
|
|
201
202
|
color: var(--apm-muted);
|
|
202
203
|
font-size: 13px;
|
|
204
|
+
overflow-wrap: anywhere;
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
.apm-toolbar {
|
|
@@ -242,6 +244,8 @@ body.simple-apm {
|
|
|
242
244
|
margin-top: 18px;
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
.apm-grid > .apm-panel { margin-top: 0; }
|
|
248
|
+
|
|
245
249
|
.apm-panel-header {
|
|
246
250
|
display: flex;
|
|
247
251
|
align-items: center;
|
|
@@ -302,6 +306,7 @@ body.simple-apm {
|
|
|
302
306
|
line-height: 1.2;
|
|
303
307
|
font-weight: 700;
|
|
304
308
|
word-break: break-word;
|
|
309
|
+
font-variant-numeric: tabular-nums;
|
|
305
310
|
}
|
|
306
311
|
|
|
307
312
|
.apm-stat-meta {
|
|
@@ -359,6 +364,11 @@ body.simple-apm {
|
|
|
359
364
|
border-top: 1px solid #eef2f7;
|
|
360
365
|
color: #243044;
|
|
361
366
|
vertical-align: middle;
|
|
367
|
+
font-variant-numeric: tabular-nums;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.simple-apm table.table tbody tr:hover td {
|
|
371
|
+
background: var(--apm-surface-soft);
|
|
362
372
|
}
|
|
363
373
|
|
|
364
374
|
.simple-apm table.table-bordered {
|
|
@@ -415,6 +425,11 @@ body.simple-apm {
|
|
|
415
425
|
.simple-apm .btn {
|
|
416
426
|
border-radius: 6px;
|
|
417
427
|
font-weight: 600;
|
|
428
|
+
min-height: 40px;
|
|
429
|
+
display: inline-flex;
|
|
430
|
+
align-items: center;
|
|
431
|
+
justify-content: center;
|
|
432
|
+
padding: 8px 14px;
|
|
418
433
|
}
|
|
419
434
|
|
|
420
435
|
.simple-apm a.btn,
|
|
@@ -501,6 +516,10 @@ pre {
|
|
|
501
516
|
padding: 14px 18px;
|
|
502
517
|
border-bottom: 1px solid var(--apm-border);
|
|
503
518
|
background: var(--apm-surface-soft);
|
|
519
|
+
display: flex;
|
|
520
|
+
align-items: center;
|
|
521
|
+
justify-content: space-between;
|
|
522
|
+
gap: 12px;
|
|
504
523
|
}
|
|
505
524
|
|
|
506
525
|
.apm-modal-title {
|
|
@@ -547,6 +566,62 @@ pre {
|
|
|
547
566
|
color: #475467;
|
|
548
567
|
}
|
|
549
568
|
|
|
569
|
+
.simple-apm :focus-visible {
|
|
570
|
+
outline: 3px solid var(--apm-accent);
|
|
571
|
+
outline-offset: 3px;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.apm-skip-link {
|
|
575
|
+
position: fixed;
|
|
576
|
+
top: -100px;
|
|
577
|
+
left: 16px;
|
|
578
|
+
z-index: 1100;
|
|
579
|
+
padding: 12px;
|
|
580
|
+
background: var(--apm-surface);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
.apm-skip-link:focus { top: 8px; }
|
|
584
|
+
|
|
585
|
+
.apm-date-form {
|
|
586
|
+
display: flex;
|
|
587
|
+
align-items: center;
|
|
588
|
+
gap: 8px;
|
|
589
|
+
width: 100%;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.apm-date-form label { margin: 0; white-space: nowrap; }
|
|
593
|
+
|
|
594
|
+
.simple-apm a.apm-button-secondary,
|
|
595
|
+
.simple-apm .apm-button-secondary {
|
|
596
|
+
background: var(--apm-surface);
|
|
597
|
+
border: 1px solid var(--apm-border-strong);
|
|
598
|
+
color: var(--apm-ink);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.simple-apm .apm-button-secondary:hover { background: var(--apm-surface-soft); color: var(--apm-accent); }
|
|
602
|
+
.apm-toolbar-actions form { margin: 0; }
|
|
603
|
+
#sample-limit { width: auto; min-width: 80px; }
|
|
604
|
+
.apm-metrics-compact { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
605
|
+
.apm-mono { font-family: Menlo, Monaco, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
|
606
|
+
.apm-stat-hint { color: var(--apm-muted); font-size: 12px; margin: 8px 0 0; }
|
|
607
|
+
.apm-breadcrumb { margin: 0 0 16px; color: var(--apm-muted); display: flex; gap: 8px; flex-wrap: wrap; }
|
|
608
|
+
.apm-empty-state { padding: 40px 24px; text-align: center; color: var(--apm-muted); }
|
|
609
|
+
.apm-empty-state .apm-toolbar-actions { justify-content: center; margin-top: 20px; }
|
|
610
|
+
.apm-notice { padding: 12px 16px; margin-bottom: 18px; background: #d1fae5; border: 1px solid #a7f3d0; color: #065f46; border-radius: 8px; }
|
|
611
|
+
.apm-copy-status, .apm-table-feedback { color: var(--apm-brand-dark); margin: 8px 0 0; }
|
|
612
|
+
.apm-copy-status:empty, .apm-table-feedback:empty { display: none; }
|
|
613
|
+
.apm-report-text { margin-top: 12px; width: 100%; resize: vertical; border: 1px solid var(--apm-border); border-radius: 6px; background: var(--apm-surface-soft); padding: 14px; line-height: 1.6; }
|
|
614
|
+
.simple-apm summary { cursor: pointer; color: var(--apm-accent); padding: 8px 0; }
|
|
615
|
+
.simple-apm button.sql { border: 0; background: transparent; padding: 4px 0; text-align: left; }
|
|
616
|
+
.apm-runtime-row { display: grid; grid-template-columns: 90px minmax(0, 1fr) 110px; align-items: center; gap: 16px; margin: 10px 0; }
|
|
617
|
+
.apm-runtime-row meter { width: 100%; height: 14px; accent-color: var(--apm-brand); }
|
|
618
|
+
.apm-runtime-row span:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
|
619
|
+
.apm-status-neutral { background: #e2e8f0; color: #334155; }
|
|
620
|
+
|
|
621
|
+
@media (prefers-reduced-motion: reduce) {
|
|
622
|
+
.simple-apm *, .simple-apm *::before, .simple-apm *::after { animation: none !important; transition: none !important; }
|
|
623
|
+
}
|
|
624
|
+
|
|
550
625
|
@media (max-width: 980px) {
|
|
551
626
|
.apm-topbar-inner,
|
|
552
627
|
.apm-content,
|
|
@@ -583,7 +658,7 @@ pre {
|
|
|
583
658
|
grid-column: span 12;
|
|
584
659
|
}
|
|
585
660
|
|
|
586
|
-
.apm-metrics {
|
|
661
|
+
.apm-metrics, .apm-metrics-compact {
|
|
587
662
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
588
663
|
}
|
|
589
664
|
}
|
|
@@ -599,7 +674,7 @@ pre {
|
|
|
599
674
|
}
|
|
600
675
|
|
|
601
676
|
.apm-language-switch {
|
|
602
|
-
width:
|
|
677
|
+
width: auto;
|
|
603
678
|
justify-content: center;
|
|
604
679
|
}
|
|
605
680
|
|
|
@@ -615,11 +690,21 @@ pre {
|
|
|
615
690
|
font-size: 22px;
|
|
616
691
|
}
|
|
617
692
|
|
|
618
|
-
.apm-metrics {
|
|
619
|
-
grid-template-columns: 1fr;
|
|
693
|
+
.apm-metrics, .apm-metrics-compact {
|
|
694
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
620
695
|
}
|
|
621
696
|
|
|
697
|
+
.apm-metrics .apm-stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
|
698
|
+
.apm-stat-value { font-size: 18px; }
|
|
699
|
+
|
|
622
700
|
.apm-detail-list {
|
|
623
701
|
grid-template-columns: 1fr;
|
|
624
702
|
}
|
|
703
|
+
|
|
704
|
+
.apm-panel-header { flex-wrap: wrap; }
|
|
705
|
+
.apm-runtime-row { grid-template-columns: 60px minmax(0, 1fr) 90px; gap: 8px; }
|
|
706
|
+
.apm-nav { gap: 2px; }
|
|
707
|
+
.apm-nav .navbar-brand { white-space: nowrap; padding: 12px 8px; font-size: 12px; }
|
|
708
|
+
.simple-apm .btn { min-height: 44px; }
|
|
709
|
+
.simple-apm .dataTables_wrapper .dataTables_filter input { max-width: 160px; }
|
|
625
710
|
}
|
|
@@ -8,27 +8,32 @@ module SimpleApm
|
|
|
8
8
|
def dashboard
|
|
9
9
|
d = SimpleApm::RedisKey.query_date == Time.now.strftime('%Y-%m-%d') ? Time.now.strftime('%H:%M') : '23:50'
|
|
10
10
|
data = SimpleApm::Hit.chart_data(0, d)
|
|
11
|
+
hits = data.values.sum { |point| point[:hits].to_i }
|
|
12
|
+
time = data.values.sum { |point| point[:time].to_f }
|
|
13
|
+
@daily_stats = {hits: hits, time: time, avg_time: hits.zero? ? 0 : time / hits}
|
|
11
14
|
@x_names = data.keys.sort
|
|
12
15
|
@time_arr = @x_names.map {|n| data[n][:hits].to_i.zero? ? 0 : (data[n][:time].to_f / data[n][:hits].to_i).round(3)}
|
|
13
16
|
@hits_arr = @x_names.map {|n| data[n][:hits] rescue 0}
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def index
|
|
20
|
+
@sample_limit = [[(params[:count].presence || 200).to_i, 1].max, SimpleApm::Setting::SLOW_ACTIONS_LIMIT.to_i].min
|
|
17
21
|
respond_to do |format|
|
|
18
22
|
format.json do
|
|
19
|
-
@slow_requests = SimpleApm::SlowRequest.list(
|
|
20
|
-
request = r.request
|
|
23
|
+
@slow_requests = SimpleApm::SlowRequest.list(@sample_limit).map do |r|
|
|
24
|
+
request = r.request
|
|
25
|
+
next unless request
|
|
21
26
|
[
|
|
22
|
-
link_to(time_label(request.started), show_path(id: request.request_id)),
|
|
23
|
-
link_to(request.action_name, action_info_path(action_name: request.action_name)),
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
link_to(time_label(request.started), show_path(id: request.request_id, apm_date: apm_date)),
|
|
28
|
+
link_to(request.action_name, action_info_path(action_name: request.action_name, apm_date: apm_date)),
|
|
29
|
+
duration_cell(request.during),
|
|
30
|
+
duration_cell(request.db_runtime),
|
|
26
31
|
request.queries_count,
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
duration_cell(request.view_runtime),
|
|
33
|
+
duration_cell(request.net_http_during),
|
|
29
34
|
request.memory_during.to_f.round(1),
|
|
30
|
-
request.host,
|
|
31
|
-
request.remote_addr
|
|
35
|
+
ERB::Util.html_escape(request.host),
|
|
36
|
+
ERB::Util.html_escape(request.remote_addr)
|
|
32
37
|
]
|
|
33
38
|
end.compact
|
|
34
39
|
render json: {data: @slow_requests}
|
|
@@ -39,6 +44,7 @@ module SimpleApm
|
|
|
39
44
|
|
|
40
45
|
def show
|
|
41
46
|
@request = SimpleApm::Request.find(params[:id])
|
|
47
|
+
render :missing, status: :not_found unless @request
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
def actions
|
|
@@ -47,6 +53,7 @@ module SimpleApm
|
|
|
47
53
|
|
|
48
54
|
def action_info
|
|
49
55
|
@action = SimpleApm::Action.find(params[:action_name])
|
|
56
|
+
render :missing, status: :not_found if @action.click_count.to_i.zero?
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
def change_date
|
|
@@ -102,7 +109,11 @@ module SimpleApm
|
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
def link_to(name, url)
|
|
105
|
-
|
|
112
|
+
helpers.link_to(name, url)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def duration_cell(seconds)
|
|
116
|
+
helpers.content_tag(:span, sec_str(seconds), data: {seconds: seconds.to_f})
|
|
106
117
|
end
|
|
107
118
|
end
|
|
108
119
|
end
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
module SimpleApm
|
|
2
2
|
module ApplicationHelper
|
|
3
|
+
def duration_percent(part, total)
|
|
4
|
+
return 0 if total.to_f <= 0
|
|
5
|
+
[[(part.to_f / total.to_f * 100).round(1), 0].max, 100].min
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def table_language
|
|
9
|
+
{
|
|
10
|
+
search: t('simple_apm.table.search'),
|
|
11
|
+
lengthMenu: t('simple_apm.table.length_menu'),
|
|
12
|
+
info: t('simple_apm.table.info'),
|
|
13
|
+
infoEmpty: t('simple_apm.table.info_empty'),
|
|
14
|
+
infoFiltered: t('simple_apm.table.info_filtered'),
|
|
15
|
+
zeroRecords: t('simple_apm.table.zero_records'),
|
|
16
|
+
emptyTable: t('simple_apm.table.empty'),
|
|
17
|
+
loadingRecords: t('simple_apm.table.loading'),
|
|
18
|
+
processing: t('simple_apm.table.processing'),
|
|
19
|
+
paginate: {
|
|
20
|
+
first: t('simple_apm.table.first'), last: t('simple_apm.table.last'),
|
|
21
|
+
next: t('simple_apm.table.next'), previous: t('simple_apm.table.previous')
|
|
22
|
+
},
|
|
23
|
+
aria: {sortAscending: t('simple_apm.table.sort_ascending'), sortDescending: t('simple_apm.table.sort_descending')}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
3
27
|
def time_label(t, full = false)
|
|
4
28
|
Time.parse(t).strftime("#{'%Y-%m-%d ' if full}%H:%M:%S") rescue ''
|
|
5
29
|
end
|
|
@@ -23,23 +23,23 @@ module SimpleApm
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def avg_http_time
|
|
26
|
-
http_time.to_f/click_count.to_i
|
|
26
|
+
click_count.to_i.zero? ? 0.0 : http_time.to_f/click_count.to_i
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def avg_db_time
|
|
30
|
-
db_time.to_f/click_count.to_i
|
|
30
|
+
click_count.to_i.zero? ? 0.0 : db_time.to_f/click_count.to_i
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def avg_queries_count
|
|
34
|
-
queries_count.to_f/click_count.to_i
|
|
34
|
+
click_count.to_i.zero? ? 0.0 : queries_count.to_f/click_count.to_i
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def avg_cached_queries_count
|
|
38
|
-
cached_queries_count.to_f/click_count.to_i
|
|
38
|
+
click_count.to_i.zero? ? 0.0 : cached_queries_count.to_f/click_count.to_i
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def avg_time
|
|
42
|
-
time.to_f/click_count.to_i
|
|
42
|
+
click_count.to_i.zero? ? 0.0 : time.to_f/click_count.to_i
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
class << self
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
require 'time'
|
|
3
|
+
require 'active_support/parameter_filter'
|
|
4
|
+
|
|
5
|
+
module SimpleApm
|
|
6
|
+
class DiagnosticReport
|
|
7
|
+
FILTERED = '[FILTERED]'.freeze
|
|
8
|
+
SECRET_KEYS = /password|passwd|secret|token|authorization|cookie|session|api[_-]?key|credential|private[_-]?key/i
|
|
9
|
+
|
|
10
|
+
def self.for_request(request, date:)
|
|
11
|
+
new(date).with_query_date { |report| report.request_report(request) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.for_action(action, date:)
|
|
15
|
+
new(date).with_query_date { |report| report.action_report(action) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(date)
|
|
19
|
+
@date = date.to_s
|
|
20
|
+
@generated_at = Time.now.utc.iso8601
|
|
21
|
+
@sample_reports = {}
|
|
22
|
+
@filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters + [SECRET_KEYS])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_query_date
|
|
26
|
+
previous_date = Thread.current['apm_query_date']
|
|
27
|
+
SimpleApm::RedisKey.query_date = @date
|
|
28
|
+
yield self
|
|
29
|
+
ensure
|
|
30
|
+
Thread.current['apm_query_date'] = previous_date
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request_report(request)
|
|
34
|
+
sqls = request.sqls
|
|
35
|
+
http_requests = request.net_http_requests
|
|
36
|
+
envelope('request').merge(
|
|
37
|
+
request: {
|
|
38
|
+
request_id: request.request_id, action_name: request.action_name,
|
|
39
|
+
controller: request.controller, action: request.action, method: request.method,
|
|
40
|
+
format: request.format, url: redact_url(request.url), host: request.host,
|
|
41
|
+
remote_addr: request.remote_addr, started_at: request.started&.to_s,
|
|
42
|
+
status: request.status, exception: redact_exception(request.exception)
|
|
43
|
+
},
|
|
44
|
+
timings: {
|
|
45
|
+
total_seconds: number(request.during), database_seconds: number(request.db_runtime),
|
|
46
|
+
view_seconds: number(request.view_runtime), external_http_seconds: number(request.net_http_during)
|
|
47
|
+
},
|
|
48
|
+
counters: {
|
|
49
|
+
queries: integer(request.queries_count), cached_queries: integer(request.cached_queries_count),
|
|
50
|
+
stored_sql_statements: sqls.length, stored_http_calls: http_requests.length
|
|
51
|
+
},
|
|
52
|
+
memory: { completed_mb: number(request.completed_memory), change_mb: number(request.memory_during) },
|
|
53
|
+
sql: sqls.map { |sql| sql_report(sql) },
|
|
54
|
+
http_calls: http_requests.map { |call| http_report(call) },
|
|
55
|
+
limitations: [
|
|
56
|
+
'SQL details contain all stored statements; statements at or below the configured SQL threshold are not stored.',
|
|
57
|
+
'Status and SQL cache flags may be unavailable in older records. Request parameters/bodies and HTTP response status/bodies are not collected.',
|
|
58
|
+
'Process memory is RSS, not allocations attributable solely to this request.'
|
|
59
|
+
],
|
|
60
|
+
sql_storage_threshold_seconds: SimpleApm::Setting::SQL_CRITICAL_TIME.to_f
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def action_report(action)
|
|
65
|
+
count = action.click_count.to_i
|
|
66
|
+
limit = [SimpleApm::Setting::ACTION_SLOW_REQUEST_LIMIT.to_i, 0].max
|
|
67
|
+
slow_samples = limit.zero? ? [] : SimpleApm::SlowRequest.list_by_action(action.name, limit)
|
|
68
|
+
totals = {
|
|
69
|
+
total_seconds: action.time.to_f, database_seconds: action.db_time.to_f,
|
|
70
|
+
external_http_seconds: action.http_time.to_f, queries: action.queries_count.to_i,
|
|
71
|
+
cached_queries: action.cached_queries_count.to_i
|
|
72
|
+
}
|
|
73
|
+
envelope('action').merge(
|
|
74
|
+
action_name: action.name,
|
|
75
|
+
metrics: {
|
|
76
|
+
requests: count, totals: totals,
|
|
77
|
+
averages: totals.transform_values { |value| count.positive? ? value.to_f / count : 0.0 },
|
|
78
|
+
fastest_seconds: number(action.fast_time), slowest_seconds: number(action.slow_time)
|
|
79
|
+
},
|
|
80
|
+
samples: {
|
|
81
|
+
fastest: sample(action.fast_id, action.fast_time),
|
|
82
|
+
slowest: sample(action.slow_id, action.slow_time),
|
|
83
|
+
retained_slow: slow_samples.map { |slow| sample(slow.request_id, slow.during) },
|
|
84
|
+
retained_slow_limit: limit
|
|
85
|
+
},
|
|
86
|
+
limitations: [
|
|
87
|
+
'Aggregates cover recorded requests on the query date. Details are retained for fastest, slowest and configured slow samples only; these are not representative of all requests.',
|
|
88
|
+
'Stored request details may be unavailable. A missing sample is explicitly marked available=false.',
|
|
89
|
+
'Historical request records and SQL/HTTP lists may outlive their current slow-sample ranking.'
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def envelope(type)
|
|
97
|
+
{
|
|
98
|
+
schema_version: '1.0', type: type, application: SimpleApm::Setting::APP_NAME,
|
|
99
|
+
query_date: @date, generated_at: @generated_at,
|
|
100
|
+
units: { duration: 'seconds', memory: 'MB', memory_bytes_per_mb: 1_048_576 },
|
|
101
|
+
redaction: 'Best effort: Rails filter_parameters and common credential keys; SQL string literals are filtered. Review remaining values before sharing.'
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def sample(id, duration)
|
|
106
|
+
request = SimpleApm::Request.find(id) if id.present?
|
|
107
|
+
{
|
|
108
|
+
request_id: id, duration_seconds: number(duration), available: !request.nil?,
|
|
109
|
+
report: request ? (@sample_reports[id] ||= request_report(request)) : nil
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sql_report(sql)
|
|
114
|
+
statement = redact_text(sql.sql).to_s
|
|
115
|
+
statement = statement.gsub(/\$(\w*)\$.*?\$\1\$/m, "'#{FILTERED}'")
|
|
116
|
+
statement = statement.gsub(/'(?:''|\\.|[^'\\])*'/m, "'#{FILTERED}'")
|
|
117
|
+
binds = redact_binds(sql.value)
|
|
118
|
+
{
|
|
119
|
+
name: sql.name, statement: statement,
|
|
120
|
+
full_statement: binds.present? ? "#{statement}\n\nParameters: #{binds.to_json}" : statement,
|
|
121
|
+
binds: binds, cached: sql.respond_to?(:cached) ? sql.cached : nil,
|
|
122
|
+
duration_seconds: number(sql.during), started_at: sql.started&.to_s,
|
|
123
|
+
location: { filename: sql.filename, line: sql.line, method: sql.method }
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def http_report(call)
|
|
128
|
+
{
|
|
129
|
+
name: call.name, url: redact_url(call.url), host: call.host, path: redact_url(call.path),
|
|
130
|
+
duration_seconds: number(call.during), started_at: call.started&.to_s,
|
|
131
|
+
location: { filename: call.filename, line: call.line }
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def number(value)
|
|
136
|
+
value.nil? ? nil : value.to_f
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def integer(value)
|
|
140
|
+
value.nil? ? nil : value.to_i
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def redact_binds(value)
|
|
144
|
+
if value.is_a?(Array)
|
|
145
|
+
value.map do |bind|
|
|
146
|
+
if bind.is_a?(Array) && bind.length == 2
|
|
147
|
+
[bind[0], redact_text(@filter.filter_param(bind[0].to_s, bind[1]))]
|
|
148
|
+
else
|
|
149
|
+
redact_binds(bind)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
elsif value.is_a?(Hash)
|
|
153
|
+
@filter.filter(value.deep_dup).transform_values { |item| redact_text(item) }
|
|
154
|
+
else
|
|
155
|
+
redact_text(value)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def redact_exception(value)
|
|
160
|
+
exception = value.is_a?(String) ? JSON.parse(value) : value
|
|
161
|
+
redact_text(exception)
|
|
162
|
+
rescue JSON::ParserError
|
|
163
|
+
redact_text(value)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def redact_url(value)
|
|
167
|
+
return nil if value.nil?
|
|
168
|
+
|
|
169
|
+
url = URI.parse(value.to_s)
|
|
170
|
+
url.userinfo = 'FILTERED' if url.userinfo
|
|
171
|
+
if url.query
|
|
172
|
+
parameters = URI.decode_www_form(url.query).map do |key, item|
|
|
173
|
+
filter_key = key.gsub(/\[([^\]]*)\]/, '.\\1')
|
|
174
|
+
[key, @filter.filter_param(filter_key, item)]
|
|
175
|
+
end
|
|
176
|
+
url.query = URI.encode_www_form(parameters).gsub('%5BFILTERED%5D', FILTERED)
|
|
177
|
+
end
|
|
178
|
+
redact_text(url.to_s)
|
|
179
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
180
|
+
redact_text(value.to_s.gsub(%r{([a-z][a-z0-9+.-]*://)[^/\s?#]+@}i, '\\1FILTERED@'))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def redact_text(value)
|
|
184
|
+
case value
|
|
185
|
+
when Hash
|
|
186
|
+
@filter.filter(value.deep_dup).transform_values { |item| redact_text(item) }
|
|
187
|
+
when Array
|
|
188
|
+
value.map { |item| redact_text(item) }
|
|
189
|
+
when String
|
|
190
|
+
text = value.gsub(%r{([a-z][a-z0-9+.-]*://)[^/\s?#]+@}i, '\\1FILTERED@')
|
|
191
|
+
text = text.gsub(/\b(Bearer|Basic)\s+[A-Za-z0-9+\/=._-]+/i, "\\1 #{FILTERED}")
|
|
192
|
+
text.gsub(/([\w.\[\]-]+)(["'`]?\s*[=:]\s*)('(?:''|[^'])*'|"(?:\\.|[^"])*"|[^\s&,;\)]+)/) do |match|
|
|
193
|
+
key, separator, original = Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)
|
|
194
|
+
filtered = @filter.filter_param(key, original)
|
|
195
|
+
mask = original.start_with?("'", '"') ? "#{original[0]}#{FILTERED}#{original[0]}" : FILTERED
|
|
196
|
+
filtered == FILTERED ? "#{key}#{separator}#{mask}" : match
|
|
197
|
+
end
|
|
198
|
+
else
|
|
199
|
+
value
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 请求对应的SQL查询列表
|
|
2
2
|
module SimpleApm
|
|
3
3
|
class Sql
|
|
4
|
-
attr_accessor :request_id, :sql, :line, :filename, :method, :name, :during, :started, :value
|
|
4
|
+
attr_accessor :request_id, :sql, :line, :filename, :method, :name, :during, :started, :value, :cached
|
|
5
5
|
def initialize(h, request = nil)
|
|
6
6
|
h.each do |k, v|
|
|
7
7
|
send("#{k}=", v) rescue puts "attr #{k} not set!"
|