solid_log-core 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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +176 -0
  4. data/Rakefile +11 -0
  5. data/db/log_migrate/20251222000001_create_solid_log_raw.rb +15 -0
  6. data/db/log_migrate/20251222000002_create_solid_log_entries.rb +29 -0
  7. data/db/log_migrate/20251222000004_create_solid_log_fields.rb +17 -0
  8. data/db/log_migrate/20251222000005_create_solid_log_tokens.rb +13 -0
  9. data/db/log_migrate/20251222000006_create_solid_log_facet_cache.rb +13 -0
  10. data/db/log_migrate/20251222000007_create_solid_log_fts_triggers.rb +41 -0
  11. data/db/log_structure_mysql.sql +96 -0
  12. data/db/log_structure_postgresql.sql +118 -0
  13. data/db/log_structure_sqlite.sql +123 -0
  14. data/lib/generators/solid_log/install/install_generator.rb +134 -0
  15. data/lib/generators/solid_log/install/templates/solid_log.rb.tt +133 -0
  16. data/lib/solid_log/adapters/adapter_factory.rb +34 -0
  17. data/lib/solid_log/adapters/base_adapter.rb +88 -0
  18. data/lib/solid_log/adapters/mysql_adapter.rb +163 -0
  19. data/lib/solid_log/adapters/postgresql_adapter.rb +141 -0
  20. data/lib/solid_log/adapters/sqlite_adapter.rb +149 -0
  21. data/lib/solid_log/core/client/buffer.rb +112 -0
  22. data/lib/solid_log/core/client/configuration.rb +31 -0
  23. data/lib/solid_log/core/client/http.rb +89 -0
  24. data/lib/solid_log/core/client/lograge_formatter.rb +99 -0
  25. data/lib/solid_log/core/client/retry_handler.rb +48 -0
  26. data/lib/solid_log/core/client.rb +138 -0
  27. data/lib/solid_log/core/configuration.rb +60 -0
  28. data/lib/solid_log/core/services/correlation_service.rb +74 -0
  29. data/lib/solid_log/core/services/field_analyzer.rb +108 -0
  30. data/lib/solid_log/core/services/health_service.rb +151 -0
  31. data/lib/solid_log/core/services/retention_service.rb +72 -0
  32. data/lib/solid_log/core/services/search_service.rb +269 -0
  33. data/lib/solid_log/core/version.rb +5 -0
  34. data/lib/solid_log/core.rb +106 -0
  35. data/lib/solid_log/direct_logger.rb +197 -0
  36. data/lib/solid_log/models/entry.rb +185 -0
  37. data/lib/solid_log/models/facet_cache.rb +58 -0
  38. data/lib/solid_log/models/field.rb +100 -0
  39. data/lib/solid_log/models/raw_entry.rb +33 -0
  40. data/lib/solid_log/models/record.rb +5 -0
  41. data/lib/solid_log/models/token.rb +61 -0
  42. data/lib/solid_log/parser.rb +179 -0
  43. data/lib/solid_log/silence_middleware.rb +34 -0
  44. data/lib/solid_log-core.rb +2 -0
  45. metadata +244 -0
