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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +176 -0
- data/Rakefile +11 -0
- data/db/log_migrate/20251222000001_create_solid_log_raw.rb +15 -0
- data/db/log_migrate/20251222000002_create_solid_log_entries.rb +29 -0
- data/db/log_migrate/20251222000004_create_solid_log_fields.rb +17 -0
- data/db/log_migrate/20251222000005_create_solid_log_tokens.rb +13 -0
- data/db/log_migrate/20251222000006_create_solid_log_facet_cache.rb +13 -0
- data/db/log_migrate/20251222000007_create_solid_log_fts_triggers.rb +41 -0
- data/db/log_structure_mysql.sql +96 -0
- data/db/log_structure_postgresql.sql +118 -0
- data/db/log_structure_sqlite.sql +123 -0
- data/lib/generators/solid_log/install/install_generator.rb +134 -0
- data/lib/generators/solid_log/install/templates/solid_log.rb.tt +133 -0
- data/lib/solid_log/adapters/adapter_factory.rb +34 -0
- data/lib/solid_log/adapters/base_adapter.rb +88 -0
- data/lib/solid_log/adapters/mysql_adapter.rb +163 -0
- data/lib/solid_log/adapters/postgresql_adapter.rb +141 -0
- data/lib/solid_log/adapters/sqlite_adapter.rb +149 -0
- data/lib/solid_log/core/client/buffer.rb +112 -0
- data/lib/solid_log/core/client/configuration.rb +31 -0
- data/lib/solid_log/core/client/http.rb +89 -0
- data/lib/solid_log/core/client/lograge_formatter.rb +99 -0
- data/lib/solid_log/core/client/retry_handler.rb +48 -0
- data/lib/solid_log/core/client.rb +138 -0
- data/lib/solid_log/core/configuration.rb +60 -0
- data/lib/solid_log/core/services/correlation_service.rb +74 -0
- data/lib/solid_log/core/services/field_analyzer.rb +108 -0
- data/lib/solid_log/core/services/health_service.rb +151 -0
- data/lib/solid_log/core/services/retention_service.rb +72 -0
- data/lib/solid_log/core/services/search_service.rb +269 -0
- data/lib/solid_log/core/version.rb +5 -0
- data/lib/solid_log/core.rb +106 -0
- data/lib/solid_log/direct_logger.rb +197 -0
- data/lib/solid_log/models/entry.rb +185 -0
- data/lib/solid_log/models/facet_cache.rb +58 -0
- data/lib/solid_log/models/field.rb +100 -0
- data/lib/solid_log/models/raw_entry.rb +33 -0
- data/lib/solid_log/models/record.rb +5 -0
- data/lib/solid_log/models/token.rb +61 -0
- data/lib/solid_log/parser.rb +179 -0
- data/lib/solid_log/silence_middleware.rb +34 -0
- data/lib/solid_log-core.rb +2 -0
- 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,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
|