solid_apm 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc8464230e852711199328548a375cc742873a408f04b34fd793e305d854420a
4
- data.tar.gz: 27a42427c5fb310b459bcf673c9fedd4dc1a0d0852a15fb4fa367bc6fed475a8
3
+ metadata.gz: 96ffe2124d7df431b9e6d3bc95f0ed77d047bf387d1dce1008f5bc654519b7b7
4
+ data.tar.gz: c4ae53a38929c8eb07e30be80e372c07e0e846165295222857008fc5cf442950
5
5
  SHA512:
6
- metadata.gz: c249809b73d29facaccf681ba95bf60fce23debccc69850f8bb309d826d5b13e7ec093d2acb5346518e0e1c8b5a479743735fa7071a69b63e5a3672f3c5edb00
7
- data.tar.gz: '093e4a4e39e436b26307a67559517ac2e1cc3e3375aa7afeb980db674301e0571b74e74ad68ca8895667557465461e790b1158d0ab8c48f16f53ad301cb37eb8'
6
+ metadata.gz: d1278425ff0312598f4d75676c5e6ba6eb1e97d6033dfbc45f85e107323aa334a695c75f4118e7d312b28b5640426d6a4725cb3fa8ed37d5d261f9336c349c94
7
+ data.tar.gz: e0397780ca57f21a8495d02473f67fd0ab3ad9fa60975d6062a13269a07d6a2a1ad87a32c7201f1891f726cbccc7b8282e14e42bd17fe300bf05eac5a8d9cefe
data/README.md CHANGED
@@ -52,6 +52,113 @@ class ApplicationController
52
52
  end
