log_shout 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: cc4cc267c8eeb16ef1f1615f57a50189549a896c2d583eb74ab0540857812146
4
+ data.tar.gz: 487bb3371dbf862cb701e1e5278959d722765f424c3f70832cffdef30eb577dc
5
+ SHA512:
6
+ metadata.gz: e85575c4a8cb47cabd352d1104326f07fb3c15f7ec486ae64de5b61cb75182235a3889a671b598a510aa5ed9f4c7a44df5cd062a743e86c0b5cefdbc46b71ae8
7
+ data.tar.gz: b45d29c88b91d0bacae4f55ce5864dd630847a544e680b6319bbf6b37c4a5c71c06a3ab412f3c4baefe61cf80c4e60486baad3455fd72319769158accb64abca
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ Initial release candidate:
6
+
7
+ - Fan out Ruby Logger messages to stdout, stderr, files, and memory through Ractors.
8
+ - Configure named loggers and shared destinations with YAML.
9
+ - Provide class and instance logger helpers and background job logging.
10
+ - Require Ruby 4.0 or newer for Ractor::Port and Ractor#value.
11
+ - Drain and close destinations during reconfiguration, reset, and shutdown.
12
+ - Surface destination failures to callers of flush, read, and close.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Christen
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.
data/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # LogShout
2
+
3
+ Fan-out logging for Ruby using Ractors. LogShout wraps Ruby's `::Logger` to write each log message to multiple destinations (stdout, stderr, files, in-memory) concurrently and safely.
4
+
5
+ ## WARNING
6
+
7
+ Ractor support in Ruby is EXPERIMENTAL use at your own risk.
8
+
9
+ ## Installation
10
+
11
+ Requires CRuby 4.0 or newer (`Ractor::Port` and `Ractor#value`).
12
+
13
+ Add to your Gemfile and bundle:
14
+
15
+ ```ruby
16
+ gem "log_shout", "~> 0.1"
17
+ ```
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ 1. Optionally create a `log_shout.yml` alongside your app (or set `LOG_SHOUT_CONFIG_PATH`). If you skip this, LogShout will run with sane defaults (documented below). If you specify `LOG_SHOUT_CONFIG_PATH` and the file does not exist you will get a runtime error.
26
+
27
+ ```yaml
28
+ ---
29
+ defaultLevel: info
30
+ defaultHandlers: [stdout]
31
+ extraHandlers:
32
+ app_file_log:
33
+ type: file
34
+ path: ./app.log
35
+ loggers:
36
+ app:
37
+ level: debug
38
+ handlers: [stdout, app_file_log]
39
+ ```
40
+
41
+ 2. Use LogShout in your code:
42
+
43
+ ```ruby
44
+ require "log_shout"
45
+
46
+ logger = LogShout.get_logger(:app)
47
+ logger.info("Hello from LogShout")
48
+ # in your console
49
+ # [2025-10-04 10:50:55.514722 #583620/583642] I -- app: Hello from LogShout
50
+ ```
51
+
52
+ Tip: include the helper to get class/instance-level loggers.
53
+ This will provide access to a logger named after the class via both a class and instance method named logger.
54
+
55
+ ```ruby
56
+ module MyModule
57
+ class MyClass
58
+ include LogShout::Helper
59
+
60
+ def self.class_method
61
+ logger.debug("my message")
62
+ # [2025-10-04 10:50:55.514722 #583620/583642] D -- MyModule::MyClass: my message
63
+ end
64
+
65
+ def method
66
+ logger.debug("my message")
67
+ # [2025-10-04 10:50:55.514722 #583620/583642] D -- MyModule::MyClass: my message
68
+ end
69
+ end
70
+ end
71
+
72
+ ```
73
+
74
+ Tip: Run a job in its own Ractor with a Ractor safe logger
75
+ This behavior is similar to the class based helpers but the API is different.
76
+
77
+ If you have the following class
78
+
79
+ ```ruby
80
+ class MyJob
81
+ def self.run(logger, p1, p2)
82
+ logger.debug("some background task #{p1} #{p2}")
83
+ p1+p2
84
+ end
85
+ end
86
+ ```
87
+
88
+ Then you can run the job as follows.
89
+
90
+ ```ruby
91
+ r = LogShout::LoggingRactor.start(target: MyJob, name: :app, args: [1, 4])
92
+ # [2025-10-04 10:50:55.514722 #583620/583642] D -- MyJob: some background task 1 4
93
+ r.value # 5
94
+ ```
95
+
96
+ ## Configuration
97
+
98
+ ### Schema
99
+
100
+ - defaultLevel: info|debug|warn|fatal
101
+ - defaultHandlers: [stdout] # array of handler names that every logger inherits by default.
102
+ - extraHandlers: map of name -> `{ type: stdout|stderr|file|memory, path?: string }` (file requires path).
103
+ - loggers: map of logger name -> `{ level: string, handlers: [names...] }`.
104
+
105
+ Notes:
106
+
107
+ - Built-in handlers `stdout` and `stderr` cannot be overridden. eg. you cannot create a **new** handler named `:stdout`
108
+
109
+ ## Using without a config file (defaults)
110
+
111
+ The configuration file is optional. These are the defaults.
112
+
113
+ ```yaml
114
+ ---
115
+ defaultLevel: INFO
116
+ defaultHandlers: [stdout]
117
+ extraHandlers: {}
118
+ loggers: {}
119
+ ```
120
+
121
+ Runtime behavior of default config:
122
+
123
+ - If you set `LOG_SHOUT_CONFIG_PATH` and that file doesn’t exist, `configure` will raise.
124
+ - Any logger name you request (e.g., `get_logger(:anything)`) uses the default level and handlers when not explicitly configured.
125
+ - By default only `stdout` is attached; to add `stderr`, `file`, or `memory`, define them in `extraHandlers` and reference by name in a logger’s `handlers`.
126
+
127
+ Minimal example with defaults (no file present):
128
+
129
+ ```ruby
130
+ require "log_shout"
131
+ LogShout.get_logger(:any).info("goes to STDOUT at INFO level")
132
+ ```
133
+
134
+ ## Delivery, failure, and lifecycle
135
+
136
+ `logger.info` and the other severity methods enqueue messages asynchronously.
137
+ Returning from a log call does not mean a destination has written the message.
138
+ Each destination processes its queue in order. Ordering across independent
139
+ producers or different destinations is not guaranteed.
140
+
141
+ ```ruby
142
+ LogShout.flush # wait for earlier queued writes at every destination
143
+ LogShout.close_handlers # drain queues, close destinations, wait for workers
144
+ ```
145
+
146
+ `flush` flushes Ruby IO buffers; it does not call `fsync` or guarantee survival
147
+ of a crash or power failure. Queues and memory destinations are unbounded:
148
+ slow destinations or large volumes can consume significant memory. There is
149
+ currently no backpressure, rotation, retry, or delivery guarantee after failure.
150
+
151
+ Destinations are opened during configuration. If opening or writing fails,
152
+ `Ractor::RemoteError` is raised by initialization or the next synchronous
153
+ `flush`, `read`, or `close`; its `cause` contains the underlying IO exception.
154
+ A failed worker wakes waiting callers. A live worker blocked in operating-system
155
+ IO can still block a flush or shutdown; no IO timeout is imposed. Ruby Logger
156
+ may report immediate write errors to stderr instead of propagating them, so use
157
+ an explicit `LogShout.flush` where delivery needs to be checked.
158
+
159
+ Calling `logger.close` flushes its shared destinations. Only
160
+ `LogShout.close_handlers` closes them, and repeated shutdown is safe. An `at_exit`
161
+ hook attempts shutdown and reports failures to stderr without masking an earlier
162
+ application exception. For reliable delivery, explicitly stop producers and call
163
+ `close_handlers` before exiting.
164
+
165
+ `LogShout.configure(path: ...)` validates and opens the replacement destinations,
166
+ drains and closes the previous handlers, and clears the logger cache. Invalid
167
+ configuration or an opening failure preserves the current configuration.
168
+ `LogShout.reset!` closes handlers and clears configuration; the next `get_logger`
169
+ loads configuration again. Stop and join background jobs before reconfiguring,
170
+ resetting, or closing. Discard old logger references and fetch fresh ones after
171
+ reconfiguration. Configuration changes are intended for the main Ractor while
172
+ producers are stopped; concurrent reconfiguration is unsupported.
173
+
174
+ String and symbol logger names are equivalent. Use `LoggingRactor.start` to
175
+ create loggers inside background Ractors rather than calling the module's global
176
+ configuration methods from another Ractor. Join the returned job with `r.value`
177
+ before flushing or closing destinations in the main Ractor.
178
+
179
+ ## Architecture: Logger I/O
180
+
181
+ This shows how log messages flow from your code through Ruby’s `::Logger`, across `MultiIO`, into per-destination Ractors, and finally to underlying IOs.
182
+
183
+ Key parts:
184
+
185
+ - `LogShout.get_logger` in [lib/log_shout.rb](lib/log_shout.rb): builds `::Logger` with a custom logdev and default formatter.
186
+ - `LogShout::MultiIO` in [lib/log_shout/multi_io.rb](lib/log_shout/multi_io.rb): fan-out logdev that forwards `write/flush/close` to all handlers.
187
+ - `LogShout::IORactor` in [lib/log_shout/io_ractor.rb](lib/log_shout/io_ractor.rb): one ractor per handler; owns the actual IO (STDOUT/ERR/File/StringIO).
188
+ - `LogShout::LoggingRactor` in [lib/log_shout/logging_ractor.rb](lib/log_shout/logging_ractor.rb): helper to run a job in its own ractor with a ready-to-use logger.
189
+ - Default formatter in [lib/log_shout/formatters/default.rb](lib/log_shout/formatters/default.rb).
190
+
191
+ `LogShout::IORactor` objects are pre-created for all handlers defined. These objects are shared between all loggers that are created. So if you create 20 loggers with the same handler, only one `LogShout::IORactor` will exist, and thus only one Ractor will be created.
192
+
193
+ ### Component view
194
+
195
+ ```mermaid
196
+ flowchart LR
197
+ subgraph App
198
+ A["Your code"]
199
+ end
200
+
201
+ subgraph LogShout core
202
+ LS["get_logger(name)<br/>/log_shout.rb"]
203
+ L["Logger<br/> stdlib"]
204
+ M["LogShout::MultiIO<br/>/log_shout/multi_io.rb"]
205
+ end
206
+
207
+ subgraph "Handlers (per destination)"
208
+ direction TB
209
+ H1["LogShout::IORactor<br/>(stdout)<br/>/log_shout/io_ractor.rb"]
210
+ H2["LogShout::IORactor<br/>(stderr)<br/>/log_shout/io_ractor.rb"]
211
+ H3["LogShout::IORactor<br/>(file)<br/>/log_shout/io_ractor.rb"]
212
+ H4["LogShout::IORactor<br/>(memory)<br/>/log_shout/io_ractor.rb"]
213
+ end
214
+
215
+ subgraph "Ractor workers (one per handler)"
216
+ direction TB
217
+ R1["Ractor loop<br/>.for_fd(1)"]
218
+ R2["Ractor loop<br/>.for_fd(2)"]
219
+ R3["Ractor loop<br/>.open(path,'a')"]
220
+ R4["Ractor loop"]
221
+ end
222
+
223
+ A -->|"logger = LogShout.get_logger(:name)"| LS
224
+ LS -->|builds| L
225
+ L -->|logdev| M
226
+
227
+ M -->|write/flush/close| H1
228
+ M -->|write/flush/close| H2
229
+ M -->|write/flush/close| H3
230
+ M -->|write/flush/close| H4
231
+
232
+ H1 -->|"send [:op, ...]"| R1
233
+ H2 -->|"send [:op, ...]"| R2
234
+ H3 -->|"send [:op, ...]"| R3
235
+ H4 -->|"send [:op, ...]"| R4
236
+
237
+ R1 --> IO1[(STDOUT)]
238
+ R2 --> IO2[(STDERR)]
239
+ R3 --> IO3[(File)]
240
+ R4 --> IO4[(StringIO)]
241
+ ```
242
+
243
+ ### Sequence view (write/flush/close)
244
+
245
+ ```mermaid
246
+ sequenceDiagram
247
+ participant App
248
+ participant Logger as ::Logger
249
+ participant MultiIO as LogShout::MultiIO
250
+ participant IO as LogShout::IORactor
251
+
252
+ App->>Logger: info("msg")
253
+ Logger->>Logger: format -> "s"
254
+ Logger->>MultiIO: write("s")
255
+ loop for each handler
256
+ MultiIO->>IO: write("s")
257
+ end
258
+ ```
259
+
260
+ ## Dev setup(with nix+direnv)
261
+
262
+ The flake uses Ruby 4.0 from `nixos-unstable`, including the `Ractor::Port` API
263
+ previously used from Ruby nightly. No local Ruby fork is needed. Bundix uses
264
+ [the fork required by ruby-nix](https://github.com/inscapist/ruby-nix#global-bundix-installation-optional)
265
+ for platform-dependent gems, built with the same Ruby as the project.
266
+
267
+ ```bash
268
+ direnv allow
269
+ bundix
270
+ ```
271
+
272
+ Without Nix, install dependencies with a released Bundler:
273
+
274
+ ```bash
275
+ gem install bundler -v 2.7.2
276
+ bundle _2.7.2_ install
277
+ ```
278
+
279
+ Run the tests and style checks with `bundle exec rake`; build with
280
+ `bundle exec rake build`. Run `ruby script/check_package.rb` to install the
281
+ built gem into a temporary gem home and check it through Bundler without the
282
+ development dependencies. That check downloads runtime dependencies.
283
+
284
+ See [CHANGELOG.md](CHANGELOG.md) and [RELEASING.md](RELEASING.md) for release status
285
+ and publishing instructions. Licensed under the [MIT License](LICENSE).
data/RELEASING.md ADDED
@@ -0,0 +1,42 @@
1
+ # Releasing LogShout
2
+
3
+ ## One-time account setup
4
+
5
+ 1. Confirm the gem name `log_shout` is available or owned by your RubyGems account.
6
+ 2. Enable MFA on that account. The gem declares `rubygems_mfa_required`.
7
+ 3. On RubyGems, create a pending trusted publisher for a first release (or a
8
+ trusted publisher on the existing gem):
9
+ - Gem: `log_shout`
10
+ - Repository owner: `mchristen`
11
+ - Repository: `log_shout`
12
+ - Workflow filename: `release.yml`
13
+ - Environment: `release`
14
+ 4. In the GitHub repository, create a `release` environment with required reviewer
15
+ approval if available for the repository, and protect your release branches.
16
+
17
+ See the official [Trusted Publishing guide](https://guides.rubygems.org/trusted-publishing/).
18
+ No long-lived RubyGems API token is needed by the workflow.
19
+
20
+ ## Each release
21
+
22
+ 1. Set `LogShout::VERSION` in `lib/log_shout/version.rb`. Update the matching
23
+ changelog entry with the release date and user-visible changes. Verify the
24
+ maintainer email and public metadata before the first release.
25
+ 2. If dependencies change, run `bundle _2.7.2_ lock`, regenerate `gemset.nix`
26
+ with `bundix`, and commit both lock representations.
27
+ 3. Run `bundle exec rake`, `bundle exec rake build`, and
28
+ `ruby script/check_package.rb`. Inspect the packaged file list. The package
29
+ check installs runtime dependencies from RubyGems into a temporary directory.
30
+ 4. Commit all release changes and ensure CI passes on Ruby 4.0.0 and the latest
31
+ 4.0 patch release. Review the exact commit to publish.
32
+ 5. Create and push the matching version tag, for example `v0.1.0`, pointing at
33
+ that tested commit.
34
+ 6. In GitHub Actions, manually run **Release** from the default branch, entering
35
+ that existing tag. It verifies the tag matches the gemspec, reruns checks,
36
+ and publishes through RubyGems Trusted Publishing. Approve the `release`
37
+ environment deployment when configured.
38
+ 7. Verify the published version and its metadata on RubyGems, then create release
39
+ notes from the changelog. Published version numbers cannot be reused.
40
+
41
+ The repository includes preparation and verification code only; account setup,
42
+ pushing release tags, and publishing are deliberate maintainer actions.
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogShout
4
+ module Formatters
5
+ class Default < ::Logger::Formatter
6
+ FORMAT = "[%s #%d/%6s] %.1s -- %s: %s\n"
7
+ DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%6N"
8
+
9
+ def call(severity, time, progname, msg)
10
+ thread_id = Thread.current.name || Thread.current.native_thread_id
11
+ sprintf(FORMAT, time.strftime(DATETIME_FORMAT), Process.pid, thread_id, severity, progname, msg2str(msg))
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogShout
4
+ module Helper
5
+ module ClassMethods
6
+ def logger(extra_name: nil)
7
+ logger_name = "#{name}::#{extra_name}" if extra_name
8
+ logger_name ||= name
9
+ LogShout.get_logger(logger_name.to_sym)
10
+ end
11
+ end
12
+
13
+ def logger(extra_name: nil)
14
+ self.class.logger(extra_name: extra_name)
15
+ end
16
+
17
+ def self.included(other)
18
+ other.extend(ClassMethods)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogShout
4
+ class IORactor
5
+ def initialize(handler: {})
6
+ @handler = handler
7
+ @closed = false
8
+ @r = Ractor.new(handler) do |definition|
9
+ IORactor.execute_ractor(definition)
10
+ end
11
+ request(:ready)
12
+ end
13
+
14
+ def self.execute_ractor(handler)
15
+ io = case handler[:type]
16
+ when nil, :stdout
17
+ IO.for_fd(1, autoclose: false)
18
+ when :stderr
19
+ IO.for_fd(2, autoclose: false)
20
+ when :file
21
+ File.open(handler.fetch(:destination), "a")
22
+ when :memory
23
+ StringIO.new
24
+ else
25
+ raise ArgumentError, "Invalid handler definition: #{handler.inspect}"
26
+ end
27
+ io.sync = true if io.respond_to?(:sync=)
28
+
29
+ begin
30
+ loop do
31
+ op, arg, reply = Ractor.receive
32
+ result = case op
33
+ when :ready
34
+ nil
35
+ when :write
36
+ io.write(arg)
37
+ when :flush
38
+ io.flush
39
+ nil
40
+ when :read
41
+ io.is_a?(StringIO) ? io.string : nil
42
+ when :close
43
+ break
44
+ end
45
+ reply&.send([:ok, result])
46
+ end
47
+ ensure
48
+ io.close unless io.closed?
49
+ end
50
+ :closed
51
+ end
52
+
53
+ def name
54
+ @handler[:name]
55
+ end
56
+
57
+ def type
58
+ @handler[:type]
59
+ end
60
+
61
+ def destination
62
+ @handler[:destination]
63
+ end
64
+
65
+ def closed?
66
+ @closed || @r.default_port.closed?
67
+ end
68
+
69
+ def write(str)
70
+ raise IOError, "closed log handler" if closed?
71
+ s = String(str)
72
+ @r.send([:write, s])
73
+ s.bytesize
74
+ end
75
+
76
+ def flush(wait_for_flush = true)
77
+ if wait_for_flush
78
+ request(:flush)
79
+ else
80
+ @r.send([:flush])
81
+ end
82
+ nil
83
+ end
84
+
85
+ def read
86
+ request(:read)
87
+ end
88
+
89
+ def close
90
+ return if @closed
91
+ begin
92
+ begin
93
+ @r.send([:close])
94
+ rescue Ractor::ClosedError
95
+ # Join below still reports the worker's original failure.
96
+ end
97
+ @r.join
98
+ ensure
99
+ @closed = true
100
+ end
101
+ nil
102
+ end
103
+
104
+ private
105
+
106
+ # A fresh reply port belongs to the calling Ractor. Monitoring that same
107
+ # port also wakes the caller if the worker dies before replying.
108
+ def request(op)
109
+ raise IOError, "closed log handler" if @closed
110
+ reply = Ractor::Port.new
111
+ @r.monitor(reply)
112
+ begin
113
+ @r.send([op, nil, reply])
114
+ rescue Ractor::ClosedError
115
+ @r.join
116
+ raise IOError, "closed log handler"
117
+ end
118
+ response = reply.receive
119
+ unless response.is_a?(Array)
120
+ @r.join
121
+ raise IOError, "log handler terminated before replying"
122
+ end
123
+ response[1]
124
+ ensure
125
+ if reply
126
+ @r.unmonitor(reply)
127
+ reply.close
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogShout
4
+ class LoggingRactor
5
+ # caller passes a shareable target (Module/Class) and a method Symbol
6
+ def self.start(target:, method: :run, name: nil, args: [])
7
+ name ||= target.name
8
+ handlers = LogShout.get_handlers_for(name).freeze
9
+ level = ::Logger.const_get(LogShout.get_level_for_name(name).upcase)
10
+ logdev = LogShout::MultiIO.new(handlers)
11
+ r = Ractor.new(level, logdev, args, name: name) do |level, logdev, a|
12
+ logger = ::Logger.new(logdev,
13
+ level: level,
14
+ progname: Ractor.current.name,
15
+ formatter: LogShout::Formatters::Default.new)
16
+ # Call a shareable entrypoint; e.g., MyJob.run(logger, *a)
17
+ job = Ractor.receive # receive [ModuleOrClass, :method]
18
+ receiver = Ractor.receive
19
+ job.public_send(receiver, logger, *a)
20
+ ensure
21
+ logdev.flush(false)
22
+ end
23
+
24
+ # Send the callable in a ractor-safe way (two shareable messages)
25
+ r.send(target)
26
+ r.send(method)
27
+ r
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogShout
4
+ class MultiIO
5
+ def initialize(targets)
6
+ @targets = targets
7
+ end
8
+
9
+ def write(message)
10
+ @targets.each { |t| t.write(message) }
11
+ nil
12
+ end
13
+
14
+ def close
15
+ # Destinations belong to LogShout and may be used by other loggers.
16
+ flush
17
+ nil
18
+ end
19
+
20
+ def flush(wait_for_flush = true)
21
+ @targets.each do |t|
22
+ t.flush(wait_for_flush)
23
+ end
24
+ nil
25
+ end
26
+
27
+ # only useful for debugging memory handler
28
+ def read(handler)
29
+ @targets.each do |t|
30
+ next unless t.name == handler
31
+ return t.read
32
+ end
33
+ nil
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogShout
4
+ VERSION = "0.1.0"
5
+ end
data/lib/log_shout.rb ADDED
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+ require "logger"
6
+ require "stringio"
7
+
8
+ require "log_shout/helper"
9
+ require "log_shout/multi_io"
10
+ require "log_shout/io_ractor"
11
+ require "log_shout/logging_ractor"
12
+ require "log_shout/version"
13
+ require "log_shout/formatters/default"
14
+
15
+ module LogShout
16
+ class LogShoutError < StandardError; end
17
+
18
+ # Replace the Sorbet enum with a plain Ruby value-object enum
19
+ class LogDestination
20
+ attr_reader :name
21
+ private_class_method :new
22
+
23
+ def initialize(name)
24
+ @name = name
25
+ end
26
+
27
+ def to_sym
28
+ @name
29
+ end
30
+
31
+ def to_s
32
+ @name.to_s
33
+ end
34
+
35
+ # Keep the same API used elsewhere
36
+ def serialize
37
+ @name
38
+ end
39
+
40
+ def ==(other)
41
+ other.is_a?(LogDestination) && other.name == name
42
+ end
43
+
44
+ alias_method :eql?, :==
45
+
46
+ def hash
47
+ name.hash
48
+ end
49
+
50
+ STD_OUT = new(:stdout)
51
+ STD_ERR = new(:stderr)
52
+ FILE = new(:file)
53
+ MEMORY = new(:memory)
54
+
55
+ ALL = [STD_OUT, STD_ERR, FILE, MEMORY].freeze
56
+
57
+ def self.from(value)
58
+ return value if value.is_a?(LogDestination)
59
+ sym = value.to_s.downcase.to_sym
60
+ ALL.find { |v| v.name == sym } || (raise ArgumentError, "Unknown destination: #{value.inspect}")
61
+ end
62
+ end
63
+
64
+ # Keep keyword-only constructors for the public configuration structs.
65
+
66
+ LogHandler = Struct.new(:name, :type, :destination, keyword_init: true) do # standard:disable Style/RedundantStructKeywordInit
67
+ def ==(other)
68
+ other.is_a?(LogHandler) && name == other.name
69
+ end
70
+
71
+ def serialize
72
+ {
73
+ name: name,
74
+ type: type.respond_to?(:serialize) ? type.serialize : type.to_sym,
75
+ destination: destination
76
+ }
77
+ end
78
+ end
79
+
80
+ LogConfig = Struct.new(:level, :handlers, keyword_init: true) do # standard:disable Style/RedundantStructKeywordInit
81
+ def ==(other)
82
+ other.is_a?(LogConfig) &&
83
+ level == other.level &&
84
+ handlers == other.handlers
85
+ end
86
+
87
+ def serialize
88
+ {
89
+ level: level,
90
+ handlers: handlers.map(&:serialize)
91
+ }
92
+ end
93
+ end
94
+
95
+ LogConfigFile = Struct.new(:loggers, :extraHandlers, :defaultLevel, :defaultHandlers, keyword_init: true) do # standard:disable Style/RedundantStructKeywordInit
96
+ def ==(other)
97
+ other.is_a?(LogConfigFile) &&
98
+ loggers == other.loggers &&
99
+ extraHandlers == other.extraHandlers &&
100
+ defaultLevel == other.defaultLevel &&
101
+ defaultHandlers == other.defaultHandlers
102
+ end
103
+
104
+ def serialize
105
+ {
106
+ loggers: loggers.transform_values(&:serialize),
107
+ extraHandlers: extraHandlers.transform_values(&:serialize),
108
+ defaultLevel: defaultLevel,
109
+ defaultHandlers: defaultHandlers.map(&:serialize)
110
+ }
111
+ end
112
+ end
113
+
114
+ BUILTIN_HANDLERS = {
115
+ stdout: LogHandler.new(name: :stdout, type: LogDestination::STD_OUT),
116
+ stderr: LogHandler.new(name: :stderr, type: LogDestination::STD_ERR)
117
+ }
118
+
119
+ DEFAULT_CONFIG = LogConfigFile.new(
120
+ defaultLevel: "INFO",
121
+ defaultHandlers: [BUILTIN_HANDLERS[:stdout]],
122
+ loggers: {},
123
+ extraHandlers: {}
124
+ )
125
+
126
+ @loggers = {}
127
+
128
+ def self.configure(path: nil)
129
+ default_path = File.expand_path("log_shout.yml", FileUtils.pwd)
130
+ requested_path = ENV.fetch("LOG_SHOUT_CONFIG_PATH", path)
131
+ config_file_path = requested_path || default_path
132
+ config = if File.exist?(config_file_path)
133
+ begin
134
+ validate_config(YAML.load_file(config_file_path, symbolize_names: true))
135
+ rescue Psych::SyntaxError => e
136
+ raise LogShoutError, "Failed to parse YAML configuration file at #{config_file_path}: #{e.message}"
137
+ end
138
+ elsif requested_path
139
+ raise LogShoutError, "Configuration file not found at #{config_file_path}"
140
+ else
141
+ DEFAULT_CONFIG
142
+ end
143
+ validated_config = config.serialize.freeze
144
+ handlers = build_handlers(validated_config)
145
+ begin
146
+ close_handlers
147
+ rescue
148
+ handlers.each_value(&:close)
149
+ raise
150
+ end
151
+ @config_file_path = config_file_path
152
+ @validated_config = validated_config
153
+ @handlers = handlers.freeze
154
+ @loggers = {}
155
+ @config_file_path
156
+ end
157
+
158
+ def self.build_handlers(config)
159
+ handlers = {}
160
+ definitions = config[:defaultHandlers] + config[:extraHandlers].values +
161
+ config[:loggers].values.flat_map { |logger| logger[:handlers] }
162
+ definitions.uniq { |handler| handler[:name] }.each do |handler|
163
+ handlers[handler[:name]] = create_handler(handler)
164
+ end
165
+ handlers
166
+ rescue
167
+ handlers.each_value(&:close)
168
+ raise
169
+ end
170
+ private_class_method :build_handlers
171
+
172
+ def self.create_handler(handler)
173
+ LogShout::IORactor.new(handler: handler) # must respond_to?(:write)
174
+ end
175
+
176
+ def self.validate_config(config)
177
+ raise LogShoutError, "Configuration must be a mapping" unless config.is_a?(Hash)
178
+ [:extraHandlers, :loggers].each do |key|
179
+ raise LogShoutError, "#{key} must be a mapping" unless config.fetch(key, {}).is_a?(Hash)
180
+ end
181
+ default_level = config.fetch(:defaultLevel, "INFO")
182
+ validate_level(default_level)
183
+ default_handler_names = config.fetch(:defaultHandlers, ["stdout"])
184
+ raise LogShoutError, "defaultHandlers must be an array" unless default_handler_names.is_a?(Array)
185
+ extra_handlers = config.fetch(:extraHandlers, {}).each_with_object({}) do |(name, config), obj|
186
+ if name === :stdout || name === :stderr
187
+ raise LogShoutError, "Cannot override built-in handler: #{name}"
188
+ end
189
+ raise LogShoutError, "Handler #{name} must be a mapping" unless config.is_a?(Hash)
190
+ handler = case config[:type]
191
+ when "memory"
192
+ LogHandler.new(name: name.to_sym, type: LogDestination::MEMORY)
193
+ when "file"
194
+ if !config[:path]
195
+ raise LogShoutError, "File handler #{name} requires a path"
196
+ end
197
+ LogHandler.new(name: name.to_sym, type: LogDestination::FILE, destination: config[:path])
198
+ when "stdout"
199
+ LogHandler.new(name: name.to_sym, type: LogDestination::STD_OUT)
200
+ when "stderr"
201
+ LogHandler.new(name: name.to_sym, type: LogDestination::STD_ERR)
202
+ else
203
+ raise LogShoutError, "Unknown handler type: #{config[:type]}"
204
+ end
205
+ obj[name.to_sym] = handler
206
+ end
207
+ default_handlers = default_handler_names.map do |name|
208
+ BUILTIN_HANDLERS[name.to_sym] || extra_handlers[name.to_sym] || (raise LogShoutError, "Unknown handler: #{name}")
209
+ end
210
+
211
+ loggers = config.fetch(:loggers, {}).each_with_object({}) do |(name, config), obj|
212
+ raise LogShoutError, "Logger #{name} must be a mapping" unless config.is_a?(Hash)
213
+ level = config.fetch(:level, default_level)
214
+ validate_level(level)
215
+ handler_names = config.fetch(:handlers, default_handler_names)
216
+ raise LogShoutError, "Logger #{name} handlers must be an array" unless handler_names.is_a?(Array)
217
+ handlers = handler_names.map do |name|
218
+ BUILTIN_HANDLERS[name.to_sym] || extra_handlers[name.to_sym] || (raise LogShoutError, "Unknown handler: #{name}")
219
+ end
220
+ obj[name.to_sym] = LogConfig.new(level: level, handlers: handlers)
221
+ end
222
+
223
+ LogConfigFile.new(
224
+ defaultLevel: config.fetch(:defaultLevel, "INFO"),
225
+ defaultHandlers: default_handlers,
226
+ extraHandlers: extra_handlers,
227
+ loggers: loggers
228
+ )
229
+ end
230
+
231
+ def self.validate_level(level)
232
+ unless level.is_a?(String) && %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].include?(level.upcase)
233
+ raise LogShoutError, "Invalid log level: #{level.inspect}"
234
+ end
235
+ end
236
+ private_class_method :validate_level
237
+
238
+ def self.get_handler(name)
239
+ @handlers[name]
240
+ end
241
+
242
+ def self.get_handlers_for(logger_name)
243
+ config = configuration[:loggers].fetch(
244
+ logger_name.to_sym,
245
+ {
246
+ level: configuration[:defaultLevel],
247
+ handlers: configuration[:defaultHandlers]
248
+ }
249
+ )
250
+ config[:handlers].map do |h|
251
+ get_handler(h[:name])
252
+ end
253
+ end
254
+
255
+ def self.flush
256
+ return unless @handlers
257
+ @handlers.each_value do |handler|
258
+ handler.flush
259
+ end
260
+ end
261
+
262
+ def self.close_handlers
263
+ error = nil
264
+ (@handlers || {}).each_value do |handler|
265
+ handler.close
266
+ rescue => e
267
+ error ||= e
268
+ end
269
+ raise error if error
270
+ nil
271
+ end
272
+
273
+ def self.get_level_for_name(name)
274
+ configuration[:loggers].fetch(
275
+ name.to_sym,
276
+ {
277
+ level: configuration[:defaultLevel],
278
+ handlers: configuration[:defaultHandlers]
279
+ }
280
+ )[:level]
281
+ end
282
+
283
+ def self.get_logger(name)
284
+ configure unless @handlers
285
+ name = name.to_sym
286
+ if (logger = @loggers[name])
287
+ return logger
288
+ end
289
+
290
+ config = configuration[:loggers].fetch(
291
+ name,
292
+ {
293
+ level: configuration[:defaultLevel],
294
+ handlers: configuration[:defaultHandlers]
295
+ }
296
+ )
297
+
298
+ handlers = config[:handlers].map do |h|
299
+ get_handler(h[:name])
300
+ end
301
+
302
+ @loggers[name] = ::Logger.new(LogShout::MultiIO.new(handlers),
303
+ level: ::Logger.const_get(config[:level].upcase),
304
+ progname: name.to_s,
305
+ formatter: LogShout::Formatters::Default.new)
306
+ end
307
+
308
+ def self.configuration
309
+ @validated_config || DEFAULT_CONFIG.serialize.freeze
310
+ end
311
+
312
+ def self.config_file_path
313
+ @config_file_path
314
+ end
315
+
316
+ def self.reset!
317
+ close_handlers
318
+ @handlers = nil
319
+ @config_file_path = nil
320
+ @validated_config = nil
321
+ @loggers = {}
322
+ end
323
+ end
324
+
325
+ LogShout.configure
326
+
327
+ at_exit do
328
+ LogShout.close_handlers
329
+ rescue => e
330
+ warn "LogShout shutdown failed: #{e.class}: #{e.message}"
331
+ end
metadata ADDED
@@ -0,0 +1,174 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: log_shout
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt Christen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.7'
26
+ - !ruby/object:Gem::Dependency
27
+ name: standard
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.53'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.53'
40
+ - !ruby/object:Gem::Dependency
41
+ name: standard-performance
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.6'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.6'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ruby-lsp
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.23'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.23'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '5.26'
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: 5.26.1
78
+ type: :development
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - "~>"
83
+ - !ruby/object:Gem::Version
84
+ version: '5.26'
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 5.26.1
88
+ - !ruby/object:Gem::Dependency
89
+ name: rake
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '13.1'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '13.1'
102
+ - !ruby/object:Gem::Dependency
103
+ name: bundler-audit
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '0.9'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '0.9'
116
+ - !ruby/object:Gem::Dependency
117
+ name: debug
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '1.11'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '1.11'
130
+ description: Easy to configure ruby loggers. Multi-destination and configuration levels.
131
+ email:
132
+ - oss@christen.tech
133
+ executables: []
134
+ extensions: []
135
+ extra_rdoc_files: []
136
+ files:
137
+ - CHANGELOG.md
138
+ - LICENSE
139
+ - README.md
140
+ - RELEASING.md
141
+ - lib/log_shout.rb
142
+ - lib/log_shout/formatters/default.rb
143
+ - lib/log_shout/helper.rb
144
+ - lib/log_shout/io_ractor.rb
145
+ - lib/log_shout/logging_ractor.rb
146
+ - lib/log_shout/multi_io.rb
147
+ - lib/log_shout/version.rb
148
+ homepage: https://github.com/mchristen/log_shout
149
+ licenses:
150
+ - MIT
151
+ metadata:
152
+ changelog_uri: https://github.com/mchristen/log_shout/blob/main/CHANGELOG.md
153
+ documentation_uri: https://github.com/mchristen/log_shout#readme
154
+ source_code_uri: https://github.com/mchristen/log_shout
155
+ bug_tracker_uri: https://github.com/mchristen/log_shout/issues
156
+ rubygems_mfa_required: 'true'
157
+ rdoc_options: []
158
+ require_paths:
159
+ - lib
160
+ required_ruby_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: 4.0.0
165
+ required_rubygems_version: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - ">="
168
+ - !ruby/object:Gem::Version
169
+ version: '0'
170
+ requirements: []
171
+ rubygems_version: 4.0.20
172
+ specification_version: 4
173
+ summary: Ruby loggers with flexibility.
174
+ test_files: []