lescopr 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: 291f6d817d72c6598f388e7088f36c742d278d58508b74443327bc81a108771e
4
+ data.tar.gz: 31c73668b1e5b2c57461d28fdd6eb085fdc55227de6973597ccd0656bfa45f91
5
+ SHA512:
6
+ metadata.gz: a5783011f3c58e03a82c22141ec444f07cfbc4e0e3a93afbe533cbd465c4d235a0c2d8d70679eae06dbf8c392b0761ec8a7385733505465d052b150f61d5f32d
7
+ data.tar.gz: 8c9ee746851f418d710bc4e374d2c5bb03161e88ee58938afcf39b4ebe55f711f7c34135cc8345cd046c56fd22a48664b50886bbc3f76daec63e70957674252c
data/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to `lescopr` (Ruby gem) are documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ---
9
+
10
+ ## [Unreleased]
11
+
12
+ ---
13
+
14
+ ## [0.1.0] — 2026-03-08
15
+
16
+ ---
17
+
18
+ ## [0.1.0] — 2026-03-07
19
+
20
+ ### Added
21
+ - **Core SDK** (`Lescopr::Core::Client`) — central object managing config, log queue and daemon lifecycle
22
+ - **Background daemon** (`DaemonRunner`) — Ruby thread flushing logs to `api.lescopr.com` every 5 s, heartbeat every 30 s
23
+ - **HTTP transport** (`Transport::HttpClient`) — HTTPS batch delivery via `net/http`, 3 retries, zero external gems
24
+ - **Framework auto-detection** (`Filesystem::ProjectAnalyzer`) — detects Rails, Sinatra, Hanami, Grape, Padrino and plain Rack from `Gemfile`
25
+ - **Config manager** (`Filesystem::ConfigManager`) — thread-safe read/write of `.lescopr.json`
26
+ - **Rails integration** (`Integrations::Rails::Railtie`) — auto-registers on `require "lescopr"`; hooks `process_action.action_controller` notifications
27
+ - **Rack middleware** (`Integrations::Rack::Middleware`) — captures exceptions from any Rack-compatible app
28
+ - **Sinatra extension** (`Integrations::Sinatra::Extension`) — `register`-based integration with `lescopr_log` helper
29
+ - **CLI** (`exe/lescopr`) — `init`, `status`, `diagnose`, `reset` commands via stdlib `optparse`
30
+ - **Internal logger** (`Monitoring::Logger`) — writes to `.lescopr.log` with rotation, never pollutes app output
31
+ - **Test suite** — RSpec unit tests for LogQueue, ConfigManager, ProjectAnalyzer, Rack middleware
32
+ - **Ruby 2.7+ compatibility** — no Ruby 3.x-only syntax; tested on 2.7, 3.0, 3.1, 3.2, 3.3
33
+ - **Zero external runtime dependencies** — only `json` (stdlib-compatible)
34
+
35
+ ### Compatibility matrix
36
+
37
+ | Framework | Version | Ruby |
38
+ |-------------|---------------|---------|
39
+ | Rails | 6, 7, 8 | 2.7–3.3 |
40
+ | Sinatra | 2, 3, 4 | 2.7–3.3 |
41
+ | Hanami | 1, 2 | 2.7–3.3 |
42
+ | Grape | 1.x, 2.x | 2.7–3.3 |
43
+ | Plain Rack | 2, 3 | 2.7–3.3 |
44
+ | Plain Ruby | — | 2.7–3.3 |
45
+
46
+ ---
47
+
48
+ [Unreleased]: https://github.com/Lescopr/lescopr-ruby/compare/v0.1.0...HEAD
49
+ [0.1.0]: https://github.com/Lescopr/lescopr-ruby/releases/tag/v0.1.0
50
+ [0.1.0]: https://github.com/Lescopr/lescopr-ruby/releases/tag/v0.1.0
51
+
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 SonnaLab (https://sonnalab.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # Lescopr Ruby SDK
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/lescopr?cacheSeconds=300)](https://rubygems.org/gems/lescopr)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/lescopr?cacheSeconds=300)](https://rubygems.org/gems/lescopr)
5
+ [![Ruby versions](https://img.shields.io/badge/ruby-2.7%20%7C%203.0%20%7C%203.1%20%7C%203.2%20%7C%203.3-ruby?cacheSeconds=300)](https://rubygems.org/gems/lescopr)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
8
+ **Lescopr** is a zero-configuration Ruby monitoring SDK that automatically captures logs, errors, and exceptions from any Ruby project and streams them in real-time to the [Lescopr dashboard](https://app.lescopr.com).
9
+
10
+ Works out of the box with **Rails**, **Sinatra**, **Rack**, **Hanami**, **Grape**, and **plain Ruby**.
11
+
12
+ ---
13
+
14
+ ## Table of Contents
15
+
16
+ - [Features](#features)
17
+ - [Requirements](#requirements)
18
+ - [Installation](#installation)
19
+ - [Quick Start](#quick-start)
20
+ - [Framework Integration](#framework-integration)
21
+ - [Rails](#rails)
22
+ - [Sinatra](#sinatra)
23
+ - [Rack](#rack)
24
+ - [Plain Ruby](#plain-ruby)
25
+ - [Architecture](#architecture)
26
+ - [CLI Reference](#cli-reference)
27
+ - [Advanced Configuration](#advanced-configuration)
28
+ - [RubyGems](#rubygems)
29
+ - [License](#license)
30
+
31
+ ---
32
+
33
+ ## Features
34
+
35
+ - ✅ **Automatic error capture** — hooks into Rails `process_action`, Rack middleware, and `at_exit`
36
+ - ✅ **Framework auto-detection** — detects Rails, Sinatra, Hanami, Grape, Padrino and plain Rack from `Gemfile`
37
+ - ✅ **Background daemon** — Ruby thread, completely non-blocking, flushes every 5 seconds
38
+ - ✅ **HTTP batch transport** — logs are batched and sent via `net/http` (no external gem required)
39
+ - ✅ **Zero runtime dependencies** — only `json` (already in stdlib since Ruby 2.7)
40
+ - ✅ **Works everywhere** — Rails, Sinatra, Rack, scripts, plain Ruby
41
+
42
+ ---
43
+
44
+ ## Requirements
45
+
46
+ | Requirement | Version |
47
+ |---|---|
48
+ | Ruby | ≥ 2.7 |
49
+ | Bundler | ≥ 2.0 |
50
+ | `json` | bundled with Ruby |
51
+
52
+ > **Note:** No external gem required at runtime. `net/http` and `openssl` are part of Ruby's stdlib.
53
+
54
+ ---
55
+
56
+ ## Installation
57
+
58
+ Add to your `Gemfile`:
59
+
60
+ ```ruby
61
+ gem "lescopr"
62
+ ```
63
+
64
+ Then run:
65
+
66
+ ```bash
67
+ bundle install
68
+ ```
69
+
70
+ Or install directly:
71
+
72
+ ```bash
73
+ gem install lescopr
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Quick Start
79
+
80
+ **Step 1 — Initialise the SDK in your project directory:**
81
+
82
+ ```bash
83
+ bundle exec lescopr init --sdk-key YOUR_SDK_KEY
84
+ ```
85
+
86
+ This detects your framework, registers the project with the Lescopr API, and writes `.lescopr.json`.
87
+
88
+ **Step 2 — Integrate into your application** (see [Framework Integration](#framework-integration) below).
89
+
90
+ **That's it.** All logs and exceptions are forwarded to the Lescopr dashboard automatically.
91
+
92
+ ---
93
+
94
+ ## Framework Integration
95
+
96
+ ### Rails
97
+
98
+ The SDK registers automatically via the `Railtie`. Simply add the gem and create an initializer:
99
+
100
+ **`config/initializers/lescopr.rb`:**
101
+
102
+ ```ruby
103
+ Lescopr.configure do |c|
104
+ c.sdk_key = ENV["LESCOPR_SDK_KEY"]
105
+ c.api_key = ENV["LESCOPR_API_KEY"]
106
+ c.environment = Rails.env
107
+ end
108
+ ```
109
+
110
+ **Environment variables (`.env` / credentials):**
111
+
112
+ ```dotenv
113
+ LESCOPR_SDK_KEY=lsk_xxxx
114
+ LESCOPR_API_KEY=lak_xxxx
115
+ LESCOPR_ENVIRONMENT=production
116
+ ```
117
+
118
+ All controller exceptions are captured automatically via `process_action.action_controller` notification.
119
+
120
+ ---
121
+
122
+ ### Sinatra
123
+
124
+ ```ruby
125
+ require "sinatra"
126
+ require "lescopr"
127
+
128
+ Lescopr.init!(sdk_key: ENV["LESCOPR_SDK_KEY"], api_key: ENV["LESCOPR_API_KEY"])
129
+
130
+ class MyApp < Sinatra::Base
131
+ register Lescopr::Integrations::Sinatra::Extension
132
+
133
+ get "/" do
134
+ lescopr_log(:info, "Home page visited")
135
+ "Hello!"
136
+ end
137
+ end
138
+ ```
139
+
140
+ ---
141
+
142
+ ### Rack
143
+
144
+ ```ruby
145
+ # config.ru
146
+ require "lescopr"
147
+
148
+ Lescopr.init!(sdk_key: ENV["LESCOPR_SDK_KEY"], api_key: ENV["LESCOPR_API_KEY"])
149
+
150
+ use Lescopr::Integrations::Rack::Middleware
151
+
152
+ run MyApp
153
+ ```
154
+
155
+ ---
156
+
157
+ ### Plain Ruby
158
+
159
+ ```ruby
160
+ require "lescopr"
161
+
162
+ Lescopr.init!(
163
+ sdk_key: "lsk_xxxx",
164
+ api_key: "lak_xxxx",
165
+ environment: "production"
166
+ )
167
+
168
+ # Manual logging
169
+ Lescopr.log(:info, "Job started", { job: "EmailWorker" })
170
+
171
+ # Exceptions are captured automatically at_exit
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Architecture
177
+
178
+ ```
179
+ Your Ruby Application
180
+
181
+ │ Rails.logger / raise / Lescopr.log
182
+
183
+ ┌─────────────────────────────────┐
184
+ │ Railtie / Rack Middleware │ (framework hooks)
185
+ │ OR at_exit handler │ (plain Ruby)
186
+ └──────────────┬──────────────────┘
187
+ │ push to LogQueue
188
+
189
+ ┌─────────────────────────────────┐
190
+ │ DaemonRunner (Thread) │ flushes every 5 s
191
+ │ Heartbeat every 30 s │
192
+ └──────────────┬──────────────────┘
193
+ │ HTTPS batch (net/http)
194
+
195
+ https://api.lescopr.com
196
+
197
+
198
+ Lescopr Dashboard
199
+ https://app.lescopr.com
200
+ ```
201
+
202
+ **Key components:**
203
+
204
+ | Component | Path | Role |
205
+ |---|---|---|
206
+ | `Lescopr` (module) | `lib/lescopr.rb` | Entry point, `configure`, `init!`, `log` |
207
+ | `Core::Client` | `lib/lescopr/core/client.rb` | Central client — config, queue, daemon lifecycle |
208
+ | `Core::DaemonRunner` | `lib/lescopr/core/daemon_runner.rb` | Background thread — flush + heartbeat |
209
+ | `Core::LogQueue` | `lib/lescopr/core/log_queue.rb` | Thread-safe in-memory queue |
210
+ | `Transport::HttpClient` | `lib/lescopr/transport/http_client.rb` | HTTPS delivery via `net/http` |
211
+ | `Filesystem::ProjectAnalyzer` | `lib/lescopr/filesystem/project_analyzer.rb` | Framework detection from `Gemfile` |
212
+ | `Filesystem::ConfigManager` | `lib/lescopr/filesystem/config_manager.rb` | Thread-safe `.lescopr.json` r/w |
213
+ | `Integrations::Rails::Railtie` | `lib/lescopr/integrations/rails/railtie.rb` | Auto-registers in Rails |
214
+ | `Integrations::Rack::Middleware` | `lib/lescopr/integrations/rack/middleware.rb` | Exception capture for Rack apps |
215
+ | `Integrations::Sinatra::Extension` | `lib/lescopr/integrations/sinatra/extension.rb` | `register` for Sinatra |
216
+ | CLI | `exe/lescopr` | `init`, `status`, `diagnose`, `reset` |
217
+
218
+ ---
219
+
220
+ ## CLI Reference
221
+
222
+ ```bash
223
+ bundle exec lescopr [COMMAND] [OPTIONS]
224
+ ```
225
+
226
+ | Command | Description |
227
+ |---|---|
228
+ | `init --sdk-key KEY` | Initialise the SDK in the current project |
229
+ | `status` | Show SDK configuration and daemon status |
230
+ | `diagnose` | Run a full diagnostic (Ruby env, config, API connectivity) |
231
+ | `reset` | Remove `.lescopr.json`, `.lescopr.pid`, `.lescopr.log` |
232
+
233
+ ---
234
+
235
+ ## Advanced Configuration
236
+
237
+ `.lescopr.json` is generated automatically by `lescopr init`:
238
+
239
+ ```json
240
+ {
241
+ "sdk_id": "proj_xxxx",
242
+ "sdk_key": "lsk_xxxx",
243
+ "api_key": "lak_xxxx",
244
+ "environment": "production",
245
+ "project_name": "my-app",
246
+ "project_stack": ["rails"]
247
+ }
248
+ ```
249
+
250
+ > **Security:** Add `.lescopr.json` to your `.gitignore`.
251
+
252
+ ### Environment variables
253
+
254
+ | Variable | Description |
255
+ |---|---|
256
+ | `LESCOPR_SDK_KEY` | SDK key (overrides `.lescopr.json`) |
257
+ | `LESCOPR_API_KEY` | API key (overrides `.lescopr.json`) |
258
+ | `LESCOPR_ENVIRONMENT` | `development` or `production` |
259
+ | `LESCOPR_DEBUG=true` | Enables verbose output to `.lescopr.log` |
260
+
261
+ ---
262
+
263
+ ## RubyGems
264
+
265
+ The gem is available on RubyGems: **[lescopr](https://rubygems.org/gems/lescopr)**
266
+
267
+ To publish a new release:
268
+
269
+ ```bash
270
+ ./scripts/release.sh 0.2.0
271
+ ```
272
+
273
+ ---
274
+
275
+ ## Support
276
+
277
+ | Channel | Link |
278
+ |---|---|
279
+ | 📖 Documentation | <https://docs.lescopr.com> |
280
+ | 🌐 Dashboard | <https://app.lescopr.com> |
281
+ | 📧 Email | <support@lescopr.com> |
282
+ | 🐛 Bug reports | <https://github.com/Lescopr/lescopr-ruby/issues> |
283
+
284
+ ---
285
+
286
+ ## License
287
+
288
+ [MIT](LICENSE) © 2024-present [SonnaLab](https://sonnalab.com). All rights reserved.
289
+
data/exe/lescopr ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ require_relative "../lib/lescopr"
7
+
8
+ COMMANDS = %w[init start stop status diagnose reset].freeze
9
+
10
+ def usage
11
+ puts <<~USAGE
12
+ Usage: lescopr <command> [options]
13
+
14
+ Commands:
15
+ init Initialise the SDK in the current project
16
+ start Start the monitoring daemon
17
+ stop Stop the monitoring daemon
18
+ status Show daemon and SDK status
19
+ diagnose Run a full diagnostic
20
+ reset Remove SDK configuration and stop daemon
21
+
22
+ Options:
23
+ --sdk-key KEY SDK key (lsk_xxx)
24
+ --api-key KEY API key (lak_xxx)
25
+ --environment ENV development | production (default: development)
26
+ --help Show this message
27
+ USAGE
28
+ end
29
+
30
+ options = {}
31
+ parser = OptionParser.new do |opts|
32
+ opts.on("--sdk-key KEY") { |v| options[:sdk_key] = v }
33
+ opts.on("--api-key KEY") { |v| options[:api_key] = v }
34
+ opts.on("--environment ENV") { |v| options[:environment] = v }
35
+ opts.on("-h", "--help") { usage; exit 0 }
36
+ end
37
+
38
+ parser.parse!(ARGV)
39
+ command = ARGV.shift
40
+
41
+ if command.nil? || !COMMANDS.include?(command)
42
+ usage
43
+ exit 1
44
+ end
45
+
46
+ config_path = File.join(Dir.pwd, ".lescopr.json")
47
+ pid_path = File.join(Dir.pwd, ".lescopr.pid")
48
+
49
+ case command
50
+ when "init"
51
+ unless options[:sdk_key]
52
+ puts "❌ --sdk-key is required"
53
+ exit 1
54
+ end
55
+
56
+ puts "⏳ Initialising Lescopr SDK..."
57
+
58
+ Lescopr.configure do |c|
59
+ c.sdk_key = options[:sdk_key]
60
+ c.api_key = options[:api_key]
61
+ c.environment = options[:environment] || "development"
62
+ end
63
+
64
+ if Lescopr.client&.ready?
65
+ cfg = JSON.parse(File.read(config_path), symbolize_names: true) rescue {}
66
+ puts "✅ SDK initialised"
67
+ puts " SDK ID : #{cfg[:sdk_id]}"
68
+ puts " Project : #{cfg[:project_name]}"
69
+ puts " Stack : #{Array(cfg[:project_stack]).join(', ')}"
70
+ puts " Config : #{config_path}"
71
+ else
72
+ puts "❌ Initialisation failed — check your keys and connectivity"
73
+ exit 1
74
+ end
75
+
76
+ when "status"
77
+ if File.exist?(config_path)
78
+ cfg = JSON.parse(File.read(config_path), symbolize_names: true) rescue {}
79
+ puts "✅ SDK configured"
80
+ puts " SDK ID : #{cfg[:sdk_id]}"
81
+ puts " Project : #{cfg[:project_name]}"
82
+ puts " Env : #{cfg[:environment]}"
83
+ pid = File.exist?(pid_path) ? File.read(pid_path).strip : nil
84
+ puts " Daemon : #{pid ? "running (PID #{pid})" : "stopped"}"
85
+ else
86
+ puts "⚠️ SDK not initialised. Run: lescopr init --sdk-key YOUR_KEY"
87
+ end
88
+
89
+ when "diagnose"
90
+ puts "🔍 Lescopr Diagnostic"
91
+ puts " Ruby version : #{RUBY_VERSION}"
92
+ puts " Platform : #{RUBY_PLATFORM}"
93
+ puts " Config present : #{File.exist?(config_path)}"
94
+ puts " PID file : #{File.exist?(pid_path) ? File.read(pid_path).strip : 'none'}"
95
+
96
+ begin
97
+ require "net/http"
98
+ uri = URI("https://api.lescopr.com/health")
99
+ res = Net::HTTP.get_response(uri)
100
+ puts " API reachable : #{res.is_a?(Net::HTTPSuccess) ? '✅' : "❌ (#{res.code})"}"
101
+ rescue StandardError => e
102
+ puts " API reachable : ❌ (#{e.message})"
103
+ end
104
+
105
+ when "reset"
106
+ [config_path, pid_path, File.join(Dir.pwd, ".lescopr.log")].each do |f|
107
+ if File.exist?(f)
108
+ File.delete(f)
109
+ puts "🗑 Deleted #{f}"
110
+ end
111
+ end
112
+ puts "✅ SDK reset"
113
+
114
+ else
115
+ puts "ℹ️ Command '#{command}' is not yet implemented via CLI. Use the Ruby API directly."
116
+ end
117
+
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lescopr
4
+ module Core
5
+ # Central SDK client — manages configuration, log queue, daemon lifecycle
6
+ # and the Ruby logger hook.
7
+ class Client
8
+ attr_reader :configuration, :sdk_id, :log_queue, :http_client, :sdk_logger
9
+
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ @sdk_id = nil
13
+ @ready = false
14
+ @mutex = Mutex.new
15
+
16
+ @sdk_logger = Monitoring::Logger.new(debug: configuration.debug)
17
+ @log_queue = LogQueue.new
18
+ @http_client = Transport::HttpClient.new(
19
+ api_key: configuration.api_key,
20
+ sdk_key: configuration.sdk_key
21
+ )
22
+ @daemon = DaemonRunner.new(self)
23
+ @config_mgr = Filesystem::ConfigManager.new
24
+
25
+ load_config
26
+ end
27
+
28
+ # @return [Boolean] true if the SDK is fully initialised and ready
29
+ def ready? = @ready
30
+
31
+ # Bootstrap: analyse project, register with API, start daemon.
32
+ # @return [Boolean]
33
+ def setup!
34
+ unless configuration.api_key && configuration.sdk_key
35
+ sdk_logger.warn("sdk_key and api_key are required. SDK inactive.")
36
+ return false
37
+ end
38
+
39
+ analyzer = Filesystem::ProjectAnalyzer.new
40
+ payload = analyzer.analyze
41
+
42
+ response = http_client.verify_project(payload)
43
+
44
+ unless response && response[:sdk_id]
45
+ sdk_logger.warn("API verification failed — check sdk_key / api_key")
46
+ return false
47
+ end
48
+
49
+ @sdk_id = response[:sdk_id]
50
+ config_data = response.merge(
51
+ sdk_key: configuration.sdk_key,
52
+ api_key: configuration.api_key,
53
+ environment: configuration.environment,
54
+ project_name: payload[:project_name]
55
+ )
56
+ @config_mgr.save(config_data)
57
+
58
+ @daemon.start
59
+ @ready = true
60
+
61
+ sdk_logger.info("SDK initialised — project: #{payload[:project_name]}, sdk_id: #{@sdk_id}")
62
+ true
63
+ rescue StandardError => e
64
+ sdk_logger.error("setup! failed: #{e.message}")
65
+ false
66
+ end
67
+
68
+ # Hook into Ruby's stdlib Logger so existing `Rails.logger.info` etc.
69
+ # calls are automatically forwarded to Lescopr.
70
+ def setup_auto_logging!
71
+ setup!
72
+ install_global_exception_handler!
73
+ install_at_exit_hook!
74
+ end
75
+
76
+ # Queue a log entry for async delivery.
77
+ def send_log(level, message, metadata = {})
78
+ return unless @ready
79
+
80
+ entry = {
81
+ timestamp: Time.now.utc.iso8601(3),
82
+ level: level.to_s.upcase,
83
+ message: message.to_s,
84
+ sdk_id: @sdk_id,
85
+ environment: configuration.environment,
86
+ metadata: metadata
87
+ }
88
+ log_queue.push(entry)
89
+ end
90
+
91
+ # Graceful shutdown — flush queue then stop daemon.
92
+ def shutdown!
93
+ @daemon.stop
94
+ @ready = false
95
+ sdk_logger.info("SDK shut down")
96
+ end
97
+
98
+ private
99
+
100
+ def load_config
101
+ config = @config_mgr.load
102
+ return unless config
103
+
104
+ @sdk_id = config[:sdk_id]
105
+ configuration.api_key ||= config[:api_key]
106
+ configuration.sdk_key ||= config[:sdk_key]
107
+ configuration.environment = config[:environment] || configuration.environment
108
+
109
+ sdk_logger.info("Config loaded from .lescopr.json — project: #{config[:project_name]}")
110
+ end
111
+
112
+ def install_global_exception_handler!
113
+ original = Thread.current[:__lescopr_exc_handler__]
114
+ Thread.report_on_exception = true
115
+
116
+ at_exit do
117
+ exc = $ERROR_INFO
118
+ next unless exc && !exc.is_a?(SystemExit)
119
+
120
+ send_log("FATAL", "#{exc.class}: #{exc.message}", {
121
+ backtrace: exc.backtrace&.first(10)
122
+ })
123
+ @daemon.stop
124
+ end
125
+ end
126
+
127
+ def install_at_exit_hook!
128
+ at_exit { @daemon.stop }
129
+ end
130
+ end
131
+ end
132
+ end
133
+
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lescopr
4
+ module Core
5
+ # Background thread that flushes the log queue every N seconds
6
+ # and sends heartbeats every 30 s.
7
+ class DaemonRunner
8
+ FLUSH_INTERVAL = 5 # seconds
9
+ HEARTBEAT_INTERVAL = 30 # seconds
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ @running = false
14
+ @thread = nil
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def start
19
+ @mutex.synchronize do
20
+ return if @running
21
+
22
+ @running = true
23
+ @thread = Thread.new { run_loop }
24
+ @thread.name = "lescopr-daemon"
25
+ @thread.abort_on_exception = false
26
+ end
27
+ end
28
+
29
+ def stop
30
+ @mutex.synchronize do
31
+ @running = false
32
+ end
33
+ @thread&.join(3)
34
+ end
35
+
36
+ def running? = @running
37
+
38
+ private
39
+
40
+ def run_loop
41
+ last_heartbeat = Time.now
42
+
43
+ while @running
44
+ sleep(FLUSH_INTERVAL)
45
+
46
+ flush_logs
47
+ send_heartbeat if (Time.now - last_heartbeat) >= HEARTBEAT_INTERVAL
48
+ last_heartbeat = Time.now if (Time.now - last_heartbeat) >= HEARTBEAT_INTERVAL
49
+ end
50
+
51
+ flush_logs # final flush on shutdown
52
+ rescue StandardError => e
53
+ @client.sdk_logger.error("DaemonRunner crashed: #{e.message}")
54
+ end
55
+
56
+ def flush_logs
57
+ return if @client.log_queue.empty?
58
+
59
+ batch = @client.log_queue.drain(@client.configuration.batch_size)
60
+ @client.http_client.send_logs(batch)
61
+ rescue StandardError => e
62
+ @client.sdk_logger.error("flush_logs error: #{e.message}")
63
+ end
64
+
65
+ def send_heartbeat
66
+ return unless @client.sdk_id
67
+
68
+ @client.http_client.send_heartbeat(@client.sdk_id)
69
+ rescue StandardError
70
+ # silent — heartbeat is non-critical
71
+ end
72
+ end
73
+ end
74
+ end
75
+