53
53
  ```
54
54
 
55
+ ## Configuration
56
+
57
+ SolidAPM can be configured using the following options in your `config/initializers/solid_apm.rb` file:
58
+
59
+ ### Database Connection
60
+
61
+ Configure the database connection for SolidAPM:
62
+
63
+ ```ruby
64
+ SolidApm.connects_to = { database: { writing: :solid_apm } }
65
+ ```
66
+
67
+ ### ActiveRecord Logger Silencing
68
+
69
+ Control whether ActiveRecord logger is silenced during SolidAPM operations (default: `true`):
70
+
71
+ ```ruby
72
+ # Disable ActiveRecord logger silencing to see SQL queries in logs
73
+ SolidApm.silence_active_record_logger = false
74
+ ```
75
+
76
+ ### Transaction Sampling
77
+
78
+ Control the sampling rate for transactions using a "1 out of N" approach (default: `1`):
79
+
80
+ ```ruby
81
+ # Sample every transaction (default behavior)
82
+ SolidApm.transaction_sampling = 1
83
+
84
+ # Sample 1 out of every 2 transactions (50% sampling)
85
+ SolidApm.transaction_sampling = 2
86
+
87
+ # Sample 1 out of every 5 transactions (20% sampling)
88
+ SolidApm.transaction_sampling = 5
89
+
90
+ # Sample 1 out of every 10 transactions (10% sampling)
91
+ SolidApm.transaction_sampling = 10
92
+ ```
93
+
94
+ The sampling is done per-thread using a round-robin counter, ensuring even distribution across requests.
95
+ This is useful for high-traffic applications where you want to reduce the volume of
96
+ APM data while still maintaining representative performance insights.
97
+
98
+ ### Transaction Name Filtering
99
+
100
+ Filter specific transactions by name using exact string matches or regular expressions:
101
+
102
+ ```ruby
103
+ # Filter specific transactions by exact name
104
+ SolidApm.transaction_filters += ['HomeController#index', /^Rails::HealthController/]
105
+ ```
106
+
107
+ ## Data Cleanup
108
+
109
+ SolidAPM provides a rake task to clean up old transaction data to manage database size over time.
110
+
111
+ ### Manual Cleanup
112
+
113
+ Clean up transactions older than 1 month (default):
114
+
115
+ ```shell
116
+ bin/rails solid_apm:cleanup
117
+ ```
118
+
119
+ Clean up transactions with custom time periods:
120
+
121
+ ```shell
122
+ # Delete transactions older than 1 week
123
+ bin/rails solid_apm:cleanup[1.week.ago]
124
+ ```
125
+
126
+ ### Automated Cleanup with ActiveJob
127
+
128
+ For production applications, it's recommended to set up automated cleanup.
129
+
130
+ Example with SolidQueue. Configure recurring cleanup in your `config/recurring.yml`:
131
+
132
+ ```yaml
133
+ solid_apm_cleanup_weekly:
134
+ class: SolidApm::CleanupJob
135
+ cron: "0 3 * * *" # Every day at 3 AM
136
+ args: ["1.week.ago"]
137
+ ```
138
+
139
+ ## How it works
140
+
141
+ SolidAPM stores information in the form of transactions, representing incoming HTTP requests which
142
+ listen to a variety of spans (events) from `ActiveSupport::Instrument`. Each span
143
+ saves backtrace information to easily find the source of issues.
144
+
145
+ ### Request transaction
146
+
147
+ It is based on [ActionDispatch](https://guides.rubyonrails.org/active_support_instrumentation.html#action-dispatch)
148
+ events to start and end a transaction.
149
+
150
+ A Rack middleware uses [`rack.after_reply`](https://github.blog/engineering/architecture-optimization/performance-at-github-deferring-stats-with-rack-after_reply/)
151
+ to bulk insert transactions and spans after delivering the response, so tracking your application
152
+ doesn't add delay to the client.
153
+
154
+ ### Spans saved
155
+
156
+ * Request
157
+ * Rendering
158
+ * SQL requests and transactions
159
+ * Rails cache
160
+ * Net/HTTP
161
+
55
162
  ## MCP Server
56
163
 
57
164
  SolidAPM offers an optional MCP server to allow an AI agent to interact with SolidAPM
@@ -4,6 +4,25 @@ import {
4
4
  } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
5
5
  window.Stimulus = Application.start();
6
6
 
7
+ // Global function for chart selection
8
+ window.handleChartSelection = function(minTimestamp, maxTimestamp) {
9
+ const currentUrl = new URL(window.location.href);
10
+ const params = new URLSearchParams(currentUrl.search);
11
+
12
+ // Convert from milliseconds to seconds (ApexCharts provides timestamps in milliseconds)
13
+ params.set('from_timestamp', Math.floor(minTimestamp / 1000));
14
+ params.set('to_timestamp', Math.floor(maxTimestamp / 1000));
15
+
16
+ // Remove relative time params
17
+ params.delete('from_value');
18
+ params.delete('from_unit');
19
+ params.delete('to_value');
20
+ params.delete('to_unit');
21
+
22
+ // Navigate to the new URL
23
+ window.location.href = `${currentUrl.pathname}?${params.toString()}`;
24
+ };
25
+
7
26
  //= require_tree .
8
27
 
9
28
  // require "./controllers/spans-chart_controller"
@@ -0,0 +1,219 @@
1
+ class TimeRangeForm {
2
+ constructor() {
3
+ this.form = document.getElementById('time-range-form');
4
+ this.relativeTab = document.getElementById('relative-tab');
5
+ this.absoluteTab = document.getElementById('absolute-tab');
6
+ this.relativePanel = document.getElementById('relative-panel');
7
+ this.absolutePanel = document.getElementById('absolute-panel');
8
+ this.customFromControl = document.getElementById('custom-from-control');
9
+ this.customToControl = document.getElementById('custom-to-control');
10
+
11
+ // Timezone handling
12
+ this.browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
13
+ this.timezoneOffset = new Date().getTimezoneOffset();
14
+
15
+ this.init();
16
+ }
17
+
18
+ init() {
19
+ this.setupEventListeners();
20
+ this.initializeFormState();
21
+ this.addTimezoneToForm();
22
+ this.adjustAbsoluteTimes();
23
+ }
24
+
25
+ setupEventListeners() {
26
+ if (this.form) {
27
+ this.form.addEventListener('submit', (e) => this.handleFormSubmit(e));
28
+ }
29
+ }
30
+
31
+ switchToRelative(event) {
32
+ event.preventDefault();
33
+
34
+ this.relativeTab.classList.add('is-primary');
35
+ this.absoluteTab.classList.remove('is-primary');
36
+ this.relativePanel.classList.remove('is-hidden');
37
+ this.absolutePanel.classList.add('is-hidden');
38
+
39
+ this.removeFields(['from_timestamp', 'to_timestamp']);
40
+ this.cleanupUrlParams(['from_timestamp', 'to_timestamp', 'from_datetime', 'to_datetime']);
41
+ }
42
+
43
+ switchToAbsolute(event) {
44
+ event.preventDefault();
45
+
46
+ this.absoluteTab.classList.add('is-primary');
47
+ this.relativeTab.classList.remove('is-primary');
48
+ this.absolutePanel.classList.remove('is-hidden');
49
+ this.relativePanel.classList.add('is-hidden');
50
+
51
+ this.cleanupUrlParams(['quick_range', 'from_value', 'from_unit', 'to_value', 'to_unit', 'quick_range_apply']);
52
+ }
53
+
54
+ handleQuickRangeChange(select) {
55
+ const isCustom = select.value === 'custom';
56
+
57
+ this.toggleVisibility(this.customFromControl, isCustom);
58
+ this.toggleVisibility(this.customToControl, isCustom);
59
+
60
+ if (!isCustom) {
61
+ this.applyQuickRange();
62
+ }
63
+ }
64
+
65
+ applyQuickRange() {
66
+ const quickRangeSelect = this.form.querySelector('[name="quick_range"]');
67
+ if (!quickRangeSelect || quickRangeSelect.value === 'custom') return;
68
+
69
+ this.removeFields(['from_datetime', 'to_datetime', 'from_timestamp', 'to_timestamp']);
70
+ this.addHiddenField('quick_range_apply', quickRangeSelect.value);
71
+ this.form.submit();
72
+ }
73
+
74
+ handleFormSubmit(event) {
75
+ const isAbsoluteMode = !this.absolutePanel.classList.contains('is-hidden');
76
+
77
+ if (isAbsoluteMode) {
78
+ this.handleAbsoluteModeSubmit();
79
+ } else {
80
+ this.handleRelativeModeSubmit();
81
+ }
82
+ }
83
+
84
+ handleAbsoluteModeSubmit() {
85
+ const fromDatetime = this.form.querySelector('[name="from_datetime"]');
86
+ const toDatetime = this.form.querySelector('[name="to_datetime"]');
87
+
88
+ if (fromDatetime?.value && toDatetime?.value) {
89
+ const fromTimestamp = Math.floor(new Date(fromDatetime.value).getTime() / 1000);
90
+ const toTimestamp = Math.floor(new Date(toDatetime.value).getTime() / 1000);
91
+
92
+ fromDatetime.disabled = true;
93
+ toDatetime.disabled = true;
94
+
95
+ this.addHiddenField('from_timestamp', fromTimestamp);
96
+ this.addHiddenField('to_timestamp', toTimestamp);
97
+ this.addHiddenField('browser_timezone', this.browserTimezone);
98
+ this.removeFields(['quick_range', 'from_value', 'from_unit', 'to_value', 'to_unit', 'quick_range_apply']);
99
+ }
100
+ }
101
+
102
+ handleRelativeModeSubmit() {
103
+ const quickRangeSelect = this.form.querySelector('[name="quick_range"]');
104
+ const quickRangeValue = quickRangeSelect?.value;
105
+
106
+ if (quickRangeValue && quickRangeValue !== 'custom') {
107
+ this.removeFields(['from_value', 'from_unit', 'to_value', 'to_unit', 'quick_range_apply']);
108
+ } else if (quickRangeValue === 'custom') {
109
+ this.removeFields(['quick_range_apply']);
110
+ }
111
+
112
+ this.removeFields(['from_datetime', 'to_datetime', 'from_timestamp', 'to_timestamp']);
113
+ }
114
+
115
+ initializeFormState() {
116
+ const urlParams = new URLSearchParams(window.location.search);
117
+ const hasCustomParams = urlParams.has('from_value') && urlParams.has('from_unit');
118
+ const hasQuickRange = urlParams.has('quick_range') && urlParams.get('quick_range') !== 'custom';
119
+ const quickRangeSelect = this.form.querySelector('[name="quick_range"]');
120
+
121
+ if (hasQuickRange) {
122
+ this.toggleVisibility(this.customFromControl, false);
123
+ this.toggleVisibility(this.customToControl, false);
124
+ } else if (hasCustomParams || urlParams.get('quick_range') === 'custom') {
125
+ if (quickRangeSelect) quickRangeSelect.value = 'custom';
126
+ this.toggleVisibility(this.customFromControl, true);
127
+ this.toggleVisibility(this.customToControl, true);
128
+ } else {
129
+ // Default state - show quick range only
130
+ this.toggleVisibility(this.customFromControl, false);
131
+ this.toggleVisibility(this.customToControl, false);
132
+ }
133
+ }
134
+
135
+ // Utility methods
136
+ removeFields(fieldNames) {
137
+ fieldNames.forEach(name => {
138
+ this.form.querySelectorAll(`[name="${name}"]`).forEach(field => field.remove());
139
+ });
140
+ }
141
+
142
+ addHiddenField(name, value) {
143
+ const input = document.createElement('input');
144
+ input.type = 'hidden';
145
+ input.name = name;
146
+ input.value = value;
147
+ this.form.appendChild(input);
148
+ }
149
+
150
+ toggleVisibility(element, show) {
151
+ if (!element) return;
152
+ element.classList.toggle('is-hidden', !show);
153
+ }
154
+
155
+ cleanupUrlParams(params) {
156
+ const url = new URL(window.location);
157
+ params.forEach(param => url.searchParams.delete(param));
158
+ window.history.replaceState({}, '', url);
159
+ }
160
+
161
+ // Timezone-related methods
162
+ addTimezoneToForm() {
163
+ // Add timezone information to form for server processing
164
+ this.addHiddenField('browser_timezone', this.browserTimezone);
165
+ }
166
+
167
+ adjustAbsoluteTimes() {
168
+ // Convert timestamps from URL to browser timezone for datetime-local inputs
169
+ const urlParams = new URLSearchParams(window.location.search);
170
+ const fromTimestamp = urlParams.get('from_timestamp');
171
+ const toTimestamp = urlParams.get('to_timestamp');
172
+
173
+ if (fromTimestamp && toTimestamp) {
174
+ const fromDatetime = this.form.querySelector('[name="from_datetime"]');
175
+ const toDatetime = this.form.querySelector('[name="to_datetime"]');
176
+
177
+ if (fromDatetime && toDatetime) {
178
+ // Convert UTC timestamps to local datetime strings
179
+ const fromDate = new Date(parseInt(fromTimestamp) * 1000);
180
+ const toDate = new Date(parseInt(toTimestamp) * 1000);
181
+
182
+ fromDatetime.value = this.formatDatetimeLocal(fromDate);
183
+ toDatetime.value = this.formatDatetimeLocal(toDate);
184
+ }
185
+ }
186
+ }
187
+
188
+ formatDatetimeLocal(date) {
189
+ // Format date for datetime-local input (YYYY-MM-DDTHH:MM)
190
+ const year = date.getFullYear();
191
+ const month = String(date.getMonth() + 1).padStart(2, '0');
192
+ const day = String(date.getDate()).padStart(2, '0');
193
+ const hours = String(date.getHours()).padStart(2, '0');
194
+ const minutes = String(date.getMinutes()).padStart(2, '0');
195
+
196
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
197
+ }
198
+
199
+ }
200
+
201
+ // Global functions for onclick handlers (maintaining backward compatibility)
202
+ let timeRangeFormInstance;
203
+
204
+ function switchToRelative(event) {
205
+ timeRangeFormInstance?.switchToRelative(event);
206
+ }
207
+
208
+ function switchToAbsolute(event) {
209
+ timeRangeFormInstance?.switchToAbsolute(event);
210
+ }
211
+
212
+ function handleQuickRangeChange(select) {
213
+ timeRangeFormInstance?.handleQuickRangeChange(select);
214
+ }
215
+
216
+ // Initialize when DOM is ready
217
+ document.addEventListener('DOMContentLoaded', function() {
218
+ timeRangeFormInstance = new TimeRangeForm();
219
+ });
@@ -13,3 +13,47 @@
13
13
  *= require_tree .
14
14
  *= require_self
15
15
  */
16
+
17
+ /* Time Range Component Enhancements */
18
+ #time-range-form .box {
19
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
20
+ }
21
+
22
+ #time-range-form .tabs ul {
23
+ border-bottom: 1px solid #dbdbdb;
24
+ }
25
+
26
+ #time-range-form .field.has-addons .control:not(:last-child) .input,
27
+ #time-range-form .field.has-addons .control:not(:last-child) .select select {
28
+ border-right: none;
29
+ }
30
+
31
+ #time-range-form .field.has-addons .control:not(:first-child) .input,
32
+ #time-range-form .field.has-addons .control:not(:first-child) .select select {
33
+ border-left: none;
34
+ }
35
+
36
+ #time-range-form .label.is-small {
37
+ font-size: 0.75rem;
38
+ margin-bottom: 0.25rem;
39
+ }
40
+
41
+ /* Chart Container Enhancements */
42
+ #charts-container .level {
43
+ margin-bottom: 0.75rem;
44
+ }
45
+
46
+ #charts-container .tags.has-addons .tag {
47
+ font-size: 0.75rem;
48
+ }
49
+
50
+ /* Smooth transitions */
51
+ #relative-panel,
52
+ #absolute-panel,
53
+ #custom-relative-range {
54
+ transition: opacity 0.2s ease-in-out;
55
+ }
56
+
57
+ .is-hidden {
58
+ display: none !important;
59
+ }
@@ -13,13 +13,12 @@ module SolidApm
13
13
  end
14
14
 
15
15
  @transactions_scope = Transaction.where(timestamp: from_to_range)
16
- if params[:name].present?
17
- @transactions_scope = @transactions_scope.where(name: params[:name])
18
- end
16
+ @transactions_scope = @transactions_scope.where(name: params[:name]) if params[:name].present?
19
17
  transaction_names = @transactions_scope.distinct.pluck(:name)
20
18
  latency_95p = @transactions_scope.group(:name).percentile(:duration, 0.95)
21
19
  latency_median = @transactions_scope.group(:name).median(:duration)
22
- tmp_dict = @transactions_scope.group(:name).group_by_minute(:timestamp, series: false).count.each_with_object({}) do |(k, v), h|
20
+ tmp_dict = @transactions_scope.group(:name).group_by_minute(:timestamp,
21
+ series: false).count.each_with_object({}) do |(k, v), h|
23
22
  current_value = h[k.first] ||= 0
24
23
  h[k.first] = v if v > current_value
25
24
  end
@@ -38,6 +37,7 @@ module SolidApm
38
37
  end
39
38
 
40
39
  return if @aggregated_transactions.empty?
40
+
41
41
  # Find the maximum and minimum impact values
42
42
  max_impact = @aggregated_transactions.values.max_by(&:impact).impact
43
43
  min_impact = @aggregated_transactions.values.min_by(&:impact).impact
@@ -50,9 +50,13 @@ module SolidApm
50
50
  end
51
51
  @aggregated_transactions = @aggregated_transactions.sort_by { |_, v| -v.impact }.to_h
52
52
 
53
- scope = @transactions_scope.group_by_second(:timestamp, n: n_intervals_seconds(from_to_range))
53
+ grouping_method, grouping_options = smart_time_grouping(from_to_range)
54
+ scope = @transactions_scope.public_send(grouping_method, :timestamp, range: from_to_range, **grouping_options)
54
55
  @throughput_data = scope.count
55
56
  @latency_data = scope.median(:duration).transform_values(&:to_i)
57
+
58
+ # Pass browser timezone info to view for chart rendering
59
+ @browser_timezone = params[:browser_timezone]
56
60
  end
57
61
 
58
62
  def spans
@@ -63,15 +67,71 @@ module SolidApm
63
67
  private
64
68
 
65
69
  def from_to_range
66
- params[:from_value] ||= 60
67
- params[:from_unit] ||= 'minutes'
68
- from = params[:from_value].to_i.public_send(params[:from_unit].to_sym).ago
69
- params[:to_value] ||= 1
70
- params[:to_unit] ||= 'seconds'
71
- to = params[:to_value].to_i.public_send(params[:to_unit].to_sym).ago
70
+ if params[:from_timestamp].present? && params[:to_timestamp].present?
71
+ # Timestamps from browser are already in UTC/local time
72
+ from = Time.zone.at(params[:from_timestamp].to_i)
73
+ to = Time.zone.at(params[:to_timestamp].to_i)
74
+ elsif params[:quick_range_apply].present? || (params[:quick_range].present? && params[:quick_range] != 'custom')
75
+ to = Time.current
76
+ quick_range_value = params[:quick_range_apply] || params[:quick_range]
77
+ from = case quick_range_value
78
+ when '5m'
79
+ 5.minutes.ago
80
+ when '15m'
81
+ 15.minutes.ago
82
+ when '30m'
83
+ 30.minutes.ago
84
+ when '1h'
85
+ 1.hour.ago
86
+ when '3h'
87
+ 3.hours.ago
88
+ when '6h'
89
+ 6.hours.ago
90
+ when '12h'
91
+ 12.hours.ago
92
+ when '24h'
93
+ 24.hours.ago
94
+ when '3d'
95
+ 3.days.ago
96
+ when '7d'
97
+ 7.days.ago
98
+ else
99
+ 1.hour.ago
100
+ end
101
+ elsif params[:from_value].present? && params[:from_unit].present?
102
+ from = params[:from_value].to_i.public_send(params[:from_unit].to_sym).ago
103
+ to_value = params[:to_value].present? ? params[:to_value].to_i : 0
104
+ to_unit = params[:to_unit].present? ? params[:to_unit] : 'seconds'
105
+ to = to_value.public_send(to_unit.to_sym).ago
106
+ else
107
+ # Default fallback
108
+ from = 1.hour.ago
109
+ to = Time.current
110
+ end
72
111
  (from..to)
73
112
  end
74
113
 
114
+ def smart_time_grouping(range)
115
+ duration_seconds = (range.end - range.begin).to_i
116
+
117
+ case duration_seconds
118
+ when 0..3600 # 1 hour or less - group by minute
119
+ [:group_by_minute, {}]
120
+ when 3601..86_400 # 1-24 hours - group by 15 minutes
121
+ [:group_by_minute, { n: 15 }]
122
+ when 86_401..604_800 # 1-7 days - group by hour
123
+ [:group_by_hour, {}]
124
+ when 604_801..2_592_000 # 1 week to 1 month - group by 6 hours
125
+ [:group_by_hour, { n: 6 }]
126
+ when 2_592_001..7_776_000 # 1-3 months - group by day
127
+ [:group_by_day, {}]
128
+ when 7_776_001..31_536_000 # 3 months to 1 year - group by week
129
+ [:group_by_week, {}]
130
+ else # more than 1 year - group by month
131
+ [:group_by_month, {}]
132
+ end
133
+ end
134
+
75
135
  def n_intervals_seconds(range, intervals_count: 30)
76
136
  start_time = range.begin
77
137
  end_time = range.end
@@ -85,7 +145,9 @@ module SolidApm
85
145
  time_range_in_seconds = (end_time - start_time).to_i
86
146
  time_interval_duration_in_seconds = (time_range_in_seconds / intervals_count.to_f).round
87
147
 
88
- items.chunk { |item| Time.zone.at((item.created_at.to_i) / time_interval_duration_in_seconds * time_interval_duration_in_seconds, 0) }.to_h
148
+ items.chunk do |item|
149
+ Time.zone.at(item.created_at.to_i / time_interval_duration_in_seconds * time_interval_duration_in_seconds, 0)
150
+ end.to_h
89
151
  end
90
152
  end
91
153
  end
@@ -19,33 +19,90 @@ module SolidApm
19
19
  end
20
20
  end
21
21
 
22
- def area_chart_options
22
+ def area_chart_options(browser_timezone: nil)
23
+ timezone_js = if browser_timezone.present?
24
+ "if (!val) return ''; return new Date(val).toLocaleString('en-US', { timeZone: '#{browser_timezone}', hour12: false })"
25
+ else
26
+ "if (!val) return ''; return new Date(val).toLocaleString('en-US', { hour12: false })"
27
+ end
28
+
29
+ # Create date formatter for x-axis labels that respects timezone
30
+ xaxis_formatter = if browser_timezone.present?
31
+ "if (!val) return ''; return new Date(val).toLocaleString('en-US', { timeZone: '#{browser_timezone}', hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short', hour12: false })"
32
+ else
33
+ "if (!val) return ''; return new Date(val).toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short', hour12: false })"
34
+ end
35
+
23
36
  {
24
37
  module: true,
25
38
  chart: {
26
- type: 'area', height: '200', background: '0', foreColor: '#ffffff55', zoom: {
27
- enabled: false,
28
- }, toolbar: {
29
- show: false,
39
+ type: 'area',
40
+ height: '200',
41
+ background: '0',
42
+ foreColor: '#ffffff55',
43
+ zoom: {
44
+ enabled: true,
45
+ autoScaleYaxis: true
46
+ },
47
+ selection: {
48
+ enabled: true,
49
+ type: 'x'
50
+ },
51
+ toolbar: {
52
+ show: false
53
+ },
54
+ events: {
55
+ zoomed: {
56
+ function: {
57
+ args: 'chartContext, { xaxis, yaxis }',
58
+ body: 'handleChartSelection(xaxis.min, xaxis.max)'
59
+ }
60
+ },
61
+ selection: {
62
+ function: {
63
+ args: 'chartContext, { xaxis, yaxis }',
64
+ body: 'handleChartSelection(xaxis.min, xaxis.max)'
65
+ }
66
+ }
30
67
  }
31
68
  },
32
69
  xaxis: {
33
- type: 'datetime',
34
- tooltip: {
70
+ type: 'datetime',
71
+ tooltip: {
72
+ enabled: false
73
+ },
74
+ labels: {
75
+ formatter: {
76
+ function: {
77
+ args: 'val',
78
+ body: xaxis_formatter
79
+ }
80
+ }
81
+ }
82
+ },
83
+ stroke: {
84
+ curve: 'smooth'
85
+ },
86
+ theme: {
87
+ mode: 'dark'
88
+ },
89
+ grid: {
90
+ show: true,
91
+ borderColor: '#ffffff55'
92
+ },
93
+ dataLabels: {
35
94
  enabled: false
36
-
95
+ },
96
+ tooltip: {
97
+ x: {
98
+ formatter: {
99
+ function: {
100
+ args: 'val',
101
+ body: timezone_js
102
+ }
103
+ }
104
+ }
37
105
  }
38
- },
39
- stroke: {
40
- curve: 'smooth'
41
- }, theme: {
42
- mode: 'dark',
43
- }, grid: {
44
- show: true, borderColor: '#ffffff55',
45
- }, dataLabels: {
46
- enabled: false
47
- },
48
- tooltip: {x: {formatter: {function: {args: "val", body: "return new Date(val).toLocaleString()"}} }}
49
106
  }
50
107
  end
51
108
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidApm
4
+ class CleanupJob < ApplicationJob
5
+ def perform(older_than = '1.month.ago')
6
+ result = CleanupService.new(older_than: older_than).call
7
+
8
+ Rails.logger.info "SolidApm::CleanupJob completed: deleted #{result[:deleted_count]} transactions older than #{result[:cutoff_time]} (#{result[:older_than]})"
9
+
10
+ result
11
+ end
12
+ end
13
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module SolidApm
3
4
  module SpanSubscriber
4
5
  class Base
@@ -1,34 +1,104 @@
1
- <%= form_with path: transactions_path, method: :get do |f| %>
2
- <div class="is-flex is-flex-direction-row is-justify-content-center is-align-items-center" style="gap: 1em">
3
- <%= f.number_field :from_value, value: params[:from_value] || 60, min: 1, class: 'input', style: 'width: 6em' %>
4
- <div class="select">
5
- <%= f.select :from_unit, {
6
- "minutes" => "minutes",
7
- "hours" => "hours",
8
- "days" => "days",
9
- "weeks" => "weeks",
10
- "months" => "months",
11
- "years" => "years"
12
- }, {selected: params[:from_unit] || 'minutes' } %>
1
+ <%
2
+ is_absolute_mode = params[:from_timestamp].present?
3
+ is_custom_range = (params[:from_value].present? && params[:from_unit].present?) || params[:quick_range] == 'custom'
4
+
5
+ time_ranges = {
6
+ "Last 5 minutes" => "5m", "Last 15 minutes" => "15m", "Last 30 minutes" => "30m",
7
+ "Last 1 hour" => "1h", "Last 3 hours" => "3h", "Last 6 hours" => "6h",
8
+ "Last 12 hours" => "12h", "Last 24 hours" => "24h", "Last 3 days" => "3d",
9
+ "Last 7 days" => "7d", "Custom" => "custom"
10
+ }
11
+
12
+ time_units = %w[minutes hours days weeks months]
13
+ to_time_units = %w[seconds minutes hours days weeks]
14
+ %>
15
+
16
+ <%= form_with path: transactions_path, method: :get, local: true, id: 'time-range-form' do |f| %>
17
+ <div class="field is-grouped is-justify-content-center" style="margin-bottom: 1rem;">
18
+ <div class="control">
19
+ <div class="buttons has-addons">
20
+ <% [['Relative', !is_absolute_mode], ['Absolute', is_absolute_mode]].each do |label, is_active| %>
21
+ <button type="button"
22
+ class="button is-small <%= 'is-primary' if is_active %>"
23
+ id="<%= label.downcase %>-tab"
24
+ onclick="switchTo<%= label %>(event)">
25
+ <%= label %>
26
+ </button>
27
+ <% end %>
28
+ </div>
13
29
  </div>
14
- <b>→</b>
15
- <%= f.number_field :to_value, value: params[:to_value] || 1, min: 1, class: 'input', style: 'width: 6em' %>
16
- <div class="select">
17
- <%= f.select :to_unit, {
18
- "seconds" => "seconds",
19
- "minutes" => "minutes",
20
- "hours" => "hours",
21
- "days" => "days",
22
- "weeks" => "weeks",
23
- "months" => "months",
24
- "years" => "years"
25
- }, {selected: params[:to_unit] || 'seconds' } %>
30
+
31
+ </div>
32
+
33
+ <div id="relative-panel" class="<%= 'is-hidden' if is_absolute_mode %>">
34
+ <div class="field is-grouped is-grouped-centered is-align-items-end" style="flex-wrap: nowrap;">
35
+ <div class="control">
36
+ <div class="select">
37
+ <%= f.select :quick_range, time_ranges, {
38
+ selected: params[:quick_range] || (is_custom_range ? 'custom' : '1h'),
39
+ include_blank: false
40
+ }, {
41
+ class: 'select-input',
42
+ onchange: 'handleQuickRangeChange(this)'
43
+ } %>
44
+ </div>
45
+ </div>
46
+
47
+ <% [
48
+ { field: :from_value, default: 60, min: 1, units: time_units, default_unit: 'minutes', label: 'From', id: 'custom-from-control' },
49
+ { field: :to_value, default: 0, min: 0, units: to_time_units, default_unit: 'seconds', label: 'To', id: 'custom-to-control' }
50
+ ].each do |config| %>
51
+ <div class="control <%= 'is-hidden' unless is_custom_range %>" id="<%= config[:id] %>">
52
+ <label class="label is-small"><%= config[:label] %></label>
53
+ <div class="field has-addons">
54
+ <div class="control">
55
+ <%= f.number_field config[:field],
56
+ value: params[config[:field]] || config[:default],
57
+ min: config[:min],
58
+ class: 'input has-text-centered',
59
+ style: 'width: 3.5em' %>
60
+ </div>
61
+ <div class="control">
62
+ <div class="select">
63
+ <%= f.select "#{config[:field].to_s.split('_')[0]}_unit", config[:units],
64
+ { selected: params["#{config[:field].to_s.split('_')[0]}_unit"] || config[:default_unit] } %>
65
+ </div>
66
+ </div>
67
+ <div class="control">
68
+ <span class="button is-static">ago</span>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <% end %>
73
+
74
+ <div class="control">
75
+ <%= f.submit 'Apply', class: 'button is-primary' %>
76
+ </div>
26
77
  </div>
78
+ </div>
27
79
 
28
- <% if params[:name] %>
29
- <%= f.hidden_field :name, value: params[:name] %>
30
- <% end %>
80
+ <%= f.hidden_field :name, value: params[:name] if params[:name] %>
31
81
 
32
- <%= f.submit 'Apply', class: 'button' %>
82
+ <div id="absolute-panel" class="<%= 'is-hidden' unless is_absolute_mode %>">
83
+ <div class="field is-grouped is-grouped-centered is-align-items-end">
84
+ <% [['from', 1.hour.ago], ['to', Time.current]].each do |prefix, default_time| %>
85
+ <div class="control">
86
+ <label class="label is-small">
87
+ <%= prefix.capitalize %>
88
+ </label>
89
+ <%= f.datetime_local_field "#{prefix}_datetime",
90
+ value: (params["#{prefix}_timestamp"] ?
91
+ Time.zone.at(params["#{prefix}_timestamp"].to_i).strftime('%Y-%m-%dT%H:%M') :
92
+ default_time.strftime('%Y-%m-%dT%H:%M')),
93
+ class: 'input',
94
+ id: "#{prefix}_datetime" %>
95
+ </div>
96
+ <% end %>
97
+ <div class="control">
98
+ <%= f.submit 'Apply', class: 'button is-primary' %>
99
+ </div>
100
+ </div>
33
101
  </div>
34
102
  <% end %>
103
+
104
+ <%= javascript_include_tag 'solid_apm/time_range_form' %>
@@ -1,10 +1,94 @@
1
- <div class="columns pt-2" style="height: 16em">
1
+ <div class="columns pt-2" style="height: 16em" id="charts-container">
2
2
  <div class="column">
3
- <h2 class="ml-4">Throughput</h2>
4
- <%= area_chart({ name: 'tmp', data: @throughput_data }, area_chart_options.merge(colors: ['#43BCCD'])) %>
3
+ <div class="level">
4
+ <div class="level-left">
5
+ <div class="level-item">
6
+ <h2 class="title is-5">Throughput</h2>
7
+ </div>
8
+ </div>
9
+ </div>
10
+ <div id="throughput-chart">
11
+ <%= area_chart({ name: 'req/min', data: @throughput_data }, area_chart_options(browser_timezone: @browser_timezone).merge(colors: ['#43BCCD'], refresh: 30)) %>
12
+ </div>
5
13
  </div>
6
14
  <div class="column">
7
- <h2 class="ml-4">Latency</h2>
8
- <%= area_chart({ name: 'ms', data: @latency_data }, area_chart_options.merge(colors: ['#13d8aa'])) %>
15
+ <div class="level">
16
+ <div class="level-left">
17
+ <div class="level-item">
18
+ <h2 class="title is-5">Latency</h2>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ <div id="latency-chart">
23
+ <%= area_chart({ name: 'ms', data: @latency_data }, area_chart_options(browser_timezone: @browser_timezone).merge(colors: ['#13d8aa'], refresh: 30)) %>
24
+ </div>
9
25
  </div>
10
26
  </div>
27
+
28
+ <script>
29
+ // Chart selection functionality for time range
30
+ function handleChartSelection(minTimestamp, maxTimestamp) {
31
+ const form = document.getElementById('time-range-form');
32
+ if (!form) return;
33
+
34
+ // Convert chart selection to timestamps
35
+ const fromTimestamp = Math.floor(minTimestamp / 1000);
36
+ const toTimestamp = Math.floor(maxTimestamp / 1000);
37
+
38
+ // Get browser timezone
39
+ const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
40
+
41
+ // Create new URL with absolute time range
42
+ const url = new URL(window.location.href);
43
+ url.searchParams.set('from_timestamp', fromTimestamp);
44
+ url.searchParams.set('to_timestamp', toTimestamp);
45
+ url.searchParams.set('browser_timezone', browserTimezone);
46
+
47
+ // Remove relative time parameters
48
+ ['quick_range', 'quick_range_apply', 'from_value', 'from_unit', 'to_value', 'to_unit'].forEach(param => {
49
+ url.searchParams.delete(param);
50
+ });
51
+
52
+ // Navigate to new URL
53
+ window.location.href = url.toString();
54
+ }
55
+
56
+ // Chart refresh functionality
57
+ function refreshCharts() {
58
+ const form = document.getElementById('time-range-form');
59
+ if (form) {
60
+ const formData = new FormData(form);
61
+ const params = new URLSearchParams(formData);
62
+
63
+ fetch('<%= transactions_path %>?' + params.toString(), {
64
+ headers: {
65
+ 'Accept': 'text/html',
66
+ 'X-Requested-With': 'XMLHttpRequest'
67
+ }
68
+ })
69
+ .then(response => response.text())
70
+ .then(html => {
71
+ const parser = new DOMParser();
72
+ const doc = parser.parseFromString(html, 'text/html');
73
+ const newChartsContainer = doc.getElementById('charts-container');
74
+ if (newChartsContainer) {
75
+ document.getElementById('charts-container').innerHTML = newChartsContainer.innerHTML;
76
+ }
77
+ })
78
+ .catch(error => console.error('Error refreshing charts:', error));
79
+ }
80
+ }
81
+
82
+ // Auto-refresh charts based on time range
83
+ document.addEventListener('DOMContentLoaded', function() {
84
+ const currentUrl = new URL(window.location.href);
85
+ const hasRecentTimeRange = currentUrl.searchParams.get('quick_range_apply') === '5m' ||
86
+ currentUrl.searchParams.get('quick_range_apply') === '15m' ||
87
+ currentUrl.searchParams.get('quick_range_apply') === '30m';
88
+
89
+ if (hasRecentTimeRange) {
90
+ // Auto-refresh every 30 seconds for recent time ranges
91
+ setInterval(refreshCharts, 30000);
92
+ }
93
+ });
94
+ </script>
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidApm
4
+ class CleanupService
5
+ # Regex to match safe time expressions like "1.week.ago", "2.months.ago", etc.
6
+ DURATION_PATTERN = /\A(\d+)\.(second|minute|hour|day|week|month|year)s?\.ago\z/.freeze
7
+ def initialize(older_than: '1.month.ago')
8
+ @older_than = older_than
9
+ end
10
+
11
+ def call
12
+ cutoff_time = parse_time_expression(@older_than)
13
+ deleted_count = Transaction.where(timestamp: ...cutoff_time).destroy_all.size
14
+
15
+ {
16
+ cutoff_time: cutoff_time,
17
+ deleted_count: deleted_count,
18
+ older_than: @older_than
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def parse_time_expression(expression)
25
+ match = expression.match(DURATION_PATTERN)
26
+ raise ArgumentError, 'Invalid time expression format' unless match
27
+
28
+ number = match[1].to_i
29
+ unit = match[2]
30
+
31
+ case unit
32
+ when 'second'
33
+ number.seconds.ago
34
+ when 'minute'
35
+ number.minutes.ago
36
+ when 'hour'
37
+ number.hours.ago
38
+ when 'day'
39
+ number.days.ago
40
+ when 'week'
41
+ number.weeks.ago
42
+ when 'month'
43
+ number.months.ago
44
+ when 'year'
45
+ number.years.ago
46
+ else
47
+ raise ArgumentError, "Unsupported time unit: #{unit}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -23,7 +23,11 @@ module SolidApm
23
23
  def self.call
24
24
  transaction = SpanSubscriber::Base.transaction
25
25
  SpanSubscriber::Base.transaction = nil
26
- if transaction.nil? || transaction.name.start_with?('SolidApm::') || transaction.name.start_with?('ActionDispatch::Request::PASS_NOT_FOUND')
26
+
27
+ if transaction.nil? ||
28
+ transaction_filtered?(transaction.name) ||
29
+ !Sampler.should_sample?
30
+
27
31
  SpanSubscriber::Base.spans = nil
28
32
  return
29
33
  end
@@ -41,8 +45,21 @@ module SolidApm
41
45
  SpanSubscriber::Base.spans = nil
42
46
  end
43
47
 
48
+ def self.transaction_filtered?(transaction_name)
49
+ SolidApm.transaction_filters.any? do |filter|
50
+ case filter
51
+ when String
52
+ transaction_name == filter
53
+ when Regexp
54
+ filter.match?(transaction_name)
55
+ else
56
+ false
57
+ end
58
+ end
59
+ end
60
+
44
61
  def self.with_silence_logger
45
- if ActiveRecord::Base.logger
62
+ if SolidApm.silence_active_record_logger && ActiveRecord::Base.logger
46
63
  ActiveRecord::Base.logger.silence { yield }
47
64
  else
48
65
  yield
@@ -0,0 +1,12 @@
1
+ module SolidApm
2
+ class Sampler
3
+ def self.should_sample?
4
+ return true if SolidApm.transaction_sampling <= 1
5
+
6
+ thread_counter = Thread.current[:solid_apm_counter] ||= 0
7
+ Thread.current[:solid_apm_counter] = (thread_counter + 1) % SolidApm.transaction_sampling
8
+
9
+ Thread.current[:solid_apm_counter] == 0
10
+ end
11
+ end
12
+ end
@@ -1,3 +1,3 @@
1
1
  module SolidApm
2
- VERSION = "0.9.0"
2
+ VERSION = "0.11.0"
3
3
  end
data/lib/solid_apm.rb CHANGED
@@ -3,12 +3,23 @@ require 'groupdate'
3
3
  require 'active_median'
4
4
  require 'apexcharts'
5
5
 
6
- require "solid_apm/version"
7
- require "solid_apm/engine"
6
+ require 'solid_apm/version'
7
+ require 'solid_apm/engine'
8
+ require 'solid_apm/sampler'
9
+ require 'solid_apm/cleanup_service'
8
10
 
9
11
  module SolidApm
10
12
  mattr_accessor :connects_to
11
13
  mattr_accessor :mcp_server_config, default: {}
14
+ mattr_accessor :silence_active_record_logger, default: true
15
+ mattr_accessor :transaction_sampling, default: 1
16
+ mattr_accessor(
17
+ :transaction_filters, default: [
18
+ /^SolidApm::/,
19
+ /^ActionDispatch::Request::PASS_NOT_FOUND/,
20
+ 'Rails::HealthController#show'
21
+ ]
22
+ )
12
23
 
13
24
  def self.set_context(context)
14
25
  SpanSubscriber::Base.context = context
@@ -1,4 +1,18 @@
1
- # desc "Explaining what the task does"
2
- # task :solid_apm do
3
- # # Task goes here
4
- # end
1
+ namespace :solid_apm do
2
+ desc 'Delete old transactions (default: older than 1 month). Usage: rake solid_apm:cleanup[1.week.ago]'
3
+ task :cleanup, [:older_than] => :environment do |_task, args|
4
+ older_than = args[:older_than] || '1.month.ago'
5
+
6
+ begin
7
+ result = SolidApm::CleanupService.new(older_than: older_than).call
8
+
9
+ puts "Deleting transactions older than #{result[:cutoff_time]}..."
10
+ puts "Deleted #{result[:deleted_count]} transactions"
11
+ rescue StandardError => e
12
+ puts "Error: #{e.message}"
13
+ puts "Please provide a valid time expression like '1.week.ago', '2.months.ago', etc."
14
+ puts 'Supported formats: [number].[unit].ago where unit is: second(s), minute(s), hour(s), day(s), week(s), month(s), year(s)'
15
+ exit 1
16
+ end
17
+ end
18
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_apm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean-Francis Bastien
@@ -119,12 +119,14 @@ files:
119
119
  - Rakefile
120
120
  - app/assets/config/solid_apm_manifest.js
121
121
  - app/assets/javascripts/solid_apm/application.js
122
+ - app/assets/javascripts/solid_apm/time_range_form.js
122
123
  - app/assets/stylesheets/solid_apm/application.css
123
124
  - app/controllers/solid_apm/application_controller.rb
124
125
  - app/controllers/solid_apm/spans_controller.rb
125
126
  - app/controllers/solid_apm/transactions_controller.rb
126
127
  - app/helpers/solid_apm/application_helper.rb
127
128
  - app/jobs/solid_apm/application_job.rb
129
+ - app/jobs/solid_apm/cleanup_job.rb
128
130
  - app/models/solid_apm/application_record.rb
129
131
  - app/models/solid_apm/span.rb
130
132
  - app/models/solid_apm/span_subscriber/action_dispatch.rb
@@ -145,10 +147,12 @@ files:
145
147
  - db/migrate/20240608015633_create_solid_apm_transactions.rb
146
148
  - db/migrate/20240608021940_create_solid_apm_spans.rb
147
149
  - lib/solid_apm.rb
150
+ - lib/solid_apm/cleanup_service.rb
148
151
  - lib/solid_apm/engine.rb
149
152
  - lib/solid_apm/mcp/impactful_transactions_resource.rb
150
153
  - lib/solid_apm/mcp/spans_for_transaction_tool.rb
151
154
  - lib/solid_apm/middleware.rb
155
+ - lib/solid_apm/sampler.rb
152
156
  - lib/solid_apm/version.rb
153
157
  - lib/tasks/solid_apm_tasks.rake
154
158
  homepage: https://github.com/Bhacaz/solid_apm
@@ -172,7 +176,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
172
176
  - !ruby/object:Gem::Version
173
177
  version: '0'
174
178
  requirements: []
175
- rubygems_version: 3.6.7
179
+ rubygems_version: 3.6.9
176
180
  specification_version: 4
177
181
  summary: SolidApm is a DB base engine for Application Performance Monitoring.
178
182
  test_files: []