binocs 0.1.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +528 -0
  4. data/Rakefile +7 -0
  5. data/app/assets/javascripts/binocs/application.js +105 -0
  6. data/app/assets/stylesheets/binocs/application.css +67 -0
  7. data/app/channels/binocs/application_cable/channel.rb +8 -0
  8. data/app/channels/binocs/application_cable/connection.rb +8 -0
  9. data/app/channels/binocs/requests_channel.rb +13 -0
  10. data/app/controllers/binocs/application_controller.rb +62 -0
  11. data/app/controllers/binocs/requests_controller.rb +69 -0
  12. data/app/helpers/binocs/application_helper.rb +61 -0
  13. data/app/models/binocs/application_record.rb +7 -0
  14. data/app/models/binocs/request.rb +198 -0
  15. data/app/views/binocs/requests/_empty_list.html.erb +9 -0
  16. data/app/views/binocs/requests/_request.html.erb +61 -0
  17. data/app/views/binocs/requests/index.html.erb +115 -0
  18. data/app/views/binocs/requests/show.html.erb +227 -0
  19. data/app/views/layouts/binocs/application.html.erb +109 -0
  20. data/config/importmap.rb +6 -0
  21. data/config/routes.rb +11 -0
  22. data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
  23. data/exe/binocs +86 -0
  24. data/lib/binocs/agent.rb +153 -0
  25. data/lib/binocs/agent_context.rb +165 -0
  26. data/lib/binocs/agent_manager.rb +302 -0
  27. data/lib/binocs/configuration.rb +65 -0
  28. data/lib/binocs/engine.rb +61 -0
  29. data/lib/binocs/log_subscriber.rb +56 -0
  30. data/lib/binocs/middleware/request_recorder.rb +264 -0
  31. data/lib/binocs/swagger/client.rb +100 -0
  32. data/lib/binocs/swagger/path_matcher.rb +118 -0
  33. data/lib/binocs/tui/agent_output.rb +163 -0
  34. data/lib/binocs/tui/agents_list.rb +195 -0
  35. data/lib/binocs/tui/app.rb +726 -0
  36. data/lib/binocs/tui/colors.rb +115 -0
  37. data/lib/binocs/tui/filter_menu.rb +162 -0
  38. data/lib/binocs/tui/help_screen.rb +93 -0
  39. data/lib/binocs/tui/request_detail.rb +899 -0
  40. data/lib/binocs/tui/request_list.rb +268 -0
  41. data/lib/binocs/tui/spirit_animal.rb +235 -0
  42. data/lib/binocs/tui/window.rb +98 -0
  43. data/lib/binocs/tui.rb +24 -0
  44. data/lib/binocs/version.rb +5 -0
  45. data/lib/binocs.rb +27 -0
  46. data/lib/generators/binocs/install/install_generator.rb +61 -0
  47. data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
  48. data/lib/generators/binocs/install/templates/initializer.rb +25 -0
  49. data/lib/tasks/binocs_tasks.rake +38 -0
  50. metadata +149 -0
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "turbo-rails"
4
+ require "stimulus-rails"
5
+
6
+ module Binocs
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Binocs
9
+
10
+ config.generators do |g|
11
+ g.test_framework :rspec
12
+ g.assets false
13
+ g.helper false
14
+ end
15
+
16
+ initializer "binocs.middleware" do |app|
17
+ next unless Binocs.enabled?
18
+
19
+ require_relative "middleware/request_recorder"
20
+ app.middleware.use Binocs::Middleware::RequestRecorder
21
+ end
22
+
23
+ initializer "binocs.log_subscriber" do
24
+ next unless Binocs.enabled?
25
+
26
+ require_relative "log_subscriber"
27
+ Binocs::LogSubscriber.attach_to :action_controller
28
+ end
29
+
30
+ initializer "binocs.assets" do |app|
31
+ next unless Binocs.enabled?
32
+
33
+ app.config.assets.precompile += %w[binocs/application.css binocs/application.js] if app.config.respond_to?(:assets)
34
+ end
35
+
36
+ initializer "binocs.importmap", before: "importmap" do |app|
37
+ next unless Binocs.enabled?
38
+ next unless app.config.respond_to?(:importmap)
39
+
40
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
41
+ end
42
+
43
+ initializer "binocs.helpers" do
44
+ ActiveSupport.on_load(:action_controller_base) do
45
+ helper Binocs::ApplicationHelper
46
+ end
47
+ end
48
+
49
+ config.after_initialize do
50
+ if Rails.env.production? && Binocs.configuration.enabled
51
+ Rails.logger.warn "[Binocs] WARNING: Binocs is disabled in production for security reasons."
52
+ Binocs.configuration.enabled = false
53
+ end
54
+
55
+ # Configure renderer for Turbo broadcasts (no request context available)
56
+ if defined?(ApplicationController)
57
+ ApplicationController.renderer.defaults[:http_host] = ENV.fetch("DOMAIN", "localhost:3000")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ class LogSubscriber < ActiveSupport::LogSubscriber
5
+ def process_action(event)
6
+ return unless Thread.current[:binocs_logs]
7
+
8
+ payload = event.payload
9
+
10
+ Thread.current[:binocs_logs] << {
11
+ type: "controller",
12
+ controller: payload[:controller],
13
+ action: payload[:action],
14
+ format: payload[:format],
15
+ method: payload[:method],
16
+ path: payload[:path],
17
+ status: payload[:status],
18
+ view_runtime: payload[:view_runtime]&.round(2),
19
+ db_runtime: payload[:db_runtime]&.round(2),
20
+ duration: event.duration.round(2),
21
+ timestamp: Time.current.iso8601
22
+ }
23
+ end
24
+
25
+ def halted_callback(event)
26
+ return unless Thread.current[:binocs_logs]
27
+
28
+ Thread.current[:binocs_logs] << {
29
+ type: "halted",
30
+ filter: event.payload[:filter],
31
+ timestamp: Time.current.iso8601
32
+ }
33
+ end
34
+
35
+ def send_data(event)
36
+ return unless Thread.current[:binocs_logs]
37
+
38
+ Thread.current[:binocs_logs] << {
39
+ type: "send_data",
40
+ filename: event.payload[:filename],
41
+ timestamp: Time.current.iso8601
42
+ }
43
+ end
44
+
45
+ def redirect_to(event)
46
+ return unless Thread.current[:binocs_logs]
47
+
48
+ Thread.current[:binocs_logs] << {
49
+ type: "redirect",
50
+ location: event.payload[:location],
51
+ status: event.payload[:status],
52
+ timestamp: Time.current.iso8601
53
+ }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Binocs
6
+ module Middleware
7
+ class RequestRecorder
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless Binocs.enabled?
14
+ return @app.call(env) if ignored_path?(env["PATH_INFO"])
15
+
16
+ request_id = SecureRandom.uuid
17
+ Thread.current[:binocs_request_id] = request_id
18
+ Thread.current[:binocs_logs] = []
19
+ Thread.current[:binocs_start_time] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ Thread.current[:binocs_memory_before] = get_memory_usage
21
+
22
+ request = ActionDispatch::Request.new(env)
23
+
24
+ recorded_request = build_request_record(request, request_id)
25
+
26
+ begin
27
+ status, headers, response = @app.call(env)
28
+
29
+ complete_request_record(recorded_request, status, headers, response, env)
30
+
31
+ [status, headers, response]
32
+ rescue Exception => e
33
+ record_exception(recorded_request, e)
34
+ raise
35
+ ensure
36
+ save_request_record(recorded_request)
37
+ cleanup_thread_locals
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def ignored_path?(path)
44
+ Binocs.configuration.ignored_paths.any? { |ignored| path.start_with?(ignored) }
45
+ end
46
+
47
+ def build_request_record(request, request_id)
48
+ {
49
+ uuid: request_id,
50
+ method: request.request_method,
51
+ path: request.path,
52
+ full_url: request.original_url,
53
+ params: sanitize_params(request),
54
+ request_headers: extract_headers(request.headers),
55
+ ip_address: request.remote_ip,
56
+ session_id: request.session.id&.to_s,
57
+ content_type: request.content_type,
58
+ request_body: extract_request_body(request)
59
+ }
60
+ end
61
+
62
+ def complete_request_record(record, status, headers, response, env)
63
+ record[:status_code] = status
64
+ record[:response_headers] = headers.to_h
65
+ record[:response_body] = extract_response_body(response, headers)
66
+ record[:duration_ms] = calculate_duration
67
+ record[:memory_delta] = calculate_memory_delta
68
+ record[:logs] = Thread.current[:binocs_logs] || []
69
+ record[:controller_name] = env["action_controller.instance"]&.class&.name
70
+ record[:action_name] = env["action_controller.instance"]&.action_name
71
+ record[:route_name] = extract_route_name(env)
72
+ end
73
+
74
+ def record_exception(record, exception)
75
+ record[:exception] = {
76
+ class: exception.class.name,
77
+ message: exception.message,
78
+ backtrace: exception.backtrace&.first(20)
79
+ }
80
+ record[:status_code] ||= 500
81
+ record[:duration_ms] = calculate_duration
82
+ record[:memory_delta] = calculate_memory_delta
83
+ record[:logs] = Thread.current[:binocs_logs] || []
84
+ end
85
+
86
+ def save_request_record(record)
87
+ return unless record[:status_code]
88
+
89
+ Binocs::Request.create!(
90
+ uuid: record[:uuid],
91
+ method: record[:method],
92
+ path: record[:path],
93
+ full_url: record[:full_url],
94
+ controller_name: record[:controller_name],
95
+ action_name: record[:action_name],
96
+ route_name: record[:route_name],
97
+ params: record[:params],
98
+ request_headers: record[:request_headers],
99
+ response_headers: record[:response_headers],
100
+ request_body: record[:request_body],
101
+ response_body: record[:response_body],
102
+ status_code: record[:status_code],
103
+ duration_ms: record[:duration_ms],
104
+ ip_address: record[:ip_address],
105
+ session_id: record[:session_id],
106
+ logs: record[:logs],
107
+ exception: record[:exception],
108
+ memory_delta: record[:memory_delta]
109
+ )
110
+
111
+ broadcast_new_request(record)
112
+ cleanup_old_requests
113
+ rescue => e
114
+ Rails.logger.error "[Binocs] Failed to save request: #{e.message}"
115
+ end
116
+
117
+ def broadcast_new_request(record)
118
+ unless defined?(Turbo::StreamsChannel)
119
+ Rails.logger.debug "[Binocs] Turbo::StreamsChannel not defined, skipping broadcast"
120
+ return
121
+ end
122
+
123
+ request = Binocs::Request.find_by(uuid: record[:uuid])
124
+ unless request
125
+ Rails.logger.debug "[Binocs] Request not found for broadcast: #{record[:uuid]}"
126
+ return
127
+ end
128
+
129
+ Rails.logger.debug "[Binocs] Broadcasting new request: #{request.method} #{request.path}"
130
+
131
+ Turbo::StreamsChannel.broadcast_prepend_to(
132
+ "binocs_requests",
133
+ target: "requests-list",
134
+ partial: "binocs/requests/request",
135
+ locals: { request: request }
136
+ )
137
+
138
+ Rails.logger.debug "[Binocs] Broadcast complete"
139
+ rescue => e
140
+ Rails.logger.error "[Binocs] Failed to broadcast request: #{e.message}"
141
+ Rails.logger.error e.backtrace.first(5).join("\n")
142
+ end
143
+
144
+ def cleanup_old_requests
145
+ return unless rand < 0.01 # Only run 1% of the time
146
+
147
+ max_requests = Binocs.configuration.max_requests
148
+ retention_period = Binocs.configuration.retention_period
149
+
150
+ Binocs::Request.where("created_at < ?", retention_period.ago).delete_all
151
+
152
+ count = Binocs::Request.count
153
+ if count > max_requests
154
+ Binocs::Request.order(created_at: :asc).limit(count - max_requests).delete_all
155
+ end
156
+ rescue => e
157
+ Rails.logger.error "[Binocs] Failed to cleanup old requests: #{e.message}"
158
+ end
159
+
160
+ def sanitize_params(request)
161
+ params = request.filtered_parameters.except("controller", "action")
162
+ params.deep_transform_values { |v| truncate_value(v) }
163
+ rescue
164
+ {}
165
+ end
166
+
167
+ def extract_headers(headers)
168
+ result = {}
169
+ headers.each do |key, value|
170
+ next unless key.start_with?("HTTP_") || %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
171
+
172
+ header_name = key.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")
173
+ result[header_name] = value.to_s
174
+ end
175
+ result.except("Cookie") # Don't store cookies for security
176
+ end
177
+
178
+ def extract_request_body(request)
179
+ return nil unless Binocs.configuration.record_request_body
180
+ return nil if ignored_content_type?(request.content_type)
181
+
182
+ body = request.body.read
183
+ request.body.rewind
184
+ truncate_body(body)
185
+ rescue
186
+ nil
187
+ end
188
+
189
+ def extract_response_body(response, headers)
190
+ return nil unless Binocs.configuration.record_response_body
191
+
192
+ content_type = headers["Content-Type"]
193
+ return nil if ignored_content_type?(content_type)
194
+
195
+ body = ""
196
+ response.each { |part| body << part.to_s }
197
+ truncate_body(body)
198
+ rescue
199
+ nil
200
+ end
201
+
202
+ def ignored_content_type?(content_type)
203
+ return true if content_type.nil?
204
+
205
+ Binocs.configuration.ignored_content_types.any? do |ignored|
206
+ content_type.to_s.include?(ignored)
207
+ end
208
+ end
209
+
210
+ def extract_route_name(env)
211
+ route = Rails.application.routes.recognize_path(
212
+ env["PATH_INFO"],
213
+ method: env["REQUEST_METHOD"]
214
+ )
215
+ "#{route[:controller]}##{route[:action]}"
216
+ rescue
217
+ nil
218
+ end
219
+
220
+ def truncate_value(value)
221
+ return value unless value.is_a?(String)
222
+ return value if value.length <= 1000
223
+
224
+ "#{value[0, 1000]}... (truncated)"
225
+ end
226
+
227
+ def truncate_body(body)
228
+ return nil if body.nil? || body.empty?
229
+
230
+ max_size = Binocs.configuration.max_body_size
231
+ if body.bytesize > max_size
232
+ "#{body.byteslice(0, max_size)}... (truncated, #{body.bytesize} bytes total)"
233
+ else
234
+ body
235
+ end
236
+ end
237
+
238
+ def calculate_duration
239
+ start_time = Thread.current[:binocs_start_time]
240
+ return nil unless start_time
241
+
242
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
243
+ end
244
+
245
+ def get_memory_usage
246
+ `ps -o rss= -p #{Process.pid}`.to_i * 1024 rescue 0
247
+ end
248
+
249
+ def calculate_memory_delta
250
+ before = Thread.current[:binocs_memory_before]
251
+ return nil unless before
252
+
253
+ get_memory_usage - before
254
+ end
255
+
256
+ def cleanup_thread_locals
257
+ Thread.current[:binocs_request_id] = nil
258
+ Thread.current[:binocs_logs] = nil
259
+ Thread.current[:binocs_start_time] = nil
260
+ Thread.current[:binocs_memory_before] = nil
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'yaml'
6
+
7
+ module Binocs
8
+ module Swagger
9
+ class Client
10
+ CACHE_TTL = 300 # 5 minutes
11
+
12
+ class << self
13
+ def fetch_spec
14
+ return nil unless Binocs.configuration.swagger_enabled?
15
+
16
+ if cached_spec_valid?
17
+ @cached_spec
18
+ else
19
+ refresh_spec
20
+ end
21
+ end
22
+
23
+ def clear_cache
24
+ @cached_spec = nil
25
+ @cache_time = nil
26
+ end
27
+
28
+ private
29
+
30
+ def cached_spec_valid?
31
+ @cached_spec && @cache_time && (Time.now - @cache_time) < CACHE_TTL
32
+ end
33
+
34
+ def refresh_spec
35
+ spec_url = build_spec_url
36
+ return nil unless spec_url
37
+
38
+ begin
39
+ response = fetch_with_redirects(spec_url)
40
+ return nil unless response
41
+
42
+ @cached_spec = parse_spec(response.body, response['content-type'])
43
+ @cache_time = Time.now
44
+ @cached_spec
45
+ rescue StandardError => e
46
+ Rails.logger.error("[Binocs] Failed to fetch Swagger spec: #{e.message}") if defined?(Rails)
47
+ nil
48
+ end
49
+ end
50
+
51
+ def fetch_with_redirects(url, limit = 5)
52
+ return nil if limit == 0
53
+
54
+ uri = URI(url)
55
+ response = Net::HTTP.get_response(uri)
56
+
57
+ case response
58
+ when Net::HTTPSuccess
59
+ response
60
+ when Net::HTTPRedirection
61
+ redirect_url = response['location']
62
+ # Handle relative redirects
63
+ redirect_url = URI.join(url, redirect_url).to_s unless redirect_url.start_with?('http')
64
+ fetch_with_redirects(redirect_url, limit - 1)
65
+ else
66
+ nil
67
+ end
68
+ end
69
+
70
+ def build_spec_url
71
+ spec_path = Binocs.configuration.swagger_spec_url
72
+ return nil if spec_path.blank?
73
+
74
+ # If it's already a full URL, use it directly
75
+ return spec_path if spec_path.start_with?('http://', 'https://')
76
+
77
+ # Otherwise, build from Rails default_url_options or localhost
78
+ if defined?(Rails)
79
+ host = Rails.application.routes.default_url_options[:host] || 'localhost'
80
+ port = Rails.application.routes.default_url_options[:port] || 3000
81
+ protocol = Rails.application.routes.default_url_options[:protocol] || 'http'
82
+ "#{protocol}://#{host}:#{port}#{spec_path}"
83
+ else
84
+ "http://localhost:3000#{spec_path}"
85
+ end
86
+ end
87
+
88
+ def parse_spec(body, content_type)
89
+ if content_type&.include?('yaml') || body.strip.start_with?('openapi:', 'swagger:')
90
+ YAML.safe_load(body, permitted_classes: [Date, Time])
91
+ else
92
+ JSON.parse(body)
93
+ end
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module Swagger
5
+ class PathMatcher
6
+ class << self
7
+ def find_operation(request)
8
+ spec = Client.fetch_spec
9
+ return nil unless spec && spec['paths']
10
+
11
+ method = request.method.downcase
12
+ path = normalize_path(request.path)
13
+
14
+ spec['paths'].each do |spec_path, path_item|
15
+ next unless path_item.is_a?(Hash)
16
+
17
+ operation = path_item[method]
18
+ next unless operation
19
+
20
+ if path_matches?(path, spec_path)
21
+ return build_operation_result(spec_path, method, operation, path_item, spec)
22
+ end
23
+ end
24
+
25
+ nil
26
+ end
27
+
28
+ def build_swagger_ui_url(operation)
29
+ return nil unless operation
30
+
31
+ base_url = Binocs.configuration.swagger_ui_url
32
+ return nil if base_url.blank?
33
+
34
+ # Build full URL if needed
35
+ unless base_url.start_with?('http://', 'https://')
36
+ if defined?(Rails)
37
+ host = Rails.application.routes.default_url_options[:host] || 'localhost'
38
+ port = Rails.application.routes.default_url_options[:port] || 3000
39
+ protocol = Rails.application.routes.default_url_options[:protocol] || 'http'
40
+ base_url = "#{protocol}://#{host}:#{port}#{base_url}"
41
+ else
42
+ base_url = "http://localhost:3000#{base_url}"
43
+ end
44
+ end
45
+
46
+ # Build anchor for Swagger UI
47
+ # Format: #/{tag}/{operationId}
48
+ # Example: #/Company%20Invitations/get_v1_companies__company_uuid__invitations
49
+ tag = operation[:tags]&.first || 'default'
50
+ encoded_tag = URI.encode_www_form_component(tag).gsub('+', '%20')
51
+
52
+ if operation[:operation_id]
53
+ "#{base_url}#/#{encoded_tag}/#{operation[:operation_id]}"
54
+ else
55
+ # Fallback: build operation ID from method and path
56
+ # get /v1/companies/{company_uuid}/invitations -> get_v1_companies__company_uuid__invitations
57
+ fallback_op_id = "#{operation[:method]}#{operation[:spec_path]}"
58
+ .gsub('/', '_')
59
+ .gsub(/[{}]/, '_')
60
+ .gsub(/__+/, '__')
61
+ "#{base_url}#/#{encoded_tag}/#{fallback_op_id}"
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def normalize_path(path)
68
+ # Remove query string and normalize
69
+ path = path.split('?').first
70
+ path = path.chomp('/')
71
+ path = '/' if path.empty?
72
+ path
73
+ end
74
+
75
+ def path_matches?(request_path, spec_path)
76
+ # Convert spec path template to regex
77
+ # /users/{id}/posts/{post_id} -> /users/[^/]+/posts/[^/]+
78
+ pattern = spec_path.gsub(/\{[^}]+\}/, '[^/]+')
79
+ pattern = "^#{pattern}$"
80
+
81
+ Regexp.new(pattern).match?(request_path)
82
+ end
83
+
84
+ def build_operation_result(spec_path, method, operation, path_item, spec)
85
+ {
86
+ spec_path: spec_path,
87
+ method: method,
88
+ operation_id: operation['operationId'],
89
+ summary: operation['summary'],
90
+ description: operation['description'],
91
+ tags: operation['tags'] || [],
92
+ parameters: collect_parameters(operation, path_item),
93
+ request_body: operation['requestBody'],
94
+ responses: operation['responses'] || {},
95
+ deprecated: operation['deprecated'] || false,
96
+ security: operation['security'] || spec['security']
97
+ }
98
+ end
99
+
100
+ def collect_parameters(operation, path_item)
101
+ params = []
102
+
103
+ # Path-level parameters
104
+ if path_item['parameters'].is_a?(Array)
105
+ params.concat(path_item['parameters'])
106
+ end
107
+
108
+ # Operation-level parameters
109
+ if operation['parameters'].is_a?(Array)
110
+ params.concat(operation['parameters'])
111
+ end
112
+
113
+ params.uniq { |p| "#{p['in']}-#{p['name']}" }
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end