From af26b1513f219fcd33abb1bb90a58a79134b6fdd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Jan 2026 20:57:23 +0000 Subject: [PATCH 1/4] WIP: Add endpoint detection scaffolding (Rails controllers, Grape, Sinatra) - Add Endpoint signal class - Add EndpointExtractor - Update AST Visitor to detect controller actions and API routes - Update SignalBundle to include endpoints - Update Config/Limits for max_endpoints Note: Grafana panel rendering and CLI updates still pending. Co-authored-by: r.buddie --- Gemfile.lock | 2 +- lib/diffdash.rb | 2 + lib/diffdash/ast/visitor.rb | 175 ++++++++++++++++++++- lib/diffdash/config.rb | 13 +- lib/diffdash/detectors/ruby_detector.rb | 5 + lib/diffdash/engine/engine.rb | 1 + lib/diffdash/engine/signal.rb | 9 ++ lib/diffdash/engine/signal_bundle.rb | 8 +- lib/diffdash/signal/base.rb | 4 + lib/diffdash/signal/endpoint.rb | 70 +++++++++ lib/diffdash/signals/endpoint_extractor.rb | 30 ++++ lib/diffdash/validation/limits.rb | 31 ++-- 12 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 lib/diffdash/signal/endpoint.rb create mode 100644 lib/diffdash/signals/endpoint_extractor.rb diff --git a/Gemfile.lock b/Gemfile.lock index 8106a7a..1418f30 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - diffdash (0.1.20) + diffdash (0.1.21) ast (~> 2.4) dotenv (>= 2.8) faraday (>= 2.0) diff --git a/lib/diffdash.rb b/lib/diffdash.rb index 4905898..5c44b44 100644 --- a/lib/diffdash.rb +++ b/lib/diffdash.rb @@ -31,6 +31,8 @@ require_relative "diffdash/formatters/dashboard_title" require_relative "diffdash/signals/log_extractor" require_relative "diffdash/signals/metric_extractor" +require_relative "diffdash/signals/endpoint_extractor" +require_relative "diffdash/signal/endpoint" require_relative "diffdash/validation/limits" # Engine (vendor-agnostic) diff --git a/lib/diffdash/ast/visitor.rb b/lib/diffdash/ast/visitor.rb index 6019599..0788f26 100644 --- a/lib/diffdash/ast/visitor.rb +++ b/lib/diffdash/ast/visitor.rb @@ -23,7 +23,8 @@ class Visitor attr_reader :file_path, :inheritance_depth, :class_definitions, :module_definitions, :log_calls, :metric_calls, :dynamic_metric_calls, :current_class, - :included_modules, :prepended_modules, :extended_modules + :included_modules, :prepended_modules, :extended_modules, + :endpoint_calls # Logger method patterns LOG_RECEIVERS = %i[logger Rails].freeze @@ -37,6 +38,27 @@ class Visitor HISTOGRAM_METHODS = %i[histogram observe timing time].freeze SUMMARY_METHODS = %i[summary].freeze + # Endpoint detection patterns + # Rails controller base classes + CONTROLLER_PARENTS = %w[ + ApplicationController + ActionController::Base + ActionController::API + ApiController + BaseController + ].freeze + + # Grape/Sinatra HTTP method DSLs + HTTP_ROUTE_METHODS = %i[get post put patch delete head options].freeze + + # API base classes (Grape, Sinatra) + API_BASE_CLASSES = %w[ + Grape::API + Grape::API::Instance + Sinatra::Base + Sinatra::Application + ].freeze + def initialize(file_path:, inheritance_depth:) @file_path = file_path @inheritance_depth = inheritance_depth @@ -45,10 +67,12 @@ def initialize(file_path:, inheritance_depth:) @log_calls = [] @metric_calls = [] @dynamic_metric_calls = [] + @endpoint_calls = [] @included_modules = [] @prepended_modules = [] @extended_modules = [] @current_class = nil + @current_class_parent = nil @class_stack = [] end @@ -62,6 +86,10 @@ def process(node) process_module(node) when :send process_send(node) + when :def + process_def(node) + when :block + process_block(node) else node.children.each { |child| process(child) } end @@ -85,11 +113,14 @@ def process_class(node) @class_stack.push(class_name) previous_class = @current_class + previous_parent = @current_class_parent @current_class = full_class_name + @current_class_parent = parent_name process(body) if body @current_class = previous_class + @current_class_parent = previous_parent @class_stack.pop end @@ -129,6 +160,36 @@ def process_send(node) node.children.each { |child| process(child) } end + # Process method definitions - detect controller actions + def process_def(node) + method_name, _args, body = node.children + + # Check if this is a controller action (public method in a controller class) + if controller_class? && public_action_method?(method_name) + record_controller_action(node, method_name) + end + + # Continue traversing the method body + process(body) if body + end + + # Process blocks - detect Grape/Sinatra route definitions + def process_block(node) + send_node, _args, body = node.children + + if send_node&.type == :send + receiver, method_name, *args = send_node.children + + # Check for HTTP route methods in API classes (get '/path' do ... end) + if api_class? && HTTP_ROUTE_METHODS.include?(method_name) && receiver.nil? + record_api_route(node, method_name, args) + end + end + + # Continue traversing + node.children.each { |child| process(child) } + end + def module_inclusion?(receiver, method_name) # include/prepend/extend at class/module level (receiver is nil) receiver.nil? && %i[include prepend extend].include?(method_name) @@ -392,6 +453,118 @@ def extract_const_name(node) nil end end + + # Endpoint detection helpers + + # Check if current class is a Rails controller + def controller_class? + return false unless @current_class + + # Check by class name convention (ends with Controller) + return true if @current_class.end_with?("Controller") + + # Check by parent class + return true if @current_class_parent && CONTROLLER_PARENTS.include?(@current_class_parent) + + false + end + + # Check if current class is a Grape/Sinatra API class + def api_class? + return false unless @current_class + + # Check by parent class + return true if @current_class_parent && API_BASE_CLASSES.include?(@current_class_parent) + + # Check if class name suggests an API endpoint + return true if @current_class.end_with?("API", "Api", "Endpoint", "Endpoints") + + false + end + + # Check if method name looks like a public controller action + # Excludes common callback/helper method patterns + def public_action_method?(method_name) + name = method_name.to_s + + # Skip private/protected indicator methods + return false if name.start_with?("_") + + # Skip common Rails callback patterns + return false if name.start_with?("before_", "after_", "around_") + return false if name.start_with?("set_", "find_", "load_", "require_", "authorize_", "authenticate_") + return false if name.end_with?("_params", "_attributes") + + # Skip common helper methods + return false if %w[initialize permitted_params strong_params resource_params].include?(name) + + true + end + + # Record a controller action as an endpoint + def record_controller_action(node, method_name) + # Infer HTTP method from action name convention + http_method = infer_http_method_from_action(method_name.to_s) + + @endpoint_calls << { + name: "#{@current_class}##{method_name}", + action_name: method_name.to_s, + http_method: http_method, + route_path: nil, # Can't infer path without routes.rb + defining_class: @current_class || "(top-level)", + line: node.loc&.line, + endpoint_type: :controller_action + } + end + + # Record a Grape/Sinatra route as an endpoint + def record_api_route(node, http_method, args) + route_path = extract_route_path(args) + + @endpoint_calls << { + name: route_path ? "#{http_method.to_s.upcase} #{route_path}" : "#{@current_class}##{http_method}", + action_name: http_method.to_s, + http_method: http_method.to_s.upcase, + route_path: route_path, + defining_class: @current_class || "(top-level)", + line: node.loc&.line, + endpoint_type: :api_route + } + end + + # Infer HTTP method from Rails action name conventions + def infer_http_method_from_action(action_name) + case action_name + when "index", "show" + "GET" + when "create" + "POST" + when "update" + "PUT" + when "destroy", "delete" + "DELETE" + when "new", "edit" + "GET" + else + # Default to GET for unknown actions + "GET" + end + end + + # Extract route path from args (first string/symbol argument) + def extract_route_path(args) + return nil if args.empty? + + first_arg = args.first + case first_arg&.type + when :str + first_arg.children.first + when :sym + "/#{first_arg.children.first}" + else + nil + end + end end end end diff --git a/lib/diffdash/config.rb b/lib/diffdash/config.rb index a73195f..e226bfe 100644 --- a/lib/diffdash/config.rb +++ b/lib/diffdash/config.rb @@ -5,12 +5,13 @@ module Diffdash class Config # Hard guard rail limits - not configurable for PoC - MAX_LOGS = 10 - MAX_METRICS = 10 - MAX_EVENTS = 5 - MAX_PANELS = 12 + MAX_LOGS = 10 + MAX_METRICS = 10 + MAX_ENDPOINTS = 5 + MAX_EVENTS = 5 + MAX_PANELS = 15 # Increased to accommodate endpoint panels (3 each) - attr_reader :max_logs, :max_metrics, :max_events, :max_panels, :loader + attr_reader :max_logs, :max_metrics, :max_endpoints, :max_events, :max_panels, :loader # Initialize Config with optional YAML file support. # @@ -19,6 +20,7 @@ class Config def initialize(config_path: nil, working_dir: nil) @max_logs = MAX_LOGS @max_metrics = MAX_METRICS + @max_endpoints = MAX_ENDPOINTS @max_events = MAX_EVENTS @max_panels = MAX_PANELS @loader = ConfigLoader.new(config_path: config_path, working_dir: working_dir) @@ -87,6 +89,7 @@ def to_h @loader.to_h.merge( max_logs: max_logs, max_metrics: max_metrics, + max_endpoints: max_endpoints, max_events: max_events, max_panels: max_panels ) diff --git a/lib/diffdash/detectors/ruby_detector.rb b/lib/diffdash/detectors/ruby_detector.rb index b5de376..463008c 100644 --- a/lib/diffdash/detectors/ruby_detector.rb +++ b/lib/diffdash/detectors/ruby_detector.rb @@ -36,6 +36,7 @@ def detect_with_metadata(source:, file_path:, inheritance_depth: 0) signals = [] signals.concat(extract_logs(visitor)) signals.concat(extract_metrics(visitor)) + signals.concat(extract_endpoints(visitor)) { signals: signals, @@ -73,6 +74,10 @@ def extract_metrics(visitor) Signals::MetricExtractor.extract(visitor) end + def extract_endpoints(visitor) + Signals::EndpointExtractor.extract(visitor) + end + def default_structure { class_definitions: [], diff --git a/lib/diffdash/engine/engine.rb b/lib/diffdash/engine/engine.rb index 1f8f1c5..20d6dab 100644 --- a/lib/diffdash/engine/engine.rb +++ b/lib/diffdash/engine/engine.rb @@ -29,6 +29,7 @@ def run(change_set: ChangeSet.from_git, time_range: DEFAULT_TIME_RANGE) bundle = SignalBundle.new( logs: build_queries(signals, :logs, time_range), metrics: build_queries(signals, :metrics, time_range), + endpoints: build_queries(signals, :endpoints, time_range), traces: [], metadata: { change_set: change_set.to_h, diff --git a/lib/diffdash/engine/signal.rb b/lib/diffdash/engine/signal.rb index 341cb14..3f9afdc 100644 --- a/lib/diffdash/engine/signal.rb +++ b/lib/diffdash/engine/signal.rb @@ -24,6 +24,15 @@ def self.from_domain(signal, time_range:) source_file: signal.source_file, defining_class: signal.defining_class ) + when :endpoint + SignalQuery.new( + type: :endpoints, + name: signal.name, + time_range: time_range, + metadata: signal.metadata, + source_file: signal.source_file, + defining_class: signal.defining_class + ) else nil end diff --git a/lib/diffdash/engine/signal_bundle.rb b/lib/diffdash/engine/signal_bundle.rb index 2b2a203..0fdc938 100644 --- a/lib/diffdash/engine/signal_bundle.rb +++ b/lib/diffdash/engine/signal_bundle.rb @@ -5,23 +5,25 @@ module Engine # Container for signals returned by the engine. # Keeps engine output serialisable and side-effect free. class SignalBundle - attr_reader :logs, :metrics, :traces, :metadata + attr_reader :logs, :metrics, :endpoints, :traces, :metadata - def initialize(logs: [], metrics: [], traces: [], metadata: {}) + def initialize(logs: [], metrics: [], endpoints: [], traces: [], metadata: {}) @logs = logs @metrics = metrics + @endpoints = endpoints @traces = traces @metadata = metadata end def empty? - logs.empty? && metrics.empty? && traces.empty? + logs.empty? && metrics.empty? && endpoints.empty? && traces.empty? end def to_h { logs: logs.map(&:to_h), metrics: metrics.map(&:to_h), + endpoints: endpoints.map(&:to_h), traces: traces.map(&:to_h), metadata: metadata } diff --git a/lib/diffdash/signal/base.rb b/lib/diffdash/signal/base.rb index cefa429..1b8d60c 100644 --- a/lib/diffdash/signal/base.rb +++ b/lib/diffdash/signal/base.rb @@ -31,6 +31,10 @@ def metric? false end + def endpoint? + false + end + def event? false end diff --git a/lib/diffdash/signal/endpoint.rb b/lib/diffdash/signal/endpoint.rb new file mode 100644 index 0000000..1839b33 --- /dev/null +++ b/lib/diffdash/signal/endpoint.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative "base" + +module Diffdash + module Signal + # Represents a detected HTTP endpoint (Rails controller action, Grape/Sinatra route) + # + # Endpoints are controller actions or API routes that handle HTTP requests. + # The dashboard will show request rate, latency, and error rate for these endpoints. + class Endpoint < Base + def initialize( + name:, + source_file:, + defining_class:, + inheritance_depth:, + metadata: {} + ) + super( + name: name, + type: :endpoint, + source_file: source_file, + defining_class: defining_class, + inheritance_depth: inheritance_depth, + metadata: metadata + ) + end + + def endpoint? + true + end + + # HTTP method (GET, POST, PUT, PATCH, DELETE) + def http_method + metadata[:http_method]&.upcase || "GET" + end + + # Route path if available (e.g., "/users/:id") + def route_path + metadata[:route_path] + end + + # Controller action name (e.g., "show", "create") + def action_name + metadata[:action_name] + end + + # Controller name (e.g., "UsersController") + def controller_name + defining_class + end + + def line + metadata[:line] + end + + # Returns a descriptive label for the endpoint + # e.g., "UsersController#show" or "GET /api/users" + def label + if route_path + "#{http_method} #{route_path}" + elsif action_name + "#{defining_class}##{action_name}" + else + name + end + end + end + end +end diff --git a/lib/diffdash/signals/endpoint_extractor.rb b/lib/diffdash/signals/endpoint_extractor.rb new file mode 100644 index 0000000..6522e8c --- /dev/null +++ b/lib/diffdash/signals/endpoint_extractor.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "../signal/endpoint" + +module Diffdash + module Signals + # Extracts endpoint signals from the AST visitor + class EndpointExtractor + class << self + def extract(visitor) + visitor.endpoint_calls.filter_map do |endpoint_call| + Diffdash::Signal::Endpoint.new( + name: endpoint_call[:name], + source_file: visitor.file_path, + defining_class: endpoint_call[:defining_class], + inheritance_depth: visitor.inheritance_depth, + metadata: { + action_name: endpoint_call[:action_name], + http_method: endpoint_call[:http_method], + route_path: endpoint_call[:route_path], + endpoint_type: endpoint_call[:endpoint_type], + line: endpoint_call[:line] + } + ) + end + end + end + end + end +end diff --git a/lib/diffdash/validation/limits.rb b/lib/diffdash/validation/limits.rb index 34e0bee..93ae96b 100644 --- a/lib/diffdash/validation/limits.rb +++ b/lib/diffdash/validation/limits.rb @@ -13,17 +13,19 @@ def initialize(config) def truncate_and_validate(signals) logs = signals.select(&:log?) metrics = signals.select(&:metric?) + endpoints = signals.select(&:endpoint?) events = signals.select(&:event?) # Truncate each type if needed logs = truncate_signals(:logs, logs, @config.max_logs) metrics = truncate_signals(:metrics, metrics, @config.max_metrics) + endpoints = truncate_signals(:endpoints, endpoints, @config.max_endpoints) events = truncate_signals(:events, events, @config.max_events) # Check panel limit and truncate further if needed - truncated = truncate_by_panel_limit(logs, metrics, events) + truncated = truncate_by_panel_limit(logs, metrics, endpoints, events) - truncated[:logs] + truncated[:metrics] + truncated[:events] + truncated[:logs] + truncated[:metrics] + truncated[:endpoints] + truncated[:events] end private @@ -37,12 +39,12 @@ def truncate_signals(type, signals, limit) signals.take(limit) end - def truncate_by_panel_limit(logs, metrics, events) - total_panels = calculate_panel_count(logs, metrics, events) - return { logs: logs, metrics: metrics, events: events } if total_panels <= @config.max_panels + def truncate_by_panel_limit(logs, metrics, endpoints, events) + total_panels = calculate_panel_count(logs, metrics, endpoints, events) + return { logs: logs, metrics: metrics, endpoints: endpoints, events: events } if total_panels <= @config.max_panels # Need to reduce panels - prioritize by removing least critical signals - result = { logs: logs.dup, metrics: metrics.dup, events: events.dup } + result = { logs: logs.dup, metrics: metrics.dup, endpoints: endpoints.dup, events: events.dup } panels_to_remove = total_panels - @config.max_panels # Remove logs first (easiest to reduce) @@ -58,6 +60,12 @@ def truncate_by_panel_limit(logs, metrics, events) panels_to_remove -= panel_cost end + # Then endpoints (each endpoint = 3 panels: request rate, latency, errors) + while panels_to_remove > 0 && result[:endpoints].any? + result[:endpoints].pop + panels_to_remove -= 3 # Each endpoint contributes 3 panels + end + # Finally events if still over while panels_to_remove > 0 && result[:events].any? result[:events].pop @@ -67,12 +75,14 @@ def truncate_by_panel_limit(logs, metrics, events) # Calculate what was excluded excluded_logs = logs.size - result[:logs].size excluded_metrics = metrics.size - result[:metrics].size + excluded_endpoints = endpoints.size - result[:endpoints].size excluded_events = events.size - result[:events].size - if excluded_logs > 0 || excluded_metrics > 0 || excluded_events > 0 + if excluded_logs > 0 || excluded_metrics > 0 || excluded_endpoints > 0 || excluded_events > 0 parts = [] parts << "#{excluded_logs} logs" if excluded_logs > 0 parts << "#{excluded_metrics} metrics" if excluded_metrics > 0 + parts << "#{excluded_endpoints} endpoints" if excluded_endpoints > 0 parts << "#{excluded_events} events" if excluded_events > 0 @warnings << "#{parts.join(", ")} not added to dashboard (panel limit: #{@config.max_panels})" end @@ -80,11 +90,12 @@ def truncate_by_panel_limit(logs, metrics, events) result end - def calculate_panel_count(logs, metrics, events) + def calculate_panel_count(logs, metrics, endpoints, events) # Each log = 1 panel # Each counter = 1 panel # Each histogram = 3 panels (p50, p95, p99) # Each gauge = 1 panel + # Each endpoint = 3 panels (request rate, latency, error rate) # Each event = 1 panel log_panels = logs.size @@ -98,9 +109,11 @@ def calculate_panel_count(logs, metrics, events) end end + endpoint_panels = endpoints.size * 3 # Each endpoint = request rate + latency + errors + event_panels = events.size - log_panels + metric_panels + event_panels + log_panels + metric_panels + endpoint_panels + event_panels end def find_top_contributor(signals) From e1d94f8a8680e440a6592bf5396497d38e756b56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Jan 2026 20:59:59 +0000 Subject: [PATCH 2/4] Revert "WIP: Add endpoint detection scaffolding (Rails controllers, Grape, Sinatra)" This reverts commit af26b1513f219fcd33abb1bb90a58a79134b6fdd. --- Gemfile.lock | 2 +- lib/diffdash.rb | 2 - lib/diffdash/ast/visitor.rb | 175 +-------------------- lib/diffdash/config.rb | 13 +- lib/diffdash/detectors/ruby_detector.rb | 5 - lib/diffdash/engine/engine.rb | 1 - lib/diffdash/engine/signal.rb | 9 -- lib/diffdash/engine/signal_bundle.rb | 8 +- lib/diffdash/signal/base.rb | 4 - lib/diffdash/signal/endpoint.rb | 70 --------- lib/diffdash/signals/endpoint_extractor.rb | 30 ---- lib/diffdash/validation/limits.rb | 31 ++-- 12 files changed, 19 insertions(+), 331 deletions(-) delete mode 100644 lib/diffdash/signal/endpoint.rb delete mode 100644 lib/diffdash/signals/endpoint_extractor.rb diff --git a/Gemfile.lock b/Gemfile.lock index 1418f30..8106a7a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - diffdash (0.1.21) + diffdash (0.1.20) ast (~> 2.4) dotenv (>= 2.8) faraday (>= 2.0) diff --git a/lib/diffdash.rb b/lib/diffdash.rb index 5c44b44..4905898 100644 --- a/lib/diffdash.rb +++ b/lib/diffdash.rb @@ -31,8 +31,6 @@ require_relative "diffdash/formatters/dashboard_title" require_relative "diffdash/signals/log_extractor" require_relative "diffdash/signals/metric_extractor" -require_relative "diffdash/signals/endpoint_extractor" -require_relative "diffdash/signal/endpoint" require_relative "diffdash/validation/limits" # Engine (vendor-agnostic) diff --git a/lib/diffdash/ast/visitor.rb b/lib/diffdash/ast/visitor.rb index 0788f26..6019599 100644 --- a/lib/diffdash/ast/visitor.rb +++ b/lib/diffdash/ast/visitor.rb @@ -23,8 +23,7 @@ class Visitor attr_reader :file_path, :inheritance_depth, :class_definitions, :module_definitions, :log_calls, :metric_calls, :dynamic_metric_calls, :current_class, - :included_modules, :prepended_modules, :extended_modules, - :endpoint_calls + :included_modules, :prepended_modules, :extended_modules # Logger method patterns LOG_RECEIVERS = %i[logger Rails].freeze @@ -38,27 +37,6 @@ class Visitor HISTOGRAM_METHODS = %i[histogram observe timing time].freeze SUMMARY_METHODS = %i[summary].freeze - # Endpoint detection patterns - # Rails controller base classes - CONTROLLER_PARENTS = %w[ - ApplicationController - ActionController::Base - ActionController::API - ApiController - BaseController - ].freeze - - # Grape/Sinatra HTTP method DSLs - HTTP_ROUTE_METHODS = %i[get post put patch delete head options].freeze - - # API base classes (Grape, Sinatra) - API_BASE_CLASSES = %w[ - Grape::API - Grape::API::Instance - Sinatra::Base - Sinatra::Application - ].freeze - def initialize(file_path:, inheritance_depth:) @file_path = file_path @inheritance_depth = inheritance_depth @@ -67,12 +45,10 @@ def initialize(file_path:, inheritance_depth:) @log_calls = [] @metric_calls = [] @dynamic_metric_calls = [] - @endpoint_calls = [] @included_modules = [] @prepended_modules = [] @extended_modules = [] @current_class = nil - @current_class_parent = nil @class_stack = [] end @@ -86,10 +62,6 @@ def process(node) process_module(node) when :send process_send(node) - when :def - process_def(node) - when :block - process_block(node) else node.children.each { |child| process(child) } end @@ -113,14 +85,11 @@ def process_class(node) @class_stack.push(class_name) previous_class = @current_class - previous_parent = @current_class_parent @current_class = full_class_name - @current_class_parent = parent_name process(body) if body @current_class = previous_class - @current_class_parent = previous_parent @class_stack.pop end @@ -160,36 +129,6 @@ def process_send(node) node.children.each { |child| process(child) } end - # Process method definitions - detect controller actions - def process_def(node) - method_name, _args, body = node.children - - # Check if this is a controller action (public method in a controller class) - if controller_class? && public_action_method?(method_name) - record_controller_action(node, method_name) - end - - # Continue traversing the method body - process(body) if body - end - - # Process blocks - detect Grape/Sinatra route definitions - def process_block(node) - send_node, _args, body = node.children - - if send_node&.type == :send - receiver, method_name, *args = send_node.children - - # Check for HTTP route methods in API classes (get '/path' do ... end) - if api_class? && HTTP_ROUTE_METHODS.include?(method_name) && receiver.nil? - record_api_route(node, method_name, args) - end - end - - # Continue traversing - node.children.each { |child| process(child) } - end - def module_inclusion?(receiver, method_name) # include/prepend/extend at class/module level (receiver is nil) receiver.nil? && %i[include prepend extend].include?(method_name) @@ -453,118 +392,6 @@ def extract_const_name(node) nil end end - - # Endpoint detection helpers - - # Check if current class is a Rails controller - def controller_class? - return false unless @current_class - - # Check by class name convention (ends with Controller) - return true if @current_class.end_with?("Controller") - - # Check by parent class - return true if @current_class_parent && CONTROLLER_PARENTS.include?(@current_class_parent) - - false - end - - # Check if current class is a Grape/Sinatra API class - def api_class? - return false unless @current_class - - # Check by parent class - return true if @current_class_parent && API_BASE_CLASSES.include?(@current_class_parent) - - # Check if class name suggests an API endpoint - return true if @current_class.end_with?("API", "Api", "Endpoint", "Endpoints") - - false - end - - # Check if method name looks like a public controller action - # Excludes common callback/helper method patterns - def public_action_method?(method_name) - name = method_name.to_s - - # Skip private/protected indicator methods - return false if name.start_with?("_") - - # Skip common Rails callback patterns - return false if name.start_with?("before_", "after_", "around_") - return false if name.start_with?("set_", "find_", "load_", "require_", "authorize_", "authenticate_") - return false if name.end_with?("_params", "_attributes") - - # Skip common helper methods - return false if %w[initialize permitted_params strong_params resource_params].include?(name) - - true - end - - # Record a controller action as an endpoint - def record_controller_action(node, method_name) - # Infer HTTP method from action name convention - http_method = infer_http_method_from_action(method_name.to_s) - - @endpoint_calls << { - name: "#{@current_class}##{method_name}", - action_name: method_name.to_s, - http_method: http_method, - route_path: nil, # Can't infer path without routes.rb - defining_class: @current_class || "(top-level)", - line: node.loc&.line, - endpoint_type: :controller_action - } - end - - # Record a Grape/Sinatra route as an endpoint - def record_api_route(node, http_method, args) - route_path = extract_route_path(args) - - @endpoint_calls << { - name: route_path ? "#{http_method.to_s.upcase} #{route_path}" : "#{@current_class}##{http_method}", - action_name: http_method.to_s, - http_method: http_method.to_s.upcase, - route_path: route_path, - defining_class: @current_class || "(top-level)", - line: node.loc&.line, - endpoint_type: :api_route - } - end - - # Infer HTTP method from Rails action name conventions - def infer_http_method_from_action(action_name) - case action_name - when "index", "show" - "GET" - when "create" - "POST" - when "update" - "PUT" - when "destroy", "delete" - "DELETE" - when "new", "edit" - "GET" - else - # Default to GET for unknown actions - "GET" - end - end - - # Extract route path from args (first string/symbol argument) - def extract_route_path(args) - return nil if args.empty? - - first_arg = args.first - case first_arg&.type - when :str - first_arg.children.first - when :sym - "/#{first_arg.children.first}" - else - nil - end - end end end end diff --git a/lib/diffdash/config.rb b/lib/diffdash/config.rb index e226bfe..a73195f 100644 --- a/lib/diffdash/config.rb +++ b/lib/diffdash/config.rb @@ -5,13 +5,12 @@ module Diffdash class Config # Hard guard rail limits - not configurable for PoC - MAX_LOGS = 10 - MAX_METRICS = 10 - MAX_ENDPOINTS = 5 - MAX_EVENTS = 5 - MAX_PANELS = 15 # Increased to accommodate endpoint panels (3 each) + MAX_LOGS = 10 + MAX_METRICS = 10 + MAX_EVENTS = 5 + MAX_PANELS = 12 - attr_reader :max_logs, :max_metrics, :max_endpoints, :max_events, :max_panels, :loader + attr_reader :max_logs, :max_metrics, :max_events, :max_panels, :loader # Initialize Config with optional YAML file support. # @@ -20,7 +19,6 @@ class Config def initialize(config_path: nil, working_dir: nil) @max_logs = MAX_LOGS @max_metrics = MAX_METRICS - @max_endpoints = MAX_ENDPOINTS @max_events = MAX_EVENTS @max_panels = MAX_PANELS @loader = ConfigLoader.new(config_path: config_path, working_dir: working_dir) @@ -89,7 +87,6 @@ def to_h @loader.to_h.merge( max_logs: max_logs, max_metrics: max_metrics, - max_endpoints: max_endpoints, max_events: max_events, max_panels: max_panels ) diff --git a/lib/diffdash/detectors/ruby_detector.rb b/lib/diffdash/detectors/ruby_detector.rb index 463008c..b5de376 100644 --- a/lib/diffdash/detectors/ruby_detector.rb +++ b/lib/diffdash/detectors/ruby_detector.rb @@ -36,7 +36,6 @@ def detect_with_metadata(source:, file_path:, inheritance_depth: 0) signals = [] signals.concat(extract_logs(visitor)) signals.concat(extract_metrics(visitor)) - signals.concat(extract_endpoints(visitor)) { signals: signals, @@ -74,10 +73,6 @@ def extract_metrics(visitor) Signals::MetricExtractor.extract(visitor) end - def extract_endpoints(visitor) - Signals::EndpointExtractor.extract(visitor) - end - def default_structure { class_definitions: [], diff --git a/lib/diffdash/engine/engine.rb b/lib/diffdash/engine/engine.rb index 20d6dab..1f8f1c5 100644 --- a/lib/diffdash/engine/engine.rb +++ b/lib/diffdash/engine/engine.rb @@ -29,7 +29,6 @@ def run(change_set: ChangeSet.from_git, time_range: DEFAULT_TIME_RANGE) bundle = SignalBundle.new( logs: build_queries(signals, :logs, time_range), metrics: build_queries(signals, :metrics, time_range), - endpoints: build_queries(signals, :endpoints, time_range), traces: [], metadata: { change_set: change_set.to_h, diff --git a/lib/diffdash/engine/signal.rb b/lib/diffdash/engine/signal.rb index 3f9afdc..341cb14 100644 --- a/lib/diffdash/engine/signal.rb +++ b/lib/diffdash/engine/signal.rb @@ -24,15 +24,6 @@ def self.from_domain(signal, time_range:) source_file: signal.source_file, defining_class: signal.defining_class ) - when :endpoint - SignalQuery.new( - type: :endpoints, - name: signal.name, - time_range: time_range, - metadata: signal.metadata, - source_file: signal.source_file, - defining_class: signal.defining_class - ) else nil end diff --git a/lib/diffdash/engine/signal_bundle.rb b/lib/diffdash/engine/signal_bundle.rb index 0fdc938..2b2a203 100644 --- a/lib/diffdash/engine/signal_bundle.rb +++ b/lib/diffdash/engine/signal_bundle.rb @@ -5,25 +5,23 @@ module Engine # Container for signals returned by the engine. # Keeps engine output serialisable and side-effect free. class SignalBundle - attr_reader :logs, :metrics, :endpoints, :traces, :metadata + attr_reader :logs, :metrics, :traces, :metadata - def initialize(logs: [], metrics: [], endpoints: [], traces: [], metadata: {}) + def initialize(logs: [], metrics: [], traces: [], metadata: {}) @logs = logs @metrics = metrics - @endpoints = endpoints @traces = traces @metadata = metadata end def empty? - logs.empty? && metrics.empty? && endpoints.empty? && traces.empty? + logs.empty? && metrics.empty? && traces.empty? end def to_h { logs: logs.map(&:to_h), metrics: metrics.map(&:to_h), - endpoints: endpoints.map(&:to_h), traces: traces.map(&:to_h), metadata: metadata } diff --git a/lib/diffdash/signal/base.rb b/lib/diffdash/signal/base.rb index 1b8d60c..cefa429 100644 --- a/lib/diffdash/signal/base.rb +++ b/lib/diffdash/signal/base.rb @@ -31,10 +31,6 @@ def metric? false end - def endpoint? - false - end - def event? false end diff --git a/lib/diffdash/signal/endpoint.rb b/lib/diffdash/signal/endpoint.rb deleted file mode 100644 index 1839b33..0000000 --- a/lib/diffdash/signal/endpoint.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -require_relative "base" - -module Diffdash - module Signal - # Represents a detected HTTP endpoint (Rails controller action, Grape/Sinatra route) - # - # Endpoints are controller actions or API routes that handle HTTP requests. - # The dashboard will show request rate, latency, and error rate for these endpoints. - class Endpoint < Base - def initialize( - name:, - source_file:, - defining_class:, - inheritance_depth:, - metadata: {} - ) - super( - name: name, - type: :endpoint, - source_file: source_file, - defining_class: defining_class, - inheritance_depth: inheritance_depth, - metadata: metadata - ) - end - - def endpoint? - true - end - - # HTTP method (GET, POST, PUT, PATCH, DELETE) - def http_method - metadata[:http_method]&.upcase || "GET" - end - - # Route path if available (e.g., "/users/:id") - def route_path - metadata[:route_path] - end - - # Controller action name (e.g., "show", "create") - def action_name - metadata[:action_name] - end - - # Controller name (e.g., "UsersController") - def controller_name - defining_class - end - - def line - metadata[:line] - end - - # Returns a descriptive label for the endpoint - # e.g., "UsersController#show" or "GET /api/users" - def label - if route_path - "#{http_method} #{route_path}" - elsif action_name - "#{defining_class}##{action_name}" - else - name - end - end - end - end -end diff --git a/lib/diffdash/signals/endpoint_extractor.rb b/lib/diffdash/signals/endpoint_extractor.rb deleted file mode 100644 index 6522e8c..0000000 --- a/lib/diffdash/signals/endpoint_extractor.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require_relative "../signal/endpoint" - -module Diffdash - module Signals - # Extracts endpoint signals from the AST visitor - class EndpointExtractor - class << self - def extract(visitor) - visitor.endpoint_calls.filter_map do |endpoint_call| - Diffdash::Signal::Endpoint.new( - name: endpoint_call[:name], - source_file: visitor.file_path, - defining_class: endpoint_call[:defining_class], - inheritance_depth: visitor.inheritance_depth, - metadata: { - action_name: endpoint_call[:action_name], - http_method: endpoint_call[:http_method], - route_path: endpoint_call[:route_path], - endpoint_type: endpoint_call[:endpoint_type], - line: endpoint_call[:line] - } - ) - end - end - end - end - end -end diff --git a/lib/diffdash/validation/limits.rb b/lib/diffdash/validation/limits.rb index 93ae96b..34e0bee 100644 --- a/lib/diffdash/validation/limits.rb +++ b/lib/diffdash/validation/limits.rb @@ -13,19 +13,17 @@ def initialize(config) def truncate_and_validate(signals) logs = signals.select(&:log?) metrics = signals.select(&:metric?) - endpoints = signals.select(&:endpoint?) events = signals.select(&:event?) # Truncate each type if needed logs = truncate_signals(:logs, logs, @config.max_logs) metrics = truncate_signals(:metrics, metrics, @config.max_metrics) - endpoints = truncate_signals(:endpoints, endpoints, @config.max_endpoints) events = truncate_signals(:events, events, @config.max_events) # Check panel limit and truncate further if needed - truncated = truncate_by_panel_limit(logs, metrics, endpoints, events) + truncated = truncate_by_panel_limit(logs, metrics, events) - truncated[:logs] + truncated[:metrics] + truncated[:endpoints] + truncated[:events] + truncated[:logs] + truncated[:metrics] + truncated[:events] end private @@ -39,12 +37,12 @@ def truncate_signals(type, signals, limit) signals.take(limit) end - def truncate_by_panel_limit(logs, metrics, endpoints, events) - total_panels = calculate_panel_count(logs, metrics, endpoints, events) - return { logs: logs, metrics: metrics, endpoints: endpoints, events: events } if total_panels <= @config.max_panels + def truncate_by_panel_limit(logs, metrics, events) + total_panels = calculate_panel_count(logs, metrics, events) + return { logs: logs, metrics: metrics, events: events } if total_panels <= @config.max_panels # Need to reduce panels - prioritize by removing least critical signals - result = { logs: logs.dup, metrics: metrics.dup, endpoints: endpoints.dup, events: events.dup } + result = { logs: logs.dup, metrics: metrics.dup, events: events.dup } panels_to_remove = total_panels - @config.max_panels # Remove logs first (easiest to reduce) @@ -60,12 +58,6 @@ def truncate_by_panel_limit(logs, metrics, endpoints, events) panels_to_remove -= panel_cost end - # Then endpoints (each endpoint = 3 panels: request rate, latency, errors) - while panels_to_remove > 0 && result[:endpoints].any? - result[:endpoints].pop - panels_to_remove -= 3 # Each endpoint contributes 3 panels - end - # Finally events if still over while panels_to_remove > 0 && result[:events].any? result[:events].pop @@ -75,14 +67,12 @@ def truncate_by_panel_limit(logs, metrics, endpoints, events) # Calculate what was excluded excluded_logs = logs.size - result[:logs].size excluded_metrics = metrics.size - result[:metrics].size - excluded_endpoints = endpoints.size - result[:endpoints].size excluded_events = events.size - result[:events].size - if excluded_logs > 0 || excluded_metrics > 0 || excluded_endpoints > 0 || excluded_events > 0 + if excluded_logs > 0 || excluded_metrics > 0 || excluded_events > 0 parts = [] parts << "#{excluded_logs} logs" if excluded_logs > 0 parts << "#{excluded_metrics} metrics" if excluded_metrics > 0 - parts << "#{excluded_endpoints} endpoints" if excluded_endpoints > 0 parts << "#{excluded_events} events" if excluded_events > 0 @warnings << "#{parts.join(", ")} not added to dashboard (panel limit: #{@config.max_panels})" end @@ -90,12 +80,11 @@ def truncate_by_panel_limit(logs, metrics, endpoints, events) result end - def calculate_panel_count(logs, metrics, endpoints, events) + def calculate_panel_count(logs, metrics, events) # Each log = 1 panel # Each counter = 1 panel # Each histogram = 3 panels (p50, p95, p99) # Each gauge = 1 panel - # Each endpoint = 3 panels (request rate, latency, error rate) # Each event = 1 panel log_panels = logs.size @@ -109,11 +98,9 @@ def calculate_panel_count(logs, metrics, endpoints, events) end end - endpoint_panels = endpoints.size * 3 # Each endpoint = request rate + latency + errors - event_panels = events.size - log_panels + metric_panels + endpoint_panels + event_panels + log_panels + metric_panels + event_panels end def find_top_contributor(signals) From 759dfe9db099a736c45a4a12639b15151b636ec0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Jan 2026 23:50:58 +0000 Subject: [PATCH 3/4] Add spike: HTTP metrics detection proof of concept Validates that we can: - Detect classes matching configurable patterns (Routes::*, *Controller, API::*) - Generate Prometheus queries using class names as handler labels - Support different frameworks through config See spike/http_metrics_spike.rb for details and open questions. Co-authored-by: r.buddie --- spike/http_metrics_spike.rb | 229 ++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 spike/http_metrics_spike.rb diff --git a/spike/http_metrics_spike.rb b/spike/http_metrics_spike.rb new file mode 100644 index 0000000..9798119 --- /dev/null +++ b/spike/http_metrics_spike.rb @@ -0,0 +1,229 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# SPIKE: HTTP Metrics Detection for Diffdash +# +# Question: Can we detect route/controller classes from changed files +# and generate Prometheus queries for HTTP traffic? +# +# Run: ruby spike/http_metrics_spike.rb + +require "parser/current" + +module Spike + # Simulated config + HTTP_METRICS_CONFIG = { + enabled: true, + detect: [ + { namespace: "Routes::" }, + { suffix: "Controller" }, + { namespace: "API::" } + ], + rate_metric: "service:http_request_rate:rate$__interval", + handler_label: "handler", + labels: { context: "$env" } + }.freeze + + # Simple class detector + class ClassDetector + def initialize(config) + @config = config + end + + def detect(source) + ast = Parser::CurrentRuby.parse(source) + classes = extract_classes(ast) + + classes.select { |c| matches_pattern?(c) } + end + + private + + def extract_classes(node, namespace = []) + return [] unless node.is_a?(Parser::AST::Node) + + results = [] + + case node.type + when :module + name = extract_const_name(node.children[0]) + results += extract_classes(node.children[1], namespace + [name]) + when :class + name = extract_const_name(node.children[0]) + full_name = (namespace + [name]).join("::") + results << full_name + results += extract_classes(node.children[2], namespace + [name]) + else + node.children.each do |child| + results += extract_classes(child, namespace) + end + end + + results + end + + def extract_const_name(node) + return nil unless node&.type == :const + parent, name = node.children + parent ? "#{extract_const_name(parent)}::#{name}" : name.to_s + end + + def matches_pattern?(class_name) + @config[:detect].any? do |pattern| + if pattern[:namespace] + class_name.start_with?(pattern[:namespace]) + elsif pattern[:suffix] + class_name.end_with?(pattern[:suffix]) + else + false + end + end + end + end + + # Query generator + class QueryGenerator + def initialize(config) + @config = config + end + + def generate(class_name) + metric = @config[:rate_metric] + handler_label = @config[:handler_label] + labels = @config[:labels].map { |k, v| "#{k}=~\"#{v}\"" } + labels << "#{handler_label}=\"#{class_name}\"" + + "sum(#{metric}{#{labels.join(', ')}})" + end + end +end + +# --- SPIKE TESTS --- + +puts "=" * 60 +puts "SPIKE: HTTP Metrics Detection" +puts "=" * 60 +puts + +# Test cases +test_cases = [ + { + name: "Routes pattern (your style)", + source: <<~RUBY + module Routes + module Users + class Show + def call + # endpoint logic + end + end + end + end + RUBY + }, + { + name: "Rails Controller", + source: <<~RUBY + class UsersController < ApplicationController + def show + @user = User.find(params[:id]) + end + + def create + @user = User.create(user_params) + end + end + RUBY + }, + { + name: "Grape API", + source: <<~RUBY + module API + module V1 + class Users < Grape::API + get ':id' do + User.find(params[:id]) + end + end + end + end + RUBY + }, + { + name: "Non-matching class (should be ignored)", + source: <<~RUBY + class PaymentProcessor + def process + StatsD.increment("payments.processed") + end + end + RUBY + }, + { + name: "Mixed file", + source: <<~RUBY + module Routes + module Payments + class Create + def call + processor = PaymentProcessor.new + processor.process + end + end + end + end + + class PaymentProcessor + def process + logger.info("Processing payment") + end + end + RUBY + } +] + +detector = Spike::ClassDetector.new(Spike::HTTP_METRICS_CONFIG) +generator = Spike::QueryGenerator.new(Spike::HTTP_METRICS_CONFIG) + +test_cases.each do |tc| + puts "Test: #{tc[:name]}" + puts "-" * 40 + + detected = detector.detect(tc[:source]) + + if detected.empty? + puts " No HTTP endpoints detected" + else + detected.each do |class_name| + puts " Detected: #{class_name}" + puts " Query: #{generator.generate(class_name)}" + end + end + + puts +end + +puts "=" * 60 +puts "SPIKE CONCLUSIONS" +puts "=" * 60 +puts <<~CONCLUSIONS + + ✓ Can detect classes matching namespace patterns (Routes::*) + ✓ Can detect classes matching suffix patterns (*Controller) + ✓ Can generate Prometheus queries from class names + ✓ Config-driven approach is flexible for different frameworks + + Questions to resolve: + + 1. For Controllers, do we need action names too? + - "UsersController" vs "UsersController#show" + - Depends on how Prometheus labels are set up + + 2. Should this be a separate signal type (:http_endpoint)? + Or extend existing detection? + + 3. Panel generation: one panel per class, or grouped? + + 4. What about latency/error metrics? Same pattern? + +CONCLUSIONS From 9a5122542a68d0dc4dc6708eef82a9ce07f865e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Jan 2026 23:52:05 +0000 Subject: [PATCH 4/4] Revert "Add spike: HTTP metrics detection proof of concept" This reverts commit 759dfe9db099a736c45a4a12639b15151b636ec0. --- spike/http_metrics_spike.rb | 229 ------------------------------------ 1 file changed, 229 deletions(-) delete mode 100644 spike/http_metrics_spike.rb diff --git a/spike/http_metrics_spike.rb b/spike/http_metrics_spike.rb deleted file mode 100644 index 9798119..0000000 --- a/spike/http_metrics_spike.rb +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# SPIKE: HTTP Metrics Detection for Diffdash -# -# Question: Can we detect route/controller classes from changed files -# and generate Prometheus queries for HTTP traffic? -# -# Run: ruby spike/http_metrics_spike.rb - -require "parser/current" - -module Spike - # Simulated config - HTTP_METRICS_CONFIG = { - enabled: true, - detect: [ - { namespace: "Routes::" }, - { suffix: "Controller" }, - { namespace: "API::" } - ], - rate_metric: "service:http_request_rate:rate$__interval", - handler_label: "handler", - labels: { context: "$env" } - }.freeze - - # Simple class detector - class ClassDetector - def initialize(config) - @config = config - end - - def detect(source) - ast = Parser::CurrentRuby.parse(source) - classes = extract_classes(ast) - - classes.select { |c| matches_pattern?(c) } - end - - private - - def extract_classes(node, namespace = []) - return [] unless node.is_a?(Parser::AST::Node) - - results = [] - - case node.type - when :module - name = extract_const_name(node.children[0]) - results += extract_classes(node.children[1], namespace + [name]) - when :class - name = extract_const_name(node.children[0]) - full_name = (namespace + [name]).join("::") - results << full_name - results += extract_classes(node.children[2], namespace + [name]) - else - node.children.each do |child| - results += extract_classes(child, namespace) - end - end - - results - end - - def extract_const_name(node) - return nil unless node&.type == :const - parent, name = node.children - parent ? "#{extract_const_name(parent)}::#{name}" : name.to_s - end - - def matches_pattern?(class_name) - @config[:detect].any? do |pattern| - if pattern[:namespace] - class_name.start_with?(pattern[:namespace]) - elsif pattern[:suffix] - class_name.end_with?(pattern[:suffix]) - else - false - end - end - end - end - - # Query generator - class QueryGenerator - def initialize(config) - @config = config - end - - def generate(class_name) - metric = @config[:rate_metric] - handler_label = @config[:handler_label] - labels = @config[:labels].map { |k, v| "#{k}=~\"#{v}\"" } - labels << "#{handler_label}=\"#{class_name}\"" - - "sum(#{metric}{#{labels.join(', ')}})" - end - end -end - -# --- SPIKE TESTS --- - -puts "=" * 60 -puts "SPIKE: HTTP Metrics Detection" -puts "=" * 60 -puts - -# Test cases -test_cases = [ - { - name: "Routes pattern (your style)", - source: <<~RUBY - module Routes - module Users - class Show - def call - # endpoint logic - end - end - end - end - RUBY - }, - { - name: "Rails Controller", - source: <<~RUBY - class UsersController < ApplicationController - def show - @user = User.find(params[:id]) - end - - def create - @user = User.create(user_params) - end - end - RUBY - }, - { - name: "Grape API", - source: <<~RUBY - module API - module V1 - class Users < Grape::API - get ':id' do - User.find(params[:id]) - end - end - end - end - RUBY - }, - { - name: "Non-matching class (should be ignored)", - source: <<~RUBY - class PaymentProcessor - def process - StatsD.increment("payments.processed") - end - end - RUBY - }, - { - name: "Mixed file", - source: <<~RUBY - module Routes - module Payments - class Create - def call - processor = PaymentProcessor.new - processor.process - end - end - end - end - - class PaymentProcessor - def process - logger.info("Processing payment") - end - end - RUBY - } -] - -detector = Spike::ClassDetector.new(Spike::HTTP_METRICS_CONFIG) -generator = Spike::QueryGenerator.new(Spike::HTTP_METRICS_CONFIG) - -test_cases.each do |tc| - puts "Test: #{tc[:name]}" - puts "-" * 40 - - detected = detector.detect(tc[:source]) - - if detected.empty? - puts " No HTTP endpoints detected" - else - detected.each do |class_name| - puts " Detected: #{class_name}" - puts " Query: #{generator.generate(class_name)}" - end - end - - puts -end - -puts "=" * 60 -puts "SPIKE CONCLUSIONS" -puts "=" * 60 -puts <<~CONCLUSIONS - - ✓ Can detect classes matching namespace patterns (Routes::*) - ✓ Can detect classes matching suffix patterns (*Controller) - ✓ Can generate Prometheus queries from class names - ✓ Config-driven approach is flexible for different frameworks - - Questions to resolve: - - 1. For Controllers, do we need action names too? - - "UsersController" vs "UsersController#show" - - Depends on how Prometheus labels are set up - - 2. Should this be a separate signal type (:http_endpoint)? - Or extend existing detection? - - 3. Panel generation: one panel per class, or grouped? - - 4. What about latency/error metrics? Same pattern? - -CONCLUSIONS