solid_log-service 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4c94d0fde51404dd97f27e8d437a9c9edab5366ba360bbd2719b0a658a28c5fe
4
+ data.tar.gz: 1addb2c0e3b2471fdadccbd3741905747679b1e6475e3f6a12f6a5f3a44f5193
5
+ SHA512:
6
+ metadata.gz: 8e3ab660ee56208d357bc6bf5baf5e81e89137df79b9fe5849fd8d3567934529335a77c264ae6de75432d07c3b1e1611827d2aa4c61fbba4c7b98edeebaccc72
7
+ data.tar.gz: 7edb569df3716558642f22c9a55d680eb2f022ff6832a8364ead35824959b1354873dab3caa2b6039c5aa5430545c005ca480dbc1f26bafc5ad4c343c79bc7f6
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Dan Loman
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # SolidLog::Service
2
+
3
+ Standalone log ingestion and processing service with HTTP API and built-in job scheduler.
4
+
5
+ ## Overview
6
+
7
+ `solid_log-service` provides:
8
+
9
+ - **HTTP Ingestion API**: Accept logs via POST with bearer token auth
10
+ - **Query APIs**: REST endpoints for searching, filtering, and retrieving logs
11
+ - **Background Processing**: Parse raw logs, retention cleanup, field analysis
12
+ - **Built-in Scheduler**: No external dependencies (or use ActiveJob/cron)
13
+ - **Health Monitoring**: Metrics endpoint for observability
14
+
15
+ ## Installation
16
+
17
+ ```ruby
18
+ gem 'solid_log-service'
19
+
20
+ # Database adapter (choose one)
21
+ gem 'sqlite3', '>= 2.1' # For SQLite (recommended for most deployments)
22
+ # OR
23
+ gem 'pg', '>= 1.1' # For PostgreSQL
24
+ # OR
25
+ gem 'mysql2', '>= 0.5' # For MySQL
26
+ ```
27
+
28
+ ## Standalone Deployment
29
+
30
+ **1. Create configuration file:**
31
+
32
+ ```ruby
33
+ # config/solid_log_service.rb
34
+ SolidLog::Service.configure do |config|
35
+ config.database_url = ENV['DATABASE_URL'] || 'sqlite3:///data/production_log.sqlite'
36
+
37
+ # Job processing mode (default: :scheduler)
38
+ config.job_mode = :scheduler # or :active_job, :manual
39
+
40
+ # Scheduler intervals (only used when job_mode = :scheduler)
41
+ config.parser_interval = 10.seconds
42
+ config.cache_cleanup_interval = 1.hour
43
+ config.retention_hour = 2 # Run at 2 AM
44
+ config.field_analysis_hour = 3 # Run at 3 AM
45
+
46
+ # Retention policies
47
+ config.retention_days = 30
48
+ config.error_retention_days = 90
49
+ end
50
+ ```
51
+
52
+ **2. Run the service:**
53
+
54
+ ```bash
55
+ bundle exec solid_log_service
56
+ ```
57
+
58
+ Or with a Procfile:
59
+ ```
60
+ service: bundle exec solid_log_service
61
+ ```
62
+
63
+ ## Kamal Deployment
64
+
65
+ See main SolidLog documentation for Kamal deployment examples.
66
+
67
+ ## API Endpoints
68
+
69
+ ### Ingestion
70
+ - `POST /api/v1/ingest` - Ingest logs (single or batch)
71
+
72
+ ### Queries
73
+ - `GET /api/v1/entries` - List/filter entries
74
+ - `GET /api/v1/entries/:id` - Get single entry
75
+ - `GET /api/v1/search` - Full-text search
76
+ - `GET /api/v1/facets` - Get filter options
77
+ - `GET /api/v1/timelines/request/:id` - Request timeline
78
+ - `GET /api/v1/timelines/job/:id` - Job timeline
79
+ - `GET /api/v1/health` - Health metrics
80
+
81
+ ## Job Processing Modes
82
+
83
+ ### Built-in Scheduler (Default)
84
+ No external dependencies. Runs jobs in background threads.
85
+
86
+ ```ruby
87
+ config.job_mode = :scheduler
88
+ config.parser_interval = 10.seconds
89
+ ```
90
+
91
+ ### ActiveJob Integration
92
+ Leverages host app's job backend (Solid Queue, Sidekiq, etc.)
93
+
94
+ ```ruby
95
+ config.job_mode = :active_job
96
+ ```
97
+
98
+ ### Manual (Cron)
99
+ You manage scheduling via cron.
100
+
101
+ ```ruby
102
+ config.job_mode = :manual
103
+ ```
104
+
105
+ Then in crontab:
106
+ ```
107
+ */1 * * * * cd /app && rails solid_log:parse_logs
108
+ ```
109
+
110
+ ## License
111
+
112
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ t.verbose = true
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,80 @@
1
+ module SolidLog
2
+ module Api
3
+ class BaseController < ActionController::API
4
+ before_action :authenticate_token!
5
+
6
+ rescue_from ActionDispatch::Http::Parameters::ParseError do |exception|
7
+ render json: {
8
+ error: "Invalid JSON",
9
+ message: exception.message
10
+ }, status: :unprocessable_entity
11
+ end
12
+
13
+ rescue_from ActionController::BadRequest do |exception|
14
+ render json: {
15
+ error: "Invalid JSON",
16
+ message: exception.message
17
+ }, status: :unprocessable_entity
18
+ end
19
+
20
+ rescue_from StandardError do |exception|
21
+ # Check if it's a parameter parsing error based on message
22
+ if exception.message.include?("parsing request parameters")
23
+ render json: {
24
+ error: "Invalid JSON",
25
+ message: exception.message
26
+ }, status: :unprocessable_entity
27
+ else
28
+ Rails.logger.error "SolidLog API Error: #{exception.message}"
29
+ Rails.logger.error exception.backtrace.join("\n")
30
+
31
+ render json: {
32
+ error: "Internal server error",
33
+ message: exception.message
34
+ }, status: :internal_server_error
35
+ end
36
+ end
37
+
38
+ rescue_from ActiveRecord::RecordInvalid do |exception|
39
+ render json: {
40
+ error: "Validation error",
41
+ details: exception.record.errors.full_messages
42
+ }, status: :unprocessable_entity
43
+ end
44
+
45
+ private
46
+
47
+ def authenticate_token!
48
+ token_value = extract_bearer_token
49
+
50
+ unless token_value
51
+ render json: { error: "Missing or invalid Authorization header" }, status: :unauthorized
52
+ return
53
+ end
54
+
55
+ @current_token = SolidLog::Token.authenticate(token_value)
56
+
57
+ unless @current_token
58
+ render json: { error: "Invalid token" }, status: :unauthorized
59
+ return
60
+ end
61
+
62
+ # Touch last_used_at timestamp
63
+ @current_token.touch_last_used!
64
+ end
65
+
66
+ def current_token
67
+ @current_token
68
+ end
69
+
70
+ def extract_bearer_token
71
+ header = request.headers["Authorization"]
72
+ return nil unless header
73
+
74
+ # Expected format: "Bearer <token>"
75
+ matches = header.match(/^Bearer (.+)$/i)
76
+ matches[1] if matches
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,55 @@
1
+ module SolidLog
2
+ module Api
3
+ module V1
4
+ class EntriesController < Api::BaseController
5
+ # GET /api/v1/entries
6
+ def index
7
+ search_service = SolidLog::SearchService.new(filter_params)
8
+ entries = search_service.search
9
+
10
+ render json: {
11
+ entries: entries.as_json(methods: [:extra_fields_hash]),
12
+ total: entries.count,
13
+ limit: params[:limit]&.to_i || 100
14
+ }
15
+ end
16
+
17
+ # GET /api/v1/entries/:id
18
+ def show
19
+ entry = Entry.find(params[:id])
20
+
21
+ render json: {
22
+ entry: entry.as_json(methods: [:extra_fields_hash])
23
+ }
24
+ rescue ActiveRecord::RecordNotFound
25
+ render json: { error: "Entry not found" }, status: :not_found
26
+ end
27
+
28
+ private
29
+
30
+ def filter_params
31
+ # Extract filters from params[:filters] to avoid Rails routing params collision
32
+ search_params = {}
33
+ filters = params[:filters] || {}
34
+
35
+ search_params[:levels] = [filters[:level]].compact if filters[:level].present?
36
+ search_params[:app] = filters[:app] if filters[:app].present?
37
+ search_params[:env] = filters[:env] if filters[:env].present?
38
+ search_params[:controller] = filters[:controller] if filters[:controller].present?
39
+ search_params[:action] = filters[:action] if filters[:action].present?
40
+ search_params[:path] = filters[:path] if filters[:path].present?
41
+ search_params[:method] = filters[:method] if filters[:method].present?
42
+ search_params[:status_code] = filters[:status_code] if filters[:status_code].present?
43
+ search_params[:start_time] = filters[:start_time] if filters[:start_time].present?
44
+ search_params[:end_time] = filters[:end_time] if filters[:end_time].present?
45
+ search_params[:min_duration] = filters[:min_duration] if filters[:min_duration].present?
46
+ search_params[:max_duration] = filters[:max_duration] if filters[:max_duration].present?
47
+ search_params[:query] = params[:q] if params[:q].present?
48
+ search_params[:limit] = params[:limit] if params[:limit].present?
49
+
50
+ search_params
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,41 @@
1
+ module SolidLog
2
+ module Api
3
+ module V1
4
+ class FacetsController < Api::BaseController
5
+ # GET /api/v1/facets
6
+ def index
7
+ field = params[:field]
8
+
9
+ if field.blank?
10
+ return render json: { error: "Field parameter required" }, status: :bad_request
11
+ end
12
+
13
+ # Use Entry model directly for facets
14
+ limit = params[:limit]&.to_i || 100
15
+ facets = SolidLog::Entry.facets_for(field, limit: limit)
16
+
17
+ render json: {
18
+ field: field,
19
+ values: facets,
20
+ total: facets.size
21
+ }
22
+ end
23
+
24
+ # GET /api/v1/facets/all
25
+ def all
26
+ facets = {
27
+ level: SolidLog::Entry.facets_for("level"),
28
+ app: SolidLog::Entry.facets_for("app"),
29
+ env: SolidLog::Entry.facets_for("env"),
30
+ controller: SolidLog::Entry.facets_for("controller", limit: 50),
31
+ action: SolidLog::Entry.facets_for("action", limit: 50),
32
+ method: SolidLog::Entry.facets_for("method"),
33
+ status_code: SolidLog::Entry.facets_for("status_code")
34
+ }
35
+
36
+ render json: { facets: facets }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ module SolidLog
2
+ module Api
3
+ module V1
4
+ class HealthController < Api::BaseController
5
+ skip_before_action :authenticate_token!, only: [:show]
6
+
7
+ # GET /api/v1/health
8
+ def show
9
+ metrics = SolidLog::HealthService.metrics
10
+
11
+ status = case metrics[:parsing][:health_status]
12
+ when "critical"
13
+ :service_unavailable
14
+ when "warning", "degraded"
15
+ :ok # Still functional
16
+ else
17
+ :ok
18
+ end
19
+
20
+ render json: {
21
+ status: metrics[:parsing][:health_status],
22
+ timestamp: Time.current.iso8601,
23
+ metrics: metrics
24
+ }, status: status
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,80 @@
1
+ module SolidLog
2
+ module Api
3
+ module V1
4
+ class IngestController < Api::BaseController
5
+ # POST /api/v1/ingest
6
+ # Accepts single log entry (hash) or batch (array of hashes)
7
+ def create
8
+ payload = params[:_json] || parse_ndjson_body
9
+
10
+ if payload.blank?
11
+ render json: { error: "Empty payload" }, status: :bad_request
12
+ return
13
+ end
14
+
15
+ entries = Array.wrap(payload)
16
+
17
+ if entries.size > max_batch_size
18
+ render json: {
19
+ error: "Batch too large",
20
+ max_size: max_batch_size,
21
+ received: entries.size
22
+ }, status: :payload_too_large
23
+ return
24
+ end
25
+
26
+ # Create raw entries
27
+ raw_entries = entries.map do |entry|
28
+ {
29
+ token_id: current_token.id,
30
+ payload: entry.to_json,
31
+ received_at: Time.current,
32
+ parsed: false
33
+ }
34
+ end
35
+
36
+ # Bulk insert
37
+ SolidLog.without_logging do
38
+ SolidLog::RawEntry.insert_all(raw_entries)
39
+ end
40
+
41
+ render json: {
42
+ status: "accepted",
43
+ count: entries.size,
44
+ message: "Log entries queued for processing"
45
+ }, status: :accepted
46
+ rescue JSON::ParserError => e
47
+ render json: {
48
+ error: "Invalid JSON",
49
+ message: e.message
50
+ }, status: :bad_request
51
+ end
52
+
53
+ private
54
+
55
+ def max_batch_size
56
+ SolidLog.configuration.max_batch_size
57
+ end
58
+
59
+ # Parse NDJSON (newline-delimited JSON) from request body
60
+ def parse_ndjson_body
61
+ return [] unless request.body
62
+
63
+ body = request.body.read
64
+ return [] if body.blank?
65
+
66
+ # Check if it's NDJSON (multiple lines) or regular JSON
67
+ if body.include?("\n")
68
+ # NDJSON format
69
+ body.lines.map do |line|
70
+ JSON.parse(line.strip) unless line.strip.empty?
71
+ end.compact
72
+ else
73
+ # Regular JSON (single entry or array)
74
+ JSON.parse(body)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,31 @@
1
+ module SolidLog
2
+ module Api
3
+ module V1
4
+ class SearchController < Api::BaseController
5
+ # POST /api/v1/search
6
+ def create
7
+ query = params[:q] || params[:query]
8
+
9
+ if query.blank?
10
+ return render json: { error: "Query parameter required" }, status: :bad_request
11
+ end
12
+
13
+ search_params = {
14
+ query: query,
15
+ limit: params[:limit]
16
+ }.compact
17
+
18
+ search_service = SolidLog::SearchService.new(search_params)
19
+ entries = search_service.search
20
+
21
+ render json: {
22
+ query: query,
23
+ entries: entries.as_json(methods: [:extra_fields_hash]),
24
+ total: entries.count,
25
+ limit: params[:limit]&.to_i || 100
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ module SolidLog
2
+ module Api
3
+ module V1
4
+ class TimelinesController < Api::BaseController
5
+ # GET /api/v1/timelines/request/:request_id
6
+ def show_request
7
+ request_id = params[:request_id]
8
+
9
+ if request_id.blank?
10
+ return render json: { error: "Request ID required" }, status: :bad_request
11
+ end
12
+
13
+ entries = SolidLog::CorrelationService.request_timeline(request_id)
14
+ stats = SolidLog::CorrelationService.request_stats(request_id)
15
+
16
+ render json: {
17
+ request_id: request_id,
18
+ entries: entries.as_json(methods: [:extra_fields_hash]),
19
+ stats: stats
20
+ }
21
+ end
22
+
23
+ # GET /api/v1/timelines/job/:job_id
24
+ def show_job
25
+ job_id = params[:job_id]
26
+
27
+ if job_id.blank?
28
+ return render json: { error: "Job ID required" }, status: :bad_request
29
+ end
30
+
31
+ entries = SolidLog::CorrelationService.job_timeline(job_id)
32
+ stats = SolidLog::CorrelationService.job_stats(job_id)
33
+
34
+ render json: {
35
+ job_id: job_id,
36
+ entries: entries.as_json(methods: [:extra_fields_hash]),
37
+ stats: stats
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,4 @@
1
+ module SolidLog
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ module SolidLog
2
+ class CacheCleanupJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform
6
+ SolidLog.without_logging do
7
+ expired_count = FacetCache.expired.count
8
+
9
+ if expired_count > 0
10
+ FacetCache.cleanup_expired!
11
+ Rails.logger.info "SolidLog::CacheCleanupJob: Cleaned up #{expired_count} expired cache entries"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ module SolidLog
2
+ class FieldAnalysisJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(auto_promote: false)
6
+ SolidLog.without_logging do
7
+ recommendations = FieldAnalyzer.analyze
8
+
9
+ if recommendations.any?
10
+ Rails.logger.info "SolidLog::FieldAnalysisJob: Found #{recommendations.size} fields for potential promotion"
11
+
12
+ recommendations.take(10).each do |rec|
13
+ Rails.logger.info " - #{rec[:field].name} (#{rec[:field].usage_count} uses, priority: #{rec[:priority]})"
14
+ end
15
+
16
+ if auto_promote
17
+ promoted_count = FieldAnalyzer.auto_promote_candidates
18
+ Rails.logger.info "SolidLog::FieldAnalysisJob: Auto-promoted #{promoted_count} fields"
19
+ end
20
+ else
21
+ Rails.logger.info "SolidLog::FieldAnalysisJob: No fields meet promotion threshold"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end