solid_apm 0.8.2 → 0.9.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: 6218de46aeec80382ad161cffb631016973a2c5f6f4762095b1e250e3424f010
4
- data.tar.gz: 7ee929e42611dc2861b1954056a3457aab1de4daff968558140b7cdd113268ad
3
+ metadata.gz: dc8464230e852711199328548a375cc742873a408f04b34fd793e305d854420a
4
+ data.tar.gz: 27a42427c5fb310b459bcf673c9fedd4dc1a0d0852a15fb4fa367bc6fed475a8
5
5
  SHA512:
6
- metadata.gz: c66d5a85d9f39e9c82d916f8c289e41addbfe4d20543ad671e7ce982be49c9954d08e5553c6b68e1a448143fef109d5dc1a16ba2613bafd1921f8c3d3a1ddebe
7
- data.tar.gz: eccd3664d5c31c26408b49db52d6e3c5a377a49861b5500acb18a6f0d56b17422037ae78e90339a6867aaeb7de62f35a040eccb61f4b5118066dda23feeb88af
6
+ metadata.gz: c249809b73d29facaccf681ba95bf60fce23debccc69850f8bb309d826d5b13e7ec093d2acb5346518e0e1c8b5a479743735fa7071a69b63e5a3672f3c5edb00
7
+ data.tar.gz: '093e4a4e39e436b26307a67559517ac2e1cc3e3375aa7afeb980db674301e0571b74e74ad68ca8895667557465461e790b1158d0ab8c48f16f53ad301cb37eb8'
data/README.md CHANGED
@@ -5,6 +5,7 @@ Rails engine to manage APM data without using a third party service.
5
5
 
6
6
  <img src="./docs/img.png" width="600px">
7
7
  <img src="./docs/img_1.png" width="600px">
8
+ <img src="./docs/img_2.png" width="600px">
8
9
 
9
10
  ## Installation
10
11
 
@@ -51,6 +52,51 @@ class ApplicationController
51
52
  end
