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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac42ca5ebe7f45973a953586a00e22bb887d77da218f11ddf734dfb90975cbf8
4
- data.tar.gz: d6ae570304dd85c0cb7bbc726fb42435f7a2d0ec461d6f2f3e56796bfed68353
3
+ metadata.gz: d98ff928f6478abea71e3375957adc7de6d5a3865019be549026b4d615b58fa0
4
+ data.tar.gz: ee6fbdf30feb8499da5ca323931272b6d270d41ccdf9cadc86213d7da5fd50ac
5
5
  SHA512:
6
- metadata.gz: 83534ce017f1502595622764a2768be13d26f47d271e00dbd5b39d44d3870031862d9e936a1d1c7bd4ec29c52ddf99088c8f42b7f866edc135d9975f4d9a86fb
7
- data.tar.gz: b1ed3284d308e6f6c7694899887dc5734e0f7dbf38252374a509c2fc6d8990c6e20d7a3078c6c8d0e2836735e3e7a662d74a8e10335d859fd02e024d13623295
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: #d97706;
29
+ --apm-warning: #a35b05;
30
30
  --apm-success: #15803d;
31
- --apm-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
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: 9px 12px;
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: 100%;
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(params[:count] || 200).map do |r|
20
- request = r.request rescue next
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
- sec_str(request.during),
25
- sec_str(request.db_runtime),
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
- sec_str(request.view_runtime),
28
- sec_str(request.net_http_during),
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
- "<a href=#{url.to_json}>#{name}</a>"
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!"