codebase_index 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/exe/codebase-console-mcp +22 -0
- data/exe/codebase-index-mcp +61 -0
- data/exe/codebase-index-mcp-http +64 -0
- data/exe/codebase-index-mcp-start +58 -0
- data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
- data/lib/codebase_index/ast/method_extractor.rb +76 -0
- data/lib/codebase_index/ast/node.rb +88 -0
- data/lib/codebase_index/ast/parser.rb +653 -0
- data/lib/codebase_index/ast.rb +6 -0
- data/lib/codebase_index/builder.rb +137 -0
- data/lib/codebase_index/chunking/chunk.rb +84 -0
- data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
- data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
- data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
- data/lib/codebase_index/console/audit_logger.rb +75 -0
- data/lib/codebase_index/console/bridge.rb +170 -0
- data/lib/codebase_index/console/confirmation.rb +90 -0
- data/lib/codebase_index/console/connection_manager.rb +173 -0
- data/lib/codebase_index/console/console_response_renderer.rb +78 -0
- data/lib/codebase_index/console/model_validator.rb +81 -0
- data/lib/codebase_index/console/safe_context.rb +82 -0
- data/lib/codebase_index/console/server.rb +557 -0
- data/lib/codebase_index/console/sql_validator.rb +172 -0
- data/lib/codebase_index/console/tools/tier1.rb +118 -0
- data/lib/codebase_index/console/tools/tier2.rb +117 -0
- data/lib/codebase_index/console/tools/tier3.rb +110 -0
- data/lib/codebase_index/console/tools/tier4.rb +79 -0
- data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
- data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
- data/lib/codebase_index/cost_model/estimator.rb +128 -0
- data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
- data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
- data/lib/codebase_index/cost_model.rb +22 -0
- data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
- data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
- data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/codebase_index/db/migrator.rb +71 -0
- data/lib/codebase_index/db/schema_version.rb +73 -0
- data/lib/codebase_index/dependency_graph.rb +227 -0
- data/lib/codebase_index/embedding/indexer.rb +130 -0
- data/lib/codebase_index/embedding/openai.rb +105 -0
- data/lib/codebase_index/embedding/provider.rb +135 -0
- data/lib/codebase_index/embedding/text_preparer.rb +112 -0
- data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
- data/lib/codebase_index/evaluation/evaluator.rb +146 -0
- data/lib/codebase_index/evaluation/metrics.rb +79 -0
- data/lib/codebase_index/evaluation/query_set.rb +148 -0
- data/lib/codebase_index/evaluation/report_generator.rb +90 -0
- data/lib/codebase_index/extracted_unit.rb +145 -0
- data/lib/codebase_index/extractor.rb +956 -0
- data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
- data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
- data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
- data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
- data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
- data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
- data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
- data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
- data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
- data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
- data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
- data/lib/codebase_index/extractors/event_extractor.rb +211 -0
- data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
- data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
- data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
- data/lib/codebase_index/extractors/job_extractor.rb +369 -0
- data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
- data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
- data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
- data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
- data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
- data/lib/codebase_index/extractors/model_extractor.rb +960 -0
- data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
- data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
- data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
- data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
- data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
- data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
- data/lib/codebase_index/extractors/route_extractor.rb +181 -0
- data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
- data/lib/codebase_index/extractors/service_extractor.rb +254 -0
- data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
- data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
- data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
- data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
- data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
- data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
- data/lib/codebase_index/feedback/gap_detector.rb +89 -0
- data/lib/codebase_index/feedback/store.rb +119 -0
- data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
- data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/codebase_index/flow_assembler.rb +290 -0
- data/lib/codebase_index/flow_document.rb +191 -0
- data/lib/codebase_index/flow_precomputer.rb +102 -0
- data/lib/codebase_index/formatting/base.rb +40 -0
- data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
- data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
- data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
- data/lib/codebase_index/formatting/human_adapter.rb +78 -0
- data/lib/codebase_index/graph_analyzer.rb +374 -0
- data/lib/codebase_index/mcp/index_reader.rb +394 -0
- data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
- data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
- data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/codebase_index/mcp/server.rb +935 -0
- data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
- data/lib/codebase_index/model_name_cache.rb +51 -0
- data/lib/codebase_index/notion/client.rb +217 -0
- data/lib/codebase_index/notion/exporter.rb +219 -0
- data/lib/codebase_index/notion/mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
- data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
- data/lib/codebase_index/notion/rate_limiter.rb +68 -0
- data/lib/codebase_index/observability/health_check.rb +81 -0
- data/lib/codebase_index/observability/instrumentation.rb +34 -0
- data/lib/codebase_index/observability/structured_logger.rb +75 -0
- data/lib/codebase_index/operator/error_escalator.rb +81 -0
- data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
- data/lib/codebase_index/operator/status_reporter.rb +80 -0
- data/lib/codebase_index/railtie.rb +26 -0
- data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
- data/lib/codebase_index/resilience/index_validator.rb +185 -0
- data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
- data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
- data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
- data/lib/codebase_index/retrieval/ranker.rb +273 -0
- data/lib/codebase_index/retrieval/search_executor.rb +327 -0
- data/lib/codebase_index/retriever.rb +160 -0
- data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
- data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
- data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
- data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
- data/lib/codebase_index/ruby_analyzer.rb +87 -0
- data/lib/codebase_index/session_tracer/file_store.rb +111 -0
- data/lib/codebase_index/session_tracer/middleware.rb +143 -0
- data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
- data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
- data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
- data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
- data/lib/codebase_index/session_tracer/store.rb +67 -0
- data/lib/codebase_index/storage/graph_store.rb +120 -0
- data/lib/codebase_index/storage/metadata_store.rb +169 -0
- data/lib/codebase_index/storage/pgvector.rb +163 -0
- data/lib/codebase_index/storage/qdrant.rb +172 -0
- data/lib/codebase_index/storage/vector_store.rb +156 -0
- data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
- data/lib/codebase_index/version.rb +5 -0
- data/lib/codebase_index.rb +223 -0
- data/lib/generators/codebase_index/install_generator.rb +32 -0
- data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
- data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
- data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
- data/lib/tasks/codebase_index.rake +583 -0
- data/lib/tasks/codebase_index_evaluation.rake +115 -0
- metadata +252 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ast'
|
|
4
|
+
require_relative 'extracted_unit'
|
|
5
|
+
require_relative 'ruby_analyzer/class_analyzer'
|
|
6
|
+
require_relative 'ruby_analyzer/method_analyzer'
|
|
7
|
+
require_relative 'ruby_analyzer/dataflow_analyzer'
|
|
8
|
+
require_relative 'ruby_analyzer/trace_enricher'
|
|
9
|
+
|
|
10
|
+
module CodebaseIndex
|
|
11
|
+
# Analyzes plain Ruby source code and produces ExtractedUnit objects.
|
|
12
|
+
#
|
|
13
|
+
# Orchestrates ClassAnalyzer, MethodAnalyzer, DataFlowAnalyzer, and
|
|
14
|
+
# optional TraceEnricher to extract structured data from Ruby files.
|
|
15
|
+
#
|
|
16
|
+
# @example Analyze gem source
|
|
17
|
+
# units = CodebaseIndex::RubyAnalyzer.analyze(paths: ["lib/"])
|
|
18
|
+
# units.select { |u| u.type == :ruby_class }.map(&:identifier)
|
|
19
|
+
#
|
|
20
|
+
module RubyAnalyzer
|
|
21
|
+
class << self
|
|
22
|
+
# Analyze Ruby source files and produce ExtractedUnit objects.
|
|
23
|
+
#
|
|
24
|
+
# @param paths [Array<String>] File paths or directories to analyze
|
|
25
|
+
# @param trace_data [Array<Hash>, nil] Optional runtime trace data for enrichment
|
|
26
|
+
# @return [Array<ExtractedUnit>] All extracted units
|
|
27
|
+
def analyze(paths:, trace_data: nil)
|
|
28
|
+
files = discover_files(paths)
|
|
29
|
+
return [] if files.empty?
|
|
30
|
+
|
|
31
|
+
parser = Ast::Parser.new
|
|
32
|
+
class_analyzer = ClassAnalyzer.new(parser: parser)
|
|
33
|
+
method_analyzer = MethodAnalyzer.new(parser: parser)
|
|
34
|
+
dataflow_analyzer = DataFlowAnalyzer.new(parser: parser)
|
|
35
|
+
|
|
36
|
+
units = []
|
|
37
|
+
|
|
38
|
+
files.each do |file_path|
|
|
39
|
+
source = read_file(file_path)
|
|
40
|
+
next unless source
|
|
41
|
+
|
|
42
|
+
units.concat(class_analyzer.analyze(source: source, file_path: file_path))
|
|
43
|
+
units.concat(method_analyzer.analyze(source: source, file_path: file_path))
|
|
44
|
+
rescue CodebaseIndex::ExtractionError
|
|
45
|
+
# Skip files that fail to parse
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
dataflow_analyzer.annotate(units)
|
|
50
|
+
TraceEnricher.merge(units: units, trace_data: trace_data) if trace_data
|
|
51
|
+
|
|
52
|
+
units
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Discover .rb files from a list of paths (files and/or directories).
|
|
58
|
+
#
|
|
59
|
+
# @param paths [Array<String>] File paths or directory paths
|
|
60
|
+
# @return [Array<String>] Absolute paths to .rb files
|
|
61
|
+
def discover_files(paths)
|
|
62
|
+
files = []
|
|
63
|
+
paths.each do |path|
|
|
64
|
+
expanded = File.expand_path(path)
|
|
65
|
+
if File.directory?(expanded)
|
|
66
|
+
Dir.glob(File.join(expanded, '**', '*.rb')).each do |f|
|
|
67
|
+
files << f
|
|
68
|
+
end
|
|
69
|
+
elsif File.file?(expanded) && expanded.end_with?('.rb')
|
|
70
|
+
files << expanded
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
files.uniq
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Read a file safely, returning nil on failure.
|
|
77
|
+
#
|
|
78
|
+
# @param path [String] File path
|
|
79
|
+
# @return [String, nil] File contents or nil
|
|
80
|
+
def read_file(path)
|
|
81
|
+
File.read(path)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'store'
|
|
6
|
+
|
|
7
|
+
module CodebaseIndex
|
|
8
|
+
module SessionTracer
|
|
9
|
+
# File-backed session store using JSONL (one JSON object per line).
|
|
10
|
+
#
|
|
11
|
+
# Sessions are stored as individual files in a configurable directory:
|
|
12
|
+
# {base_dir}/{session_id}.jsonl
|
|
13
|
+
#
|
|
14
|
+
# Append-only with file locking for concurrency safety. Zero external dependencies.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# store = FileStore.new(base_dir: "tmp/codebase_index/sessions")
|
|
18
|
+
# store.record("abc123", { controller: "PostsController", action: "create" })
|
|
19
|
+
# store.read("abc123") # => [{ "controller" => "PostsController", ... }]
|
|
20
|
+
#
|
|
21
|
+
class FileStore < Store
|
|
22
|
+
# @param base_dir [String] Directory for session JSONL files
|
|
23
|
+
def initialize(base_dir:)
|
|
24
|
+
super()
|
|
25
|
+
@base_dir = base_dir
|
|
26
|
+
FileUtils.mkdir_p(@base_dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Append a request record to a session's JSONL file.
|
|
30
|
+
#
|
|
31
|
+
# Uses file locking (LOCK_EX) for concurrency safety.
|
|
32
|
+
#
|
|
33
|
+
# @param session_id [String] The session identifier
|
|
34
|
+
# @param request_data [Hash] Request metadata to store
|
|
35
|
+
# @return [void]
|
|
36
|
+
def record(session_id, request_data)
|
|
37
|
+
path = session_path(session_id)
|
|
38
|
+
line = "#{JSON.generate(request_data)}\n"
|
|
39
|
+
|
|
40
|
+
File.open(path, 'a') do |f|
|
|
41
|
+
f.flock(File::LOCK_EX)
|
|
42
|
+
f.write(line)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Read all request records for a session, ordered by file line order (timestamp).
|
|
47
|
+
#
|
|
48
|
+
# @param session_id [String] The session identifier
|
|
49
|
+
# @return [Array<Hash>] Request records, oldest first
|
|
50
|
+
def read(session_id)
|
|
51
|
+
path = session_path(session_id)
|
|
52
|
+
return [] unless File.exist?(path)
|
|
53
|
+
|
|
54
|
+
File.readlines(path).filter_map do |line|
|
|
55
|
+
stripped = line.strip
|
|
56
|
+
next if stripped.empty?
|
|
57
|
+
|
|
58
|
+
JSON.parse(stripped)
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# List recent session summaries, sorted by last modification time (newest first).
|
|
65
|
+
#
|
|
66
|
+
# @param limit [Integer] Maximum number of sessions to return
|
|
67
|
+
# @return [Array<Hash>] Session summaries
|
|
68
|
+
def sessions(limit: 20)
|
|
69
|
+
pattern = File.join(@base_dir, '*.jsonl')
|
|
70
|
+
files = Dir.glob(pattern).sort_by { |f| -File.mtime(f).to_f }
|
|
71
|
+
|
|
72
|
+
files.first(limit).map do |file|
|
|
73
|
+
session_id = File.basename(file, '.jsonl')
|
|
74
|
+
requests = read(session_id)
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
'session_id' => session_id,
|
|
78
|
+
'request_count' => requests.size,
|
|
79
|
+
'first_request' => requests.first&.fetch('timestamp', nil),
|
|
80
|
+
'last_request' => requests.last&.fetch('timestamp', nil)
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remove all data for a single session.
|
|
86
|
+
#
|
|
87
|
+
# @param session_id [String] The session identifier
|
|
88
|
+
# @return [void]
|
|
89
|
+
def clear(session_id)
|
|
90
|
+
path = session_path(session_id)
|
|
91
|
+
FileUtils.rm_f(path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Remove all session data.
|
|
95
|
+
#
|
|
96
|
+
# @return [void]
|
|
97
|
+
def clear_all
|
|
98
|
+
pattern = File.join(@base_dir, '*.jsonl')
|
|
99
|
+
Dir.glob(pattern).each { |f| File.delete(f) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# @param session_id [String]
|
|
105
|
+
# @return [String] Full path to the session's JSONL file
|
|
106
|
+
def session_path(session_id)
|
|
107
|
+
File.join(@base_dir, "#{sanitize_session_id(session_id)}.jsonl")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module CodebaseIndex
|
|
6
|
+
module SessionTracer
|
|
7
|
+
# Rack middleware that captures request metadata for session tracing.
|
|
8
|
+
#
|
|
9
|
+
# Wraps `@app.call(env)`, records after response. Extracts controller/action
|
|
10
|
+
# from `env['action_dispatch.request.path_parameters']`. Session ID from
|
|
11
|
+
# `X-Trace-Session` header first, falls back to `request.session.id`.
|
|
12
|
+
#
|
|
13
|
+
# Fire-and-forget writes — `rescue StandardError` on recording, never breaks the request.
|
|
14
|
+
#
|
|
15
|
+
# @example Inserting into a Rails middleware stack
|
|
16
|
+
# app.middleware.insert_after ActionDispatch::Session::CookieStore,
|
|
17
|
+
# CodebaseIndex::SessionTracer::Middleware
|
|
18
|
+
#
|
|
19
|
+
class Middleware
|
|
20
|
+
# @param app [#call] The downstream Rack application
|
|
21
|
+
# @param store [Store] Session trace store backend
|
|
22
|
+
# @param session_id_proc [Proc, nil] Custom session ID extraction (receives env)
|
|
23
|
+
# @param exclude_paths [Array<String>] Path prefixes to skip
|
|
24
|
+
def initialize(app, store:, session_id_proc: nil, exclude_paths: [])
|
|
25
|
+
@app = app
|
|
26
|
+
@store = store
|
|
27
|
+
@session_id_proc = session_id_proc
|
|
28
|
+
@exclude_paths = exclude_paths
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param env [Hash] Rack environment
|
|
32
|
+
# @return [Array] Rack response triple [status, headers, body]
|
|
33
|
+
def call(env)
|
|
34
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
35
|
+
status, headers, body = @app.call(env)
|
|
36
|
+
duration_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start_time
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
record_request(env, status, duration_ms)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
# Fire-and-forget — recording failures never break the request
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
[status, headers, body]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Record the request metadata to the store.
|
|
50
|
+
#
|
|
51
|
+
# @param env [Hash] Rack environment
|
|
52
|
+
# @param status [Integer] HTTP response status
|
|
53
|
+
# @param duration_ms [Integer] Request duration in milliseconds
|
|
54
|
+
# rubocop:disable Metrics/MethodLength
|
|
55
|
+
def record_request(env, status, duration_ms)
|
|
56
|
+
path = env['PATH_INFO'] || ''
|
|
57
|
+
return if excluded?(path)
|
|
58
|
+
|
|
59
|
+
session_id = extract_session_id(env)
|
|
60
|
+
return unless session_id
|
|
61
|
+
|
|
62
|
+
path_params = env['action_dispatch.request.path_parameters'] || {}
|
|
63
|
+
controller = path_params[:controller]
|
|
64
|
+
action = path_params[:action]
|
|
65
|
+
return unless controller
|
|
66
|
+
|
|
67
|
+
# Classify controller name (e.g., "orders" -> "OrdersController")
|
|
68
|
+
controller_class = classify_controller(controller)
|
|
69
|
+
|
|
70
|
+
request_data = {
|
|
71
|
+
'session_id' => session_id,
|
|
72
|
+
'trace_tag' => env['HTTP_X_TRACE_SESSION'],
|
|
73
|
+
'timestamp' => Time.now.utc.iso8601,
|
|
74
|
+
'method' => env['REQUEST_METHOD'],
|
|
75
|
+
'path' => path,
|
|
76
|
+
'controller' => controller_class,
|
|
77
|
+
'action' => action.to_s,
|
|
78
|
+
'status' => status.to_i,
|
|
79
|
+
'duration_ms' => duration_ms.to_i,
|
|
80
|
+
'format' => extract_format(env)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@store.record(session_id, request_data)
|
|
84
|
+
end
|
|
85
|
+
# rubocop:enable Metrics/MethodLength
|
|
86
|
+
|
|
87
|
+
# Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
|
|
88
|
+
#
|
|
89
|
+
# @param env [Hash] Rack environment
|
|
90
|
+
# @return [String, nil] Session identifier
|
|
91
|
+
def extract_session_id(env)
|
|
92
|
+
return @session_id_proc.call(env) if @session_id_proc
|
|
93
|
+
|
|
94
|
+
# 1. X-Trace-Session header (explicit trace tag doubles as session ID)
|
|
95
|
+
trace_header = env['HTTP_X_TRACE_SESSION']
|
|
96
|
+
return trace_header if trace_header && !trace_header.empty?
|
|
97
|
+
|
|
98
|
+
# 2. Rack session ID
|
|
99
|
+
session = env['rack.session']
|
|
100
|
+
session_id = session&.id || session&.dig('session_id')
|
|
101
|
+
return session_id.to_s if session_id
|
|
102
|
+
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check if the path should be excluded from tracing.
|
|
107
|
+
#
|
|
108
|
+
# @param path [String] Request path
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def excluded?(path)
|
|
111
|
+
@exclude_paths.any? { |prefix| path.start_with?(prefix) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Classify a Rails controller path segment into a controller class name.
|
|
115
|
+
#
|
|
116
|
+
# @param controller [String] e.g., "orders" or "admin/orders"
|
|
117
|
+
# @return [String] e.g., "OrdersController" or "Admin::OrdersController"
|
|
118
|
+
def classify_controller(controller)
|
|
119
|
+
parts = controller.to_s
|
|
120
|
+
.split('/')
|
|
121
|
+
.map { |segment| segment.split('_').map(&:capitalize).join }
|
|
122
|
+
"#{parts.join('::')}Controller"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Extract response format from the Rack env.
|
|
126
|
+
#
|
|
127
|
+
# @param env [Hash] Rack environment
|
|
128
|
+
# @return [String] Format string (e.g., "html", "json")
|
|
129
|
+
def extract_format(env)
|
|
130
|
+
# Check action_dispatch format first
|
|
131
|
+
path_params = env['action_dispatch.request.path_parameters'] || {}
|
|
132
|
+
return path_params[:format].to_s if path_params[:format]
|
|
133
|
+
|
|
134
|
+
# Infer from content type or Accept header
|
|
135
|
+
accept = env['HTTP_ACCEPT'] || ''
|
|
136
|
+
return 'json' if accept.include?('application/json')
|
|
137
|
+
return 'html' if accept.include?('text/html')
|
|
138
|
+
|
|
139
|
+
'html'
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'store'
|
|
5
|
+
|
|
6
|
+
module CodebaseIndex
|
|
7
|
+
module SessionTracer
|
|
8
|
+
# Redis-backed session store using Lists.
|
|
9
|
+
#
|
|
10
|
+
# Each session is stored as a Redis List keyed `codebase_index:session:{id}`.
|
|
11
|
+
# RPUSH per request for append-only ordering. Native TTL for automatic cleanup.
|
|
12
|
+
#
|
|
13
|
+
# Requires the `redis` gem at runtime.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# store = RedisStore.new(redis: Redis.new, ttl: 3600)
|
|
17
|
+
# store.record("abc123", { controller: "OrdersController", action: "create" })
|
|
18
|
+
#
|
|
19
|
+
class RedisStore < Store
|
|
20
|
+
KEY_PREFIX = 'codebase_index:session:'
|
|
21
|
+
SESSIONS_KEY = 'codebase_index:sessions'
|
|
22
|
+
|
|
23
|
+
# @param redis [Redis] A Redis client instance
|
|
24
|
+
# @param ttl [Integer, nil] Time-to-live in seconds for session keys (nil = no expiry)
|
|
25
|
+
def initialize(redis:, ttl: nil)
|
|
26
|
+
super()
|
|
27
|
+
unless defined?(::Redis)
|
|
28
|
+
raise SessionTracerError, 'The redis gem is required for RedisStore. Add `gem "redis"` to your Gemfile.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@redis = redis
|
|
32
|
+
@ttl = ttl
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Append a request record to a session's Redis List.
|
|
36
|
+
#
|
|
37
|
+
# @param session_id [String] The session identifier
|
|
38
|
+
# @param request_data [Hash] Request metadata to store
|
|
39
|
+
# @return [void]
|
|
40
|
+
def record(session_id, request_data)
|
|
41
|
+
key = session_key(session_id)
|
|
42
|
+
@redis.rpush(key, JSON.generate(request_data))
|
|
43
|
+
@redis.expire(key, @ttl) if @ttl
|
|
44
|
+
@redis.sadd(SESSIONS_KEY, session_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Read all request records for a session.
|
|
48
|
+
#
|
|
49
|
+
# @param session_id [String] The session identifier
|
|
50
|
+
# @return [Array<Hash>] Request records, oldest first
|
|
51
|
+
def read(session_id)
|
|
52
|
+
key = session_key(session_id)
|
|
53
|
+
@redis.lrange(key, 0, -1).filter_map do |json|
|
|
54
|
+
JSON.parse(json)
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# List recent session summaries.
|
|
61
|
+
#
|
|
62
|
+
# @param limit [Integer] Maximum number of sessions to return
|
|
63
|
+
# @return [Array<Hash>] Session summaries
|
|
64
|
+
def sessions(limit: 20)
|
|
65
|
+
all_ids = @redis.smembers(SESSIONS_KEY)
|
|
66
|
+
|
|
67
|
+
# Filter to sessions that still have data (TTL may have expired)
|
|
68
|
+
active = all_ids.select { |id| @redis.exists?(session_key(id)) }
|
|
69
|
+
|
|
70
|
+
# Remove expired session IDs from the set
|
|
71
|
+
expired = all_ids - active
|
|
72
|
+
expired.each { |id| @redis.srem(SESSIONS_KEY, id) } if expired.any?
|
|
73
|
+
|
|
74
|
+
active.first(limit).map do |session_id|
|
|
75
|
+
requests = read(session_id)
|
|
76
|
+
{
|
|
77
|
+
'session_id' => session_id,
|
|
78
|
+
'request_count' => requests.size,
|
|
79
|
+
'first_request' => requests.first&.fetch('timestamp', nil),
|
|
80
|
+
'last_request' => requests.last&.fetch('timestamp', nil)
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remove all data for a single session.
|
|
86
|
+
#
|
|
87
|
+
# @param session_id [String] The session identifier
|
|
88
|
+
# @return [void]
|
|
89
|
+
def clear(session_id)
|
|
90
|
+
@redis.del(session_key(session_id))
|
|
91
|
+
@redis.srem(SESSIONS_KEY, session_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Remove all session data.
|
|
95
|
+
#
|
|
96
|
+
# @return [void]
|
|
97
|
+
def clear_all
|
|
98
|
+
all_ids = @redis.smembers(SESSIONS_KEY)
|
|
99
|
+
all_ids.each { |id| @redis.del(session_key(id)) }
|
|
100
|
+
@redis.del(SESSIONS_KEY)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# @param session_id [String]
|
|
106
|
+
# @return [String] Redis key for this session
|
|
107
|
+
def session_key(session_id)
|
|
108
|
+
"#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|