52
53
  ```
53
54
 
55
+ ## MCP Server
56
+
57
+ SolidAPM offers an optional MCP server to allow an AI agent to interact with SolidAPM
58
+ and help identify issues in your application, such as
59
+ N+1 queries, slow queries and more. The AI agent can analyze and suggest fixes for these issues.
60
+
61
+ ### MCP Server Configuration
62
+
63
+ The MCP server is only mounted if the [fast-mcp](https://github.com/yjacquin/fast-mcp) gem is installed by your application.
64
+
65
+ 1. Add to your Gemfile:
66
+
67
+ ```ruby
68
+ # Work in progress, plus patch for MCP 2025-06-18 Protocol Revision
69
+ # with StreamableHTTP support
70
+ # https://github.com/yjacquin/fast-mcp/issues/109
71
+ gem 'fast-mcp', branch: 'transport', github: 'Bhacaz/fast-mcp'
72
+ ```
73
+
74
+ 2. Configure the MCP server in your `config/initializers/solid_apm.rb`:
75
+
76
+ ```ruby
77
+ SolidApm.mcp_server_config = {
78
+ name: 'my-app-solid-apm',
79
+ path: '/solid_apm/mcp',
80
+ auth_token: Rails.application.credentials.solid_apm[:mcp_auth_token]
81
+ }
82
+ ```
83
+
84
+ 3. Test the MCP server by running:
85
+
86
+ ```shell
87
+ curl -X POST http://localhost:3000/solid_apm/mcp \
88
+ -H "Content-Type: application/json" \
89
+ -H "Accept: application/json" \
90
+ -H "Authorization: Bearer <AUTH_TOKEN>" \
91
+ -d '{"jsonrpc":"2.0","method":"tools/list","id":1}
92
+ ```
93
+
94
+ ### MCP usage
95
+
96
+ 1. Add the MCP resource `impactful-transactions` to the context of your prompt.
97
+ 2. Prompt example: "Analyze the impactful transactions of my application and suggest improvements, base on the spans details."
98
+ 3. Allow the AI agent to use the MCP tool `spans-for-transaction` to retrieve the longest spans for a specific transaction.
99
+
54
100
  ## TODOs
55
101
 
56
102
  ### Features
@@ -13,7 +13,7 @@ module SolidApm
13
13
  transaction.duration = ((finish.to_f - start.to_f) * 1000).round(6)
14
14
  transaction.metadata = {
15
15
  params: payload[:request].params.except(:controller, :action),
16
- context: SpanSubscriber::Base.context
16
+ context: SpanSubscriber::Base.context || {}
17
17
  }
18
18
  SpanSubscriber::Base.context = {}
19
19
  end
@@ -4,5 +4,14 @@ module SolidApm
4
4
  has_many :spans, -> { order(:timestamp, :sequence) }, foreign_key: 'transaction_id', dependent: :delete_all
5
5
 
6
6
  attribute :uuid, :string, default: -> { SecureRandom.uuid }
7
+
8
+ def self.metadata_filter
9
+ @metadata_filter ||= ActiveSupport::ParameterFilter
10
+ .new(Rails.application.config.filter_parameters)
11
+ end
12
+
13
+ def metadata=(value = {})
14
+ super(self.class.metadata_filter.filter(value))
15
+ end
7
16
  end
8
17
  end
@@ -10,6 +10,32 @@ module SolidApm
10
10
  app.config.assets.precompile += %w( application.css application.js )
11
11
  end
12
12
 
13
+ begin
14
+ # Mount the MCP server only if the main app added the fast_mcp in is Gemfile.
15
+ require 'fast_mcp'
16
+ initializer "solid_apm.mount_mcp_server" do |app|
17
+ mcp_server_config = SolidApm.mcp_server_config.reverse_merge(
18
+ name: 'solid-apm-mcp',
19
+ version: '1.0.0',
20
+ path: '/solid_apm/mcp'
21
+ )
22
+
23
+ FastMcp.mount_in_rails(
24
+ app,
25
+ **mcp_server_config
26
+ ) do |server|
27
+ app.config.after_initialize do
28
+ require_relative 'mcp/spans_for_transaction_tool'
29
+ require_relative 'mcp/impactful_transactions_resource'
30
+ server.register_resources(SolidApm::Mcp::ImpactfulTransactionsResource)
31
+ server.register_tools(SolidApm::Mcp::SpansForTransactionTool)
32
+ end
33
+ end
34
+ end
35
+ rescue LoadError
36
+ # Ignored
37
+ end
38
+
13
39
  config.after_initialize do
14
40
  SpanSubscriber::Base.subscribe!
15
41
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidApm
4
+ module Mcp
5
+ class ImpactfulTransactionsResource < FastMcp::Resource
6
+ uri "solid-apm://impactful-transactions"
7
+ resource_name "impactful_transactions"
8
+ mime_type "application/json"
9
+ description "Returns the most impactful transactions with comprehensive performance metrics"
10
+
11
+ def content
12
+ transactions_with_impact = calculate_impactful_transactions
13
+
14
+ result = {
15
+ metadata: {
16
+ total_transactions_analyzed: SolidApm::Transaction.where(timestamp: 24.hours.ago..).count,
17
+ analysis_period: "last 24 hours",
18
+ ordered_by: "More impactful first: based on P95 latency, transaction frequency, span complexity."
19
+ },
20
+ transactions: transactions_with_impact.map do |transaction_data|
21
+ {
22
+ id: transaction_data[:transaction].id,
23
+ uuid: transaction_data[:transaction].uuid,
24
+ name: transaction_data[:transaction].name,
25
+ type: transaction_data[:transaction].type,
26
+ metrics: {
27
+ p95_latency_ms: transaction_data[:p95_latency],
28
+ avg_duration_ms: transaction_data[:avg_duration],
29
+ max_duration_ms: transaction_data[:max_duration],
30
+ transactions_per_minute: transaction_data[:tpm],
31
+ max_transactions_per_minute: transaction_data[:max_tpm],
32
+ avg_spans_per_transaction: transaction_data[:avg_spans],
33
+ max_spans_per_transaction: transaction_data[:max_spans],
34
+ total_occurrences: transaction_data[:total_count],
35
+ },
36
+ sample_transaction: {
37
+ uuid: transaction_data[:sample_transaction]&.uuid,
38
+ duration_ms: transaction_data[:sample_transaction]&.duration,
39
+ span_count: transaction_data[:sample_span_count],
40
+ timestamp: transaction_data[:sample_transaction]&.timestamp
41
+ }
42
+ }
43
+ end
44
+ }
45
+
46
+ JSON.generate(result)
47
+ rescue StandardError => e
48
+ JSON.generate({ error: e.message, backtrace: e.backtrace.first(5) })
49
+ end
50
+
51
+ private
52
+
53
+ def calculate_impactful_transactions
54
+ # Get transactions from last 24 hours for more relevant data
55
+ cutoff_time = 24.hours.ago
56
+
57
+ # Group transactions by name and type to aggregate metrics
58
+ transaction_groups = SolidApm::Transaction.includes(:spans).where(timestamp: cutoff_time..).group_by { |t| [t.name, t.type] }
59
+
60
+ impact_data = transaction_groups.map do |group_key, transactions| name, type = group_key
61
+ durations = transactions.map(&:duration).compact
62
+ span_counts = transactions.map { |t| t.spans.size }
63
+
64
+ next if durations.empty?
65
+
66
+ # Calculate P95 latency
67
+ p95_latency = calculate_percentile(durations, 95)
68
+ avg_duration = durations.sum / durations.size.to_f
69
+ max_duration = durations.max
70
+
71
+ # Calculate transaction frequency metrics
72
+ total_count = transactions.size
73
+ time_span_hours = [(Time.current - transactions.map(&:timestamp).min) / 1.hour, 1].max
74
+ tpm = (total_count / (time_span_hours * 60)).round(2)
75
+
76
+ # Calculate max TPM by looking at busiest minute
77
+ max_tpm = calculate_max_tpm(transactions)
78
+
79
+ # Span complexity metrics
80
+ avg_spans = span_counts.sum / span_counts.size.to_f
81
+ max_spans = span_counts.max || 0
82
+
83
+ # Get a representative sample transaction
84
+ sample_transaction = transactions.max_by(&:duration)
85
+ sample_span_count = sample_transaction&.spans&.size || 0
86
+
87
+ {
88
+ transaction: transactions.first, # Representative transaction for metadata
89
+ p95_latency: p95_latency.round(2),
90
+ avg_duration: avg_duration.round(2),
91
+ max_duration: max_duration.round(2),
92
+ tpm: tpm,
93
+ max_tpm: max_tpm,
94
+ avg_spans: avg_spans.round(1),
95
+ max_spans: max_spans,
96
+ total_count: total_count,
97
+ sample_transaction: sample_transaction,
98
+ sample_span_count: sample_span_count
99
+ }
100
+ end.compact
101
+
102
+ # Sort by impact score and return top 10
103
+ impact_data.sort_by do |data|
104
+ -calculate_impact_score(p95_latency: data[:p95_latency],
105
+ tpm: data[:tpm],
106
+ avg_spans: data[:avg_spans],
107
+ total_count: data[:total_count])
108
+ end.first(10)
109
+ end
110
+
111
+ def calculate_percentile(array, percentile)
112
+ return 0 if array.empty?
113
+
114
+ sorted = array.sort
115
+ index = (percentile / 100.0) * (sorted.length - 1)
116
+
117
+ if index == index.to_i
118
+ sorted[index.to_i]
119
+ else lower = sorted[index.floor]
120
+ upper = sorted[index.ceil]
121
+ lower + (upper - lower) * (index - index.floor)
122
+ end
123
+ end
124
+
125
+ def calculate_max_tpm(transactions)
126
+ return 0 if transactions.empty?
127
+
128
+ # Group by minute and find the busiest minute
129
+ minute_counts = transactions.group_by { |t| t.timestamp.beginning_of_minute }.values.map(&:size)
130
+ minute_counts.max || 0
131
+ end
132
+
133
+ def calculate_impact_score(p95_latency:, tpm:, avg_spans:, total_count:)
134
+ # Weighted impact score calculation
135
+ # Higher scores indicate more impactful transactions
136
+
137
+ latency_score = Math.log([p95_latency, 1].max) * 20 # Log scale for latency
138
+ frequency_score = Math.log([tpm, 1].max) * 30 # Log scale for frequency
139
+ complexity_score = Math.log([avg_spans, 1].max) * 15 # Span complexity
140
+ volume_score = Math.log([total_count, 1].max) * 10 # Total volume
141
+
142
+ (latency_score + frequency_score + complexity_score + volume_score).round(2)
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidApm
4
+ module Mcp
5
+ class SpansForTransactionTool < FastMcp::Tool
6
+ tool_name "spans-for-transaction"
7
+ description "Returns spans for a specific transaction uuid in the APM system, with backtrace information and metadata"
8
+
9
+ arguments do
10
+ required(:transaction_uuid)
11
+ .filled(:string)
12
+ .description("The UUID of the transaction to retrieve spans for")
13
+ end
14
+
15
+ def call(transaction_uuid:)
16
+ transaction = SolidApm::Transaction.find_by!(uuid: transaction_uuid)
17
+ JSON.generate({
18
+ transaction: transaction,
19
+ spans: transaction.spans
20
+ }.as_json
21
+ )
22
+ rescue StandardError => e
23
+ JSON.generate({
24
+ error: e.message,
25
+ backtrace: e.backtrace.first(5)
26
+ }.as_json)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -23,22 +23,34 @@ 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::')
26
+ if transaction.nil? || transaction.name.start_with?('SolidApm::') || transaction.name.start_with?('ActionDispatch::Request::PASS_NOT_FOUND')
27
27
  SpanSubscriber::Base.spans = nil
28
28
  return
29
29
  end
30
30
 
31
- ApplicationRecord.transaction do
32
- transaction.save!
31
+ with_silence_logger do
32
+ ApplicationRecord.transaction do
33
+ transaction.save!
33
34
 
34
- SpanSubscriber::Base.spans.each do |span|
35
- span[:transaction_id] = transaction.id
35
+ SpanSubscriber::Base.spans.each do |span|
36
+ span[:transaction_id] = transaction.id
37
+ end
38
+ SolidApm::Span.insert_all SpanSubscriber::Base.spans
36
39
  end
37
- SolidApm::Span.insert_all SpanSubscriber::Base.spans
38
40
  end
39
41
  SpanSubscriber::Base.spans = nil
40
42
  end
41
43
 
44
+ def self.with_silence_logger
45
+ if ActiveRecord::Base.logger
46
+ ActiveRecord::Base.logger.silence { yield }
47
+ else
48
+ yield
49
+ end
50
+ end
51
+
52
+ # Initialize a new transaction and reset spans
53
+
42
54
  def self.init_transaction
43
55
  now = Time.zone.now
44
56
  SpanSubscriber::Base.transaction = Transaction.new(
@@ -1,3 +1,3 @@
1
1
  module SolidApm
2
- VERSION = "0.8.2"
2
+ VERSION = "0.9.0"
3
3
  end
data/lib/solid_apm.rb CHANGED
@@ -8,6 +8,7 @@ require "solid_apm/engine"
8
8
 
9
9
  module SolidApm
10
10
  mattr_accessor :connects_to
11
+ mattr_accessor :mcp_server_config, default: {}
11
12
 
12
13
  def self.set_context(context)
13
14
  SpanSubscriber::Base.context = context
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.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean-Francis Bastien
@@ -146,6 +146,8 @@ files:
146
146
  - db/migrate/20240608021940_create_solid_apm_spans.rb
147
147
  - lib/solid_apm.rb
148
148
  - lib/solid_apm/engine.rb
149
+ - lib/solid_apm/mcp/impactful_transactions_resource.rb
150
+ - lib/solid_apm/mcp/spans_for_transaction_tool.rb
149
151
  - lib/solid_apm/middleware.rb
150
152
  - lib/solid_apm/version.rb
151
153
  - lib/tasks/solid_apm_tasks.rake