@@ -0,0 +1,269 @@
1
+ module SolidLog
2
+ module Core
3
+ class SearchService
4
+ def initialize(params = {}, **options)
5
+ @params = params
6
+ @facet_cache_ttl = options[:facet_cache_ttl]
7
+ @cache_enabled = @facet_cache_ttl.present?
8
+ end
9
+
10
+ def search
11
+ scope = Entry.all
12
+
13
+ # Apply search query
14
+ scope = apply_search(scope)
15
+
16
+ # Apply filters
17
+ scope = apply_filters(scope)
18
+
19
+ # Apply pagination
20
+ scope = apply_pagination(scope)
21
+
22
+ # Return scoped results
23
+ scope.recent
24
+ end
25
+
26
+ def available_facets
27
+ return cached_facets if cache_enabled?
28
+
29
+ facets = {
30
+ levels: Entry.facets_for("level"),
31
+ apps: Entry.facets_for("app"),
32
+ envs: Entry.facets_for("env"),
33
+ controllers: Entry.facets_for("controller"),
34
+ actions: Entry.facets_for("action"),
35
+ paths: Entry.facets_for("path"),
36
+ methods: Entry.facets_for("method"),
37
+ status_codes: Entry.facets_for("status_code")
38
+ }
39
+
40
+ # Add promoted fields dynamically
41
+ facets.merge!(promoted_field_facets)
42
+
43
+ cache_facets(facets) if cache_enabled?
44
+ facets
45
+ end
46
+
47
+ private
48
+
49
+ def apply_search(scope)
50
+ return scope if @params[:query].blank?
51
+
52
+ # Full-text search starts from Entry.all (doesn't chain with existing scope)
53
+ # This is a limitation of FTS implementation
54
+ Entry.search_fts(@params[:query])
55
+ end
56
+
57
+ def apply_filters(scope)
58
+ scope = apply_level_filter(scope)
59
+ scope = apply_app_filter(scope)
60
+ scope = apply_env_filter(scope)
61
+ # scope = apply_controller_filter(scope)
62
+ scope = apply_action_filter(scope)
63
+ scope = apply_path_filter(scope)
64
+ scope = apply_method_filter(scope)
65
+ scope = apply_status_code_filter(scope)
66
+ scope = apply_duration_filter(scope)
67
+ scope = apply_time_range_filter(scope)
68
+ scope = apply_correlation_filters(scope)
69
+ scope = apply_promoted_field_filters(scope)
70
+ scope
71
+ end
72
+
73
+ def apply_level_filter(scope)
74
+ levels = Array(@params[:levels]).reject(&:blank?)
75
+ return scope if levels.empty?
76
+
77
+ scope.where(level: levels)
78
+ end
79
+
80
+ def apply_app_filter(scope)
81
+ return scope if @params[:app].blank?
82
+
83
+ scope.by_app(@params[:app])
84
+ end
85
+
86
+ def apply_env_filter(scope)
87
+ return scope if @params[:env].blank?
88
+
89
+ scope.by_env(@params[:env])
90
+ end
91
+
92
+ def apply_time_range_filter(scope)
93
+ start_time = parse_datetime(@params[:start_time])
94
+ end_time = parse_datetime(@params[:end_time])
95
+
96
+ scope.by_time_range(start_time, end_time)
97
+ end
98
+
99
+ def apply_correlation_filters(scope)
100
+ scope = scope.by_request_id(@params[:request_id]) if @params[:request_id].present?
101
+ scope = scope.by_job_id(@params[:job_id]) if @params[:job_id].present?
102
+ scope
103
+ end
104
+
105
+ def apply_controller_filter(scope)
106
+ return scope if @params[:controller].blank?
107
+ scope.by_controller(@params[:controller])
108
+ end
109
+
110
+ def apply_action_filter(scope)
111
+ return scope if @params[:action].blank?
112
+ scope.by_action(@params[:action])
113
+ end
114
+
115
+ def apply_path_filter(scope)
116
+ return scope if @params[:path].blank?
117
+ scope.by_path(@params[:path])
118
+ end
119
+
120
+ def apply_method_filter(scope)
121
+ return scope if @params[:method].blank?
122
+ scope.by_method(@params[:method])
123
+ end
124
+
125
+ def apply_status_code_filter(scope)
126
+ return scope if @params[:status_code].blank?
127
+ scope.by_status_code(@params[:status_code])
128
+ end
129
+
130
+ def apply_duration_filter(scope)
131
+ min_duration = @params[:min_duration].presence
132
+ max_duration = @params[:max_duration].presence
133
+ return scope if min_duration.blank? && max_duration.blank?
134
+
135
+ scope.by_duration_range(min_duration, max_duration)
136
+ end
137
+
138
+ def apply_promoted_field_filters(scope)
139
+ # Apply filters for any promoted fields that have columns
140
+ Field.promoted.each do |field|
141
+ next unless Entry.column_names.include?(field.name)
142
+
143
+ # Handle different filter types
144
+ case field.filter_type
145
+ when "multiselect"
146
+ values = Array(@params[field.name.to_sym]).reject(&:blank?)
147
+ next if values.empty?
148
+ scope = scope.where(field.name => values)
149
+ when "tokens"
150
+ values = parse_token_values(@params[field.name.to_sym])
151
+ next if values.empty?
152
+ scope = scope.where(field.name => values)
153
+ when "range"
154
+ min_value = @params["min_#{field.name}".to_sym].presence
155
+ max_value = @params["max_#{field.name}".to_sym].presence
156
+ next if min_value.blank? && max_value.blank?
157
+ scope = scope.where("#{field.name} >= ?", min_value) if min_value.present?
158
+ scope = scope.where("#{field.name} <= ?", max_value) if max_value.present?
159
+ when "exact", "contains"
160
+ param_value = @params[field.name.to_sym]
161
+ next if param_value.blank?
162
+ if field.filter_type == "contains"
163
+ scope = scope.where("#{field.name} LIKE ?", "%#{param_value}%")
164
+ else
165
+ scope = scope.where(field.name => param_value)
166
+ end
167
+ end
168
+ end
169
+ scope
170
+ end
171
+
172
+ def parse_datetime(datetime_str)
173
+ return nil if datetime_str.blank?
174
+
175
+ Time.zone.parse(datetime_str)
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+
180
+ def parse_token_values(input)
181
+ return [] if input.blank?
182
+
183
+ # Split by comma, semicolon, or newline and clean up
184
+ input.to_s.split(/[,;\n]/).map(&:strip).reject(&:blank?)
185
+ end
186
+
187
+ def limit
188
+ limit = @params[:limit].to_i
189
+ limit > 0 ? [limit, 1000].min : 200
190
+ end
191
+
192
+ def cache_enabled?
193
+ @cache_enabled
194
+ end
195
+
196
+ def promoted_field_facets
197
+ promoted_facets = {}
198
+
199
+ # Get all promoted fields that have actual database columns
200
+ Field.promoted.each do |field|
201
+ # Skip token fields - we don't fetch facets for high-cardinality fields
202
+ next if field.filter_type == "tokens"
203
+
204
+ # Check if the column exists on the entries table
205
+ if Entry.column_names.include?(field.name)
206
+ # Get distinct values for this field
207
+ values = Entry.facets_for(field.name)
208
+ promoted_facets[field.name.to_sym] = values if values.any?
209
+ end
210
+ end
211
+
212
+ promoted_facets
213
+ end
214
+
215
+ def cached_facets
216
+ FacetCache.fetch("facets:all", ttl: cache_ttl) do
217
+ facets = {
218
+ levels: Entry.facets_for("level"),
219
+ apps: Entry.facets_for("app"),
220
+ envs: Entry.facets_for("env"),
221
+ controllers: Entry.facets_for("controller"),
222
+ actions: Entry.facets_for("action"),
223
+ paths: Entry.facets_for("path"),
224
+ methods: Entry.facets_for("method"),
225
+ status_codes: Entry.facets_for("status_code")
226
+ }
227
+ facets.merge!(promoted_field_facets)
228
+ facets
229
+ end
230
+ end
231
+
232
+ def cache_facets(facets)
233
+ FacetCache.store("facets:all", facets, ttl: cache_ttl)
234
+ end
235
+
236
+ def cache_ttl
237
+ @facet_cache_ttl
238
+ end
239
+
240
+ def apply_pagination(scope)
241
+ # For infinite scroll: load logs before/after a specific ID
242
+ # - before_id: Load older logs (scroll up)
243
+ # - after_id: Load newer logs (scroll down / live tail)
244
+
245
+ if @params[:before_id].present?
246
+ # Loading older logs (scrolling up)
247
+ # Get logs with ID < before_id, ordered descending
248
+ # Partial will reverse to ASC for prepending
249
+ scope = scope.where("id < ?", @params[:before_id])
250
+ .order(id: :desc)
251
+ .limit(limit)
252
+ elsif @params[:after_id].present?
253
+ # Loading newer logs (scrolling down / live tail)
254
+ # Get logs with ID > after_id, ordered descending
255
+ # Partial will reverse to ASC for appending (oldest first, newest last)
256
+ scope = scope.where("id > ?", @params[:after_id])
257
+ .order(id: :desc)
258
+ .limit(limit)
259
+ else
260
+ # Initial load: get most recent logs
261
+ scope = scope.order(id: :desc)
262
+ .limit(limit)
263
+ end
264
+
265
+ scope
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,5 @@
1
+ module SolidLog
2
+ module Core
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,106 @@
1
+ require "solid_log/core/version"
2
+ require "solid_log/core/configuration"
3
+ require "solid_log/core/client"
4
+ require "solid_log/silence_middleware"
5
+
6
+ # Database adapters
7
+ require "solid_log/adapters/base_adapter"
8
+ require "solid_log/adapters/sqlite_adapter"
9
+ require "solid_log/adapters/postgresql_adapter"
10
+ require "solid_log/adapters/mysql_adapter"
11
+ require "solid_log/adapters/adapter_factory"
12
+
13
+ # Parser
14
+ require "solid_log/parser"
15
+
16
+ # Loggers
17
+ require "solid_log/direct_logger"
18
+
19
+ # Service objects
20
+ require "solid_log/core/services/retention_service"
21
+ require "solid_log/core/services/field_analyzer"
22
+ require "solid_log/core/services/search_service"
23
+ require "solid_log/core/services/correlation_service"
24
+ require "solid_log/core/services/health_service"
25
+
26
+ # Models (explicit requires - no engine, no app/ directory)
27
+ require "solid_log/models/record"
28
+ require "solid_log/models/raw_entry"
29
+ require "solid_log/models/entry"
30
+ require "solid_log/models/token"
31
+ require "solid_log/models/field"
32
+ require "solid_log/models/facet_cache"
33
+
34
+ module SolidLog
35
+ module Core
36
+ class << self
37
+ attr_writer :configuration
38
+
39
+ def configuration
40
+ @configuration ||= Configuration.new
41
+ end
42
+
43
+ def configure
44
+ yield(configuration)
45
+ end
46
+
47
+ def configure_client(&block)
48
+ Client.configure(&block)
49
+ end
50
+
51
+ def reset_configuration!
52
+ @configuration = Configuration.new
53
+ end
54
+
55
+ # Get database adapter
56
+ def adapter
57
+ SolidLog::Adapters::AdapterFactory.adapter
58
+ end
59
+
60
+ # Execute block without logging (prevent recursion)
61
+ def without_logging
62
+ Thread.current[:solid_log_silenced] = true
63
+ yield
64
+ ensure
65
+ Thread.current[:solid_log_silenced] = nil
66
+ end
67
+
68
+ # Check if logging is silenced
69
+ def silenced?
70
+ Thread.current[:solid_log_silenced] == true
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # Legacy SolidLog module for backward compatibility with models
77
+ module SolidLog
78
+ class << self
79
+ def configuration
80
+ Core.configuration
81
+ end
82
+
83
+ def configure(&block)
84
+ Core.configure(&block)
85
+ end
86
+
87
+ def adapter
88
+ Core.adapter
89
+ end
90
+
91
+ def without_logging(&block)
92
+ Core.without_logging(&block)
93
+ end
94
+
95
+ def silenced?
96
+ Core.silenced?
97
+ end
98
+ end
99
+
100
+ # Alias service classes for easier access
101
+ RetentionService = Core::RetentionService
102
+ FieldAnalyzer = Core::FieldAnalyzer
103
+ SearchService = Core::SearchService
104
+ CorrelationService = Core::CorrelationService
105
+ HealthService = Core::HealthService
106
+ end
@@ -0,0 +1,197 @@
1
+ module SolidLog
2
+ # DirectLogger writes logs directly to the database, bypassing HTTP overhead.
3
+ # This is optimized for the parent Rails application that has direct database access.
4
+ #
5
+ # Features:
6
+ # - Batches logs in memory for performance
7
+ # - Flushes on size threshold (default: 100 logs)
8
+ # - Flushes on time threshold (default: 5 seconds)
9
+ # - Thread-safe
10
+ # - Flushes remaining logs on process exit
11
+ #
12
+ # Usage:
13
+ # config.lograge.logger = ActiveSupport::Logger.new(SolidLog::DirectLogger.new)
14
+ class DirectLogger
15
+ attr_reader :buffer_size, :flush_interval, :last_flush_time
16
+
17
+ def initialize(batch_size: nil, flush_interval: 5, token_id: nil, eager_flush_levels: [:error, :fatal])
18
+ @buffer = []
19
+ @mutex = Mutex.new
20
+ @batch_size = batch_size || SolidLog.configuration&.max_batch_size || 100
21
+ @flush_interval = flush_interval # seconds
22
+ @last_flush_time = Time.current
23
+ @closed = false
24
+ @eager_flush_levels = Array(eager_flush_levels).map(&:to_s)
25
+
26
+ # Token ID priority: explicit param > ENV var > nil (for DirectLogger)
27
+ # token_id is only needed for audit trail (tracking which source ingested the log)
28
+ # For DirectLogger, nil is fine since we're logging internally
29
+ @token_id = token_id || token_id_from_env
30
+
31
+ # Start background flusher thread
32
+ start_flush_thread
33
+
34
+ # Ensure we flush on exit
35
+ at_exit { close }
36
+ end
37
+
38
+ # Write a log message (called by Rails logger)
39
+ # This is non-blocking - logs are buffered and flushed asynchronously
40
+ # EXCEPT for error/fatal logs which flush immediately to prevent data loss on crash
41
+ def write(message)
42
+ return if @closed
43
+
44
+ log_entry = parse_message(message)
45
+ return unless log_entry # Skip if parsing failed
46
+
47
+ # Check if this is a critical log that should flush immediately
48
+ should_eager_flush = false
49
+ if @eager_flush_levels.any?
50
+ parsed_data = JSON.parse(log_entry[:payload]) rescue {}
51
+ log_level = parsed_data["level"]&.to_s&.downcase
52
+ should_eager_flush = @eager_flush_levels.include?(log_level)
53
+ end
54
+
55
+ @mutex.synchronize do
56
+ @buffer << log_entry
57
+
58
+ # Flush immediately if:
59
+ # 1. Batch size reached, OR
60
+ # 2. This is a critical log (error/fatal) to prevent loss on crash
61
+ flush_internal if @buffer.size >= @batch_size || should_eager_flush
62
+ end
63
+ end
64
+
65
+ # Explicitly flush all buffered logs
66
+ # Useful for testing or before shutdown
67
+ def flush
68
+ @mutex.synchronize { flush_internal }
69
+ end
70
+
71
+ # Close the logger and flush remaining logs
72
+ def close
73
+ return if @closed
74
+ @closed = true
75
+
76
+ # Stop the flush thread
77
+ @flush_thread&.kill
78
+
79
+ # Flush remaining logs
80
+ flush
81
+ end
82
+
83
+ # Get current buffer size (for monitoring/debugging)
84
+ def buffer_size
85
+ @mutex.synchronize { @buffer.size }
86
+ end
87
+
88
+ private
89
+
90
+ # Parse a log message into the format expected by RawEntry
91
+ def parse_message(message)
92
+ # Handle different message formats
93
+ if message.is_a?(String)
94
+ # Try to parse as JSON (from Lograge)
95
+ begin
96
+ log_data = JSON.parse(message)
97
+ rescue JSON::ParserError
98
+ # Plain text log - wrap in JSON
99
+ log_data = {
100
+ message: message.strip,
101
+ timestamp: Time.current.utc.iso8601,
102
+ level: "info"
103
+ }
104
+ end
105
+ elsif message.is_a?(Hash)
106
+ log_data = message
107
+ else
108
+ # Unsupported format
109
+ return nil
110
+ end
111
+
112
+ # Return in RawEntry format
113
+ {
114
+ payload: log_data.to_json,
115
+ token_id: @token_id,
116
+ received_at: Time.current
117
+ }
118
+ rescue => e
119
+ # If parsing fails, log to stderr but don't crash
120
+ $stderr.puts "SolidLog::DirectLogger parse error: #{e.message}"
121
+ nil
122
+ end
123
+
124
+ # Internal flush (must be called within mutex.synchronize)
125
+ def flush_internal
126
+ return if @buffer.empty?
127
+
128
+ batch = @buffer.dup
129
+ @buffer.clear
130
+ @last_flush_time = Time.current
131
+
132
+ # Release mutex before database write
133
+ @mutex.unlock
134
+
135
+ begin
136
+ # Write batch to database
137
+ write_batch(batch)
138
+ ensure
139
+ # Re-acquire mutex
140
+ @mutex.lock
141
+ end
142
+ rescue => e
143
+ # On error, log to stderr
144
+ $stderr.puts "SolidLog::DirectLogger flush error: #{e.message}"
145
+ $stderr.puts e.backtrace.first(5).join("\n")
146
+ end
147
+
148
+ # Write a batch of logs to the database
149
+ def write_batch(batch)
150
+ return if batch.empty?
151
+
152
+ # Prevent recursive logging
153
+ SolidLog.without_logging do
154
+ # Ensure we have an ActiveRecord connection in this thread
155
+ ActiveRecord::Base.connection_pool.with_connection do
156
+ # Use insert_all for performance (single SQL statement)
157
+ RawEntry.insert_all(batch)
158
+ end
159
+ end
160
+ end
161
+
162
+ # Try to get token_id from environment variable
163
+ # This is useful for audit trail (tracking which source ingested logs)
164
+ def token_id_from_env
165
+ # Check for SOLIDLOG_TOKEN_ID env var
166
+ token_id = ENV["SOLIDLOG_TOKEN_ID"]
167
+ return nil unless token_id
168
+
169
+ # Validate it's a number
170
+ token_id.to_i if token_id.match?(/^\d+$/)
171
+ end
172
+
173
+ # Start background thread that flushes periodically
174
+ def start_flush_thread
175
+ @flush_thread = Thread.new do
176
+ loop do
177
+ sleep @flush_interval
178
+
179
+ # Check if we need to flush based on time
180
+ @mutex.synchronize do
181
+ time_since_flush = Time.current - @last_flush_time
182
+ flush_internal if time_since_flush >= @flush_interval && @buffer.any?
183
+ end
184
+ end
185
+ rescue => e
186
+ # Thread died - log but don't crash
187
+ $stderr.puts "SolidLog::DirectLogger flush thread error: #{e.message}"
188
+ end
189
+
190
+ # Make it a daemon thread so it doesn't prevent process exit
191
+ @flush_thread.abort_on_exception = false
192
+
193
+ # Set thread priority lower so it doesn't interfere with app
194
+ @flush_thread.priority = -1
195
+ end
196
+ end
197
+ end