julewire-semantic_logger 1.0.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: e701488ff85db0998210de87726f8fe7aeb5d8aacefcf6721b86e8d60b5df01b
4
+ data.tar.gz: e461c66f174f9d83105cbac6e6fa0a457e5387c8ea293ae91bf5599b2d7305d8
5
+ SHA512:
6
+ metadata.gz: ada163b0d2af3819d88a210193063d7188151a1f298508816bc82ecd4e131790450e7c646c91414db22711fee7c434f5ed2d7d9d5649e2e0dc502b090c6e3d3a
7
+ data.tar.gz: 98d7f0cb14e358b81164a5f131d7d7e99602554668fa302d7c3e9466747b6f674b2ece1bbae01fb24e0bdc637bd81987f6b9b6b60217ad509f4804503452e885
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## Unreleased
2
+
3
+ ## 1.0.0 - 2026-06-21
4
+
5
+ - Initial release: Semantic Logger transport destination with exact payload
6
+ output, async/file appender support, lifecycle hooks, and health.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alexander Grebennik
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # Julewire Semantic Logger
2
+
3
+ `julewire-semantic_logger` is a Semantic Logger transport destination for
4
+ Julewire.
5
+
6
+ Julewire keeps its own record shape. Semantic Logger owns appender plumbing,
7
+ file/stdout output, optional async queues, flush, close, and reopen.
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ gem "julewire-semantic_logger"
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```ruby
18
+ Julewire.configure do |config|
19
+ config.destinations.use(
20
+ :semantic_logger,
21
+ formatter: Julewire::RecordFormatter.new,
22
+ io: $stdout
23
+ )
24
+ end
25
+ ```
26
+
27
+ The adapter is synchronous by default. Enable async explicitly:
28
+
29
+ ```ruby
30
+ Julewire.configure do |config|
31
+ config.destinations.use(
32
+ :semantic_logger,
33
+ formatter: Julewire::RecordFormatter.new,
34
+ io: $stdout,
35
+ async: true,
36
+ max_queue_size: 10_000
37
+ )
38
+ end
39
+ ```
40
+
41
+ ## Docs
42
+
43
+ - [Configuration](docs/configuration.md)
44
+ - [Advanced Configuration](docs/advanced-configuration.md)
45
+ - [Transport](docs/transport.md)
46
+ - [Health](docs/health.md)
@@ -0,0 +1,54 @@
1
+ # Advanced Configuration
2
+
3
+ ## Prebuilt Transport
4
+
5
+ Pass `transport:` when construction needs to happen outside the destination:
6
+
7
+ ```ruby
8
+ transport = Julewire::SemanticLogger::Transport.new(io: $stdout)
9
+
10
+ config.destinations.use(
11
+ :semantic_logger,
12
+ formatter: Julewire::RecordFormatter.new,
13
+ transport: transport
14
+ )
15
+ ```
16
+
17
+ ## Prebuilt Appenders
18
+
19
+ Pass an existing Semantic Logger appender with `appender:`:
20
+
21
+ ```ruby
22
+ config.destinations.use(
23
+ :semantic_logger,
24
+ formatter: Julewire::RecordFormatter.new,
25
+ appender: my_appender
26
+ )
27
+ ```
28
+
29
+ ## Async Lag Options
30
+
31
+ These options are passed to `SemanticLogger::Appender::Async` when
32
+ `async: true`:
33
+
34
+ | Option | Default |
35
+ | --- | --- |
36
+ | `lag_check_interval:` | `1_000` |
37
+ | `lag_threshold_s:` | `30` |
38
+
39
+ ## Appender Defaults
40
+
41
+ Unknown transport options are merged into each appender spec. This is useful for
42
+ Semantic Logger appender-specific options:
43
+
44
+ ```ruby
45
+ config.destinations.use(
46
+ :semantic_logger,
47
+ formatter: Julewire::RecordFormatter.new,
48
+ appenders: [
49
+ { io: $stdout },
50
+ { file_name: "log/julewire.log" }
51
+ ],
52
+ level: :debug
53
+ )
54
+ ```
@@ -0,0 +1,87 @@
1
+ # Configuration
2
+
3
+ `julewire-semantic_logger` registers the `:semantic_logger` destination kind.
4
+
5
+ ```ruby
6
+ Julewire.configure do |config|
7
+ config.destinations.use(
8
+ :semantic_logger,
9
+ formatter: Julewire::RecordFormatter.new,
10
+ io: $stdout
11
+ )
12
+ end
13
+ ```
14
+
15
+ ## Destination Options
16
+
17
+ | Option | Meaning |
18
+ | --- | --- |
19
+ | `name:` | Destination name. Required. |
20
+ | `formatter:` | Julewire formatter object. Required. |
21
+ | `encoder:` | Julewire encoder object. Defaults to core JSON without a trailing newline. |
22
+ | `transport:` | Prebuilt `Julewire::SemanticLogger::Transport`. |
23
+ | `**transport_options` | Passed to `Transport.new` when `transport:` is omitted. |
24
+
25
+ The destination passes the immutable Julewire record to `formatter`, then hands
26
+ the formatter result through `encoder`. The transport receives the encoded
27
+ string. String formatter results are treated as already encoded and lose one
28
+ trailing newline when present.
29
+
30
+ ## Transport Options
31
+
32
+ At least one appender target is required.
33
+
34
+ | Option | Default | Meaning |
35
+ | --- | --- | --- |
36
+ | `io:` | none | IO appender target such as `$stdout`. |
37
+ | `file_name:` | none | File appender target. |
38
+ | `appender:` | none | Existing Semantic Logger appender. |
39
+ | `appenders:` | none | Array of appender specs. |
40
+ | `async:` | `false` | Wrap the sink in `SemanticLogger::Appender::Async`. |
41
+ | `max_queue_size:` | `10_000` | Async queue size. `-1` means unbounded in Semantic Logger. |
42
+
43
+ Unknown transport options are passed to Semantic Logger appender construction.
44
+
45
+ ## Appender Specs
46
+
47
+ Single stdout appender:
48
+
49
+ ```ruby
50
+ config.destinations.use(
51
+ :semantic_logger,
52
+ formatter: Julewire::RecordFormatter.new,
53
+ io: $stdout
54
+ )
55
+ ```
56
+
57
+ Multiple appenders:
58
+
59
+ ```ruby
60
+ config.destinations.use(
61
+ :semantic_logger,
62
+ formatter: Julewire::RecordFormatter.new,
63
+ appenders: [
64
+ { io: $stdout },
65
+ { file_name: "log/julewire.log" }
66
+ ]
67
+ )
68
+ ```
69
+
70
+ Async output:
71
+
72
+ ```ruby
73
+ config.destinations.use(
74
+ :semantic_logger,
75
+ formatter: Julewire::RecordFormatter.new,
76
+ io: $stdout,
77
+ async: true,
78
+ max_queue_size: 10_000
79
+ )
80
+ ```
81
+
82
+ Async moves blocking and drop behavior into Semantic Logger's queue. Keep
83
+ `max_queue_size` explicit and call `Julewire.flush` before shutdown when queued
84
+ records matter.
85
+
86
+ For multi-appender output, async lag options, and prebuilt appenders, see
87
+ [Advanced Configuration](advanced-configuration.md).
data/docs/health.md ADDED
@@ -0,0 +1,43 @@
1
+ # Health
2
+
3
+ The destination health surface is transport-shaped.
4
+
5
+ ```ruby
6
+ Julewire.health
7
+ ```
8
+
9
+ Destination health includes:
10
+
11
+ - destination `status`
12
+ - destination write/failure counts
13
+ - transport write/failure counts
14
+ - async queue state
15
+ - file appender metadata
16
+ - child appender shape for multi-appender output
17
+ - lifecycle warnings
18
+
19
+ Lifecycle warnings currently include:
20
+
21
+ - `:async_queue_blocks_when_full`
22
+ - `:async_queue_unbounded`
23
+ - `:sync_multi_appender_blocks_emitters`
24
+
25
+ The adapter does not recreate transport-level drop taxonomy. Semantic Logger
26
+ owns async worker behavior; Julewire reports the transport state it can observe.
27
+
28
+ ## Metric Mapping
29
+
30
+ One practical mapping is:
31
+
32
+ | Health path | Metric name |
33
+ | --- | --- |
34
+ | `counts.*` | `julewire_runtime_total{event}` |
35
+ | `pipeline.counts.*` | `julewire_pipeline_total{event}` |
36
+ | `destinations.*.counts.*` | `julewire_destination_total{destination,event}` |
37
+ | `destinations.*.last_loss.reason` | `julewire_destination_last_loss{destination,reason}` |
38
+ | `destinations.*.transport.counts.*` | `julewire_semantic_logger_transport_total{destination,event}` |
39
+ | `destinations.*.transport.appender.queue_size` | `julewire_semantic_logger_queue_size{destination}` |
40
+ | `destinations.*.transport.appender.max_queue_size` | `julewire_semantic_logger_queue_capacity{destination}` |
41
+ | `destinations.*.transport.warnings.*` | `julewire_semantic_logger_warning{destination,reason}` |
42
+
43
+ Treat core health paths as inputs, not as a global metrics schema.
data/docs/transport.md ADDED
@@ -0,0 +1,108 @@
1
+ # Semantic Logger Transport
2
+
3
+ The adapter uses Semantic Logger as transport only.
4
+
5
+ It does not use Semantic Logger's JSON formatter for Julewire records because
6
+ that formatter emits Semantic Logger's schema. Julewire formatters already own
7
+ the output shape.
8
+
9
+ ## Destination Path
10
+
11
+ ```text
12
+ Julewire formatter -> Julewire encoder -> Semantic Logger exact formatter -> IO
13
+ ```
14
+
15
+ This uses the adapter destination. The destination maps the record with the
16
+ configured formatter, encodes the formatter result with Julewire's encoder, then
17
+ hands the encoded payload string to Semantic Logger. The exact formatter
18
+ preserves that string shape for appenders.
19
+
20
+ ```ruby
21
+ class LogShape
22
+ def call(record)
23
+ {
24
+ level: record.fetch(:severity),
25
+ message: record.fetch(:message),
26
+ labels: record.fetch(:labels),
27
+ payload: record.fetch(:payload)
28
+ }
29
+ end
30
+ end
31
+
32
+ Julewire.configure do |config|
33
+ config.destinations.use(
34
+ :semantic_logger,
35
+ formatter: LogShape.new,
36
+ io: $stdout,
37
+ async: true
38
+ )
39
+ end
40
+ ```
41
+
42
+ Core passes a frozen, normalized, symbol-key record to the destination
43
+ formatter. The adapter formatter returns the output object; the destination
44
+ encoder turns that object into the final JSON payload.
45
+
46
+ ## Design
47
+
48
+ Semantic Logger fits as transport if Julewire owns record mapping.
49
+
50
+ Semantic Logger does not fit as the default formatter unless Julewire accepts
51
+ Semantic Logger's log schema.
52
+
53
+ ## Verified Paths
54
+
55
+ Verified paths:
56
+
57
+ - custom destination object formatter -> Julewire-encoded JSON line through Semantic Logger
58
+ - async Semantic Logger appender -> flush drains queued entries
59
+ - file appender -> writes exact Julewire payload JSON lines
60
+ - multiple appenders -> multi-appender output without Julewire owning transport code
61
+ - health -> appender type, async queue state, file metadata, child appenders,
62
+ transport warnings
63
+
64
+ ## Metric Mapping
65
+
66
+ The adapter owns metric names because queue, file, and appender behavior is
67
+ transport-specific. One practical mapping is:
68
+
69
+ | Health path | Metric name |
70
+ | --- | --- |
71
+ | `counts.*` | `julewire_runtime_total{event}` |
72
+ | `pipeline.counts.*` | `julewire_pipeline_total{event}` |
73
+ | `destinations.*.counts.*` | `julewire_destination_total{destination,event}` |
74
+ | `destinations.*.last_loss.reason` | `julewire_destination_last_loss{destination,reason}` |
75
+ | `destinations.*.transport.counts.*` | `julewire_semantic_logger_transport_total{destination,event}` |
76
+ | `destinations.*.transport.appender.queue_size` | `julewire_semantic_logger_queue_size{destination}` |
77
+ | `destinations.*.transport.appender.max_queue_size` | `julewire_semantic_logger_queue_capacity{destination}` |
78
+ | `destinations.*.transport.warnings.*` | `julewire_semantic_logger_warning{destination,reason}` |
79
+
80
+ Extensions should treat core health paths as inputs, not as a global metrics
81
+ schema.
82
+
83
+ Observed behavior:
84
+
85
+ - Semantic Logger's IO appender appends the final newline, so this adapter strips
86
+ one trailing newline when it receives an already encoded string.
87
+ - Output-shaped severity values such as `"INFO"` should not be treated as
88
+ Semantic Logger levels. The transport maps only known levels and otherwise
89
+ uses `:info`. Julewire `:unknown` maps to Semantic Logger `:fatal`.
90
+ - bounded Semantic Logger async queues block producers when full; the adapter
91
+ reports this as a lifecycle warning.
92
+ - `max_queue_size: -1` is unbounded queueing; the adapter reports that as a
93
+ lifecycle warning.
94
+ - sync multi-appender output writes through all child appenders on the caller
95
+ thread; the adapter reports that as a lifecycle warning when more than one
96
+ appender is configured and `async: false`.
97
+ - `flush(timeout:)`, `close(timeout:)`, and `reopen(timeout:)` accept Julewire's
98
+ lifecycle keyword for the destination contract, but Semantic Logger appenders
99
+ own the actual blocking behavior. The timeout is advisory for this adapter.
100
+ - `after_fork!` delegates to `reopen`, matching Semantic Logger's fork contract:
101
+ async appender queues and worker threads are recreated in the child process.
102
+
103
+ Architecture implication:
104
+
105
+ - using Semantic Logger as a destination transport is clean
106
+ - using Semantic Logger as the record schema is not
107
+ - core formatters should return objects; encoders own final serialization
108
+ - core should not grow async/file/multi-appender transport primitives
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/julewire/semantic_logger/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "julewire-semantic_logger"
7
+ spec.version = Julewire::SemanticLogger::VERSION
8
+ spec.authors = ["Alexander Grebennik"]
9
+ spec.email = ["slbug@users.noreply.github.com", "sl.bug.sl@gmail.com"]
10
+
11
+ spec.summary = "Semantic Logger transport adapter for Julewire."
12
+ spec.description = "Semantic Logger transport adapter for Julewire destination output."
13
+ spec.homepage = "https://github.com/slbug/julewire"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.4"
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/slbug/julewire/tree/main/gems/semantic_logger"
18
+ spec.metadata["changelog_uri"] = "https://github.com/slbug/julewire/blob/main/gems/semantic_logger/CHANGELOG.md"
19
+
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ Dir[
24
+ "CHANGELOG.md",
25
+ "LICENSE.txt",
26
+ "README.md",
27
+ "docs/**/*.md",
28
+ "julewire-semantic_logger.gemspec",
29
+ "lib/**/*.rb"
30
+ ]
31
+ end
32
+ spec.executables = []
33
+ spec.require_paths = ["lib"]
34
+
35
+ spec.add_dependency "julewire-core", ">= 1.0"
36
+ spec.add_dependency "logger", ">= 1.7"
37
+ spec.add_dependency "semantic_logger", ">= 4.18"
38
+ spec.add_dependency "zeitwerk", ">= 2.8.1"
39
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module SemanticLogger
5
+ module AppenderHealth
6
+ class << self
7
+ def call(value, index: nil)
8
+ {
9
+ appender_class: value.class.name,
10
+ index: index,
11
+ type: appender_type(value)
12
+ }.compact
13
+ .merge(async_health(value))
14
+ .merge(file_health(value))
15
+ .merge(collection_health(value))
16
+ end
17
+
18
+ def appender_type(value)
19
+ case value
20
+ when ::SemanticLogger::Appender::Async
21
+ "async"
22
+ when ::SemanticLogger::Appenders
23
+ "multi_appender"
24
+ when ::SemanticLogger::Appender::File
25
+ "file"
26
+ when ::SemanticLogger::Appender::IO
27
+ "io"
28
+ else
29
+ "appender"
30
+ end
31
+ end
32
+
33
+ def async_health(value)
34
+ return {} unless value.is_a?(::SemanticLogger::Appender::Async)
35
+
36
+ {
37
+ active: value.active?,
38
+ capped: value.capped?,
39
+ max_queue_size: value.max_queue_size,
40
+ queue_size: value.queue.size,
41
+ wrapped: call(value.appender)
42
+ }
43
+ end
44
+
45
+ def file_health(value)
46
+ return {} unless value.is_a?(::SemanticLogger::Appender::File)
47
+
48
+ {
49
+ current_file_name: value.current_file_name,
50
+ file_name: value.file_name,
51
+ log_count: value.log_count,
52
+ log_size: value.log_size,
53
+ reopen_at: value.reopen_at&.utc&.iso8601
54
+ }
55
+ end
56
+
57
+ def collection_health(value)
58
+ return {} unless value.is_a?(::SemanticLogger::Appenders)
59
+
60
+ {
61
+ appender_count: value.length,
62
+ appenders: value.each_with_index.map { |child, index| call(child, index: index) }
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module SemanticLogger
5
+ class Destination
6
+ attr_reader :name
7
+
8
+ def initialize(name:, formatter:, encoder: ENCODER, transport: nil, on_drop: nil, on_failure: nil,
9
+ **transport_options)
10
+ @name = Core::Destinations.normalize_name(name)
11
+ @formatter = formatter
12
+ @encoder = encoder
13
+ @on_drop = validate_optional_callback(on_drop, name: :on_drop)
14
+ @on_failure = validate_optional_callback(on_failure, name: :on_failure)
15
+ @transport = transport || Transport.new(**transport_options)
16
+ @health = Core::Integration::DestinationHealth.new(
17
+ counter_keys: %i[received formatted written failed],
18
+ failure_counter: :failed
19
+ )
20
+ end
21
+
22
+ def emit(record)
23
+ formatted = false
24
+ increment(:received)
25
+ payload = @formatter.call(record)
26
+ formatted = true
27
+ @transport.write(encoded_payload(payload), severity: record.fetch(:severity))
28
+ record_written
29
+ nil
30
+ rescue StandardError => e
31
+ record_failure(e, formatted: formatted, record: record)
32
+ nil
33
+ end
34
+
35
+ def flush(*) = call_lifecycle(:flush) { @transport.flush }
36
+
37
+ def close(*) = call_lifecycle(:close) { @transport.close }
38
+
39
+ def reopen(*) = call_lifecycle(:reopen) { @transport.reopen }
40
+
41
+ def after_fork! = call_lifecycle(:after_fork) { @transport.after_fork! }
42
+
43
+ def resource_identity = @transport
44
+
45
+ def health
46
+ transport = @transport.health
47
+ @health.snapshot(
48
+ status: status(@health.degraded?, transport),
49
+ type: "semantic_logger_destination",
50
+ transport: transport
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def validate_optional_callback(callback, name:)
57
+ return unless callback
58
+ return callback if callback.respond_to?(:call)
59
+
60
+ raise ArgumentError, "#{name} must respond to #call"
61
+ end
62
+
63
+ def encoded_payload(payload)
64
+ return payload if payload.is_a?(String)
65
+
66
+ @encoder.call(payload)
67
+ end
68
+
69
+ def increment(name)
70
+ @health.increment(name)
71
+ end
72
+
73
+ def record_written
74
+ @health.increment(:formatted)
75
+ @health.increment(:written)
76
+ @health.clear_degraded!
77
+ end
78
+
79
+ def record_failure(error, formatted: false, record: nil)
80
+ @health.increment(:formatted) if formatted
81
+ @health.record_failure(error, destination: name, phase: :destination, record_metadata: record_metadata(record))
82
+ notify_failure(error, phase: :destination, record_metadata: record_metadata(record))
83
+ record_drop(:destination_exception, record)
84
+ end
85
+
86
+ def record_lifecycle_failure(error, action:)
87
+ @health.record_failure(error, destination: name, action: action, phase: :destination_lifecycle)
88
+ notify_failure(error, action: action, phase: :destination_lifecycle)
89
+ end
90
+
91
+ def call_lifecycle(action)
92
+ yield
93
+ clear_degraded
94
+ true
95
+ rescue StandardError => e
96
+ record_lifecycle_failure(e, action: action)
97
+ false
98
+ end
99
+
100
+ def notify_failure(error, **metadata)
101
+ Core::Diagnostics::CallbackNotifier.call(@on_failure, error, { destination: name }.merge(metadata))
102
+ end
103
+
104
+ def record_drop(reason, record)
105
+ Core::Diagnostics::CallbackNotifier.call(
106
+ @on_drop,
107
+ reason,
108
+ {
109
+ destination: name,
110
+ phase: :destination,
111
+ reason: reason,
112
+ record_metadata: record_metadata(record)
113
+ }
114
+ )
115
+ end
116
+
117
+ def record_metadata(record)
118
+ Core::Records::Metadata.call(record) if record
119
+ end
120
+
121
+ def status(currently_degraded, transport)
122
+ transport_status = transport[:status]
123
+ return :closed if transport_status == :closed
124
+ return :degraded if currently_degraded
125
+ return :degraded if transport_status && transport_status != :ok
126
+
127
+ :ok
128
+ end
129
+
130
+ def clear_degraded
131
+ @health.clear_degraded!
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module SemanticLogger
5
+ class ExactFormatter
6
+ PAYLOAD_KEY = :julewire_value
7
+
8
+ def call(log, _logger = nil)
9
+ value = log.payload.fetch(PAYLOAD_KEY)
10
+ return string_value(value) if value.is_a?(String)
11
+
12
+ ENCODER.call(value)
13
+ end
14
+
15
+ private
16
+
17
+ def string_value(value)
18
+ return value.delete_suffix("\n") if value.end_with?("\n")
19
+ return value.dup if value.frozen?
20
+
21
+ value
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module SemanticLogger
5
+ module LifecycleWarnings
6
+ class << self
7
+ def call(async:, appender_count:, max_queue_size:)
8
+ if async && max_queue_size == -1
9
+ [{ reason: :async_queue_unbounded }]
10
+ elsif async
11
+ [{ reason: :async_queue_blocks_when_full, max_queue_size: max_queue_size }]
12
+ elsif appender_count > 1
13
+ [{ reason: :sync_multi_appender_blocks_emitters, appender_count: appender_count }]
14
+ else
15
+ []
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Julewire
6
+ module SemanticLogger
7
+ class Transport
8
+ LOGGER_NAME = "julewire"
9
+ DEFAULT_MAX_QUEUE_SIZE = 10_000
10
+ LEVEL_MAP = {
11
+ # SemanticLogger has no unknown level; fatal keeps unknown core records visible.
12
+ unknown: :fatal
13
+ }.freeze
14
+ LEVEL_SET = ::SemanticLogger::LEVELS.to_h { [it, true] }.freeze
15
+
16
+ def initialize(**options)
17
+ @mutex = Mutex.new
18
+ @async = options.delete(:async) { false }
19
+ @max_queue_size = options.delete(:max_queue_size) { DEFAULT_MAX_QUEUE_SIZE }
20
+ @lag_check_interval = options.delete(:lag_check_interval) { 1_000 }
21
+ @lag_threshold_s = options.delete(:lag_threshold_s) { 30 }
22
+ @write_count = 0
23
+ @failure_count = 0
24
+ @degraded = false
25
+ @closed = false
26
+ @appenders = build_appenders(
27
+ appenders: options.delete(:appenders),
28
+ appender: options.delete(:appender),
29
+ file_name: options.delete(:file_name),
30
+ io: options.delete(:io),
31
+ options: options
32
+ )
33
+ @sink = build_sink(@appenders)
34
+ @appender = build_transport_appender(@sink)
35
+ end
36
+
37
+ def write(value, severity:)
38
+ log = log_for(value, severity: severity)
39
+ @mutex.synchronize do
40
+ @write_count += 1
41
+ # Synchronous appenders write under our mutex; async appenders own
42
+ # queue synchronization and may block on bounded queues.
43
+ appender.log(log) unless @async
44
+ end
45
+ appender.log(log) if @async
46
+ clear_degraded
47
+ nil
48
+ rescue StandardError
49
+ @mutex.synchronize do
50
+ @failure_count += 1
51
+ @degraded = true
52
+ end
53
+ raise
54
+ end
55
+
56
+ def flush
57
+ appender.flush if appender.respond_to?(:flush)
58
+ clear_degraded
59
+ nil
60
+ end
61
+
62
+ def close
63
+ appender.close if appender.respond_to?(:close)
64
+ @mutex.synchronize { @closed = true }
65
+ nil
66
+ end
67
+
68
+ def reopen
69
+ appender.reopen if appender.respond_to?(:reopen)
70
+ @mutex.synchronize do
71
+ @closed = false
72
+ @degraded = false
73
+ end
74
+ nil
75
+ end
76
+
77
+ def after_fork! = reopen
78
+
79
+ def health
80
+ counts = @mutex.synchronize do
81
+ {
82
+ closed: @closed,
83
+ degraded: @degraded,
84
+ failures: @failure_count,
85
+ writes: @write_count
86
+ }
87
+ end
88
+
89
+ {
90
+ type: "semantic_logger",
91
+ status: status(counts),
92
+ async: @async,
93
+ warnings: lifecycle_warnings,
94
+ counts: {
95
+ writes: counts.fetch(:writes),
96
+ failures: counts.fetch(:failures)
97
+ },
98
+ appender: appender_health(appender),
99
+ appenders: @appenders.each_with_index.map { |child, index| appender_health(child, index: index) }
100
+ }
101
+ end
102
+
103
+ private
104
+
105
+ attr_reader :appender
106
+
107
+ def build_appenders(appenders:, appender:, file_name:, io:, options:)
108
+ specs = appenders.is_a?(Hash) ? [appenders] : Array(appenders).dup
109
+ specs << { appender: appender } if appender
110
+ specs << { file_name: file_name } if file_name
111
+ specs << { io: io } if io
112
+ raise ArgumentError, "semantic logger transport requires io, file_name, appender, or appenders" if specs.empty?
113
+
114
+ specs.map { build_appender(it, defaults: options) }
115
+ end
116
+
117
+ def build_appender(spec, defaults:)
118
+ options = normalize_appender_spec(spec, defaults: defaults)
119
+ options[:formatter] ||= ExactFormatter.new
120
+ ::SemanticLogger::Appender.factory(**options, async: false, batch: false)
121
+ end
122
+
123
+ def normalize_appender_spec(spec, defaults:)
124
+ case spec
125
+ when Hash
126
+ defaults.merge(spec)
127
+ else
128
+ defaults.merge(appender: spec)
129
+ end
130
+ end
131
+
132
+ def build_sink(appenders)
133
+ return appenders.first if appenders.one?
134
+
135
+ ::SemanticLogger::Appenders.new.tap do |collection|
136
+ appenders.each { collection << it }
137
+ end
138
+ end
139
+
140
+ def build_transport_appender(sink)
141
+ return sink unless @async
142
+
143
+ ::SemanticLogger::Appender::Async.new(
144
+ appender: sink,
145
+ lag_check_interval: @lag_check_interval,
146
+ lag_threshold_s: @lag_threshold_s,
147
+ max_queue_size: @max_queue_size
148
+ )
149
+ end
150
+
151
+ def log_for(value, severity:)
152
+ log = ::SemanticLogger::Log.new(LOGGER_NAME, level_for(severity))
153
+ log.assign(payload: { ExactFormatter::PAYLOAD_KEY => value })
154
+ log
155
+ end
156
+
157
+ def level_for(severity)
158
+ semantic_level(severity)
159
+ end
160
+
161
+ def semantic_level(value)
162
+ level = value.is_a?(Symbol) ? value : value.to_s.downcase.to_sym
163
+ level = LEVEL_MAP.fetch(level, level)
164
+ return level if LEVEL_SET.key?(level)
165
+
166
+ :info
167
+ end
168
+
169
+ def status(counts)
170
+ return :closed if counts.fetch(:closed)
171
+ return :degraded if appender.is_a?(::SemanticLogger::Appender::Async) && !appender.active?
172
+ return :degraded if counts.fetch(:degraded)
173
+
174
+ :ok
175
+ end
176
+
177
+ def clear_degraded
178
+ @mutex.synchronize { @degraded = false }
179
+ end
180
+
181
+ def lifecycle_warnings
182
+ LifecycleWarnings.call(async: @async, appender_count: @appenders.length, max_queue_size: @max_queue_size)
183
+ end
184
+
185
+ def appender_health(value, index: nil)
186
+ AppenderHealth.call(value, index: index)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module SemanticLogger
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "julewire/core"
5
+ require "semantic_logger"
6
+
7
+ module Julewire
8
+ module SemanticLogger
9
+ ENCODER = Julewire::JsonEncoder.new(append_newline: false).freeze
10
+ private_constant :ENCODER
11
+ end
12
+
13
+ loader = Zeitwerk::Loader.for_gem_extension(self)
14
+ loader.setup
15
+ Core::Destinations.register(:semantic_logger) do |name:, **options|
16
+ Julewire::SemanticLogger::Destination.new(name: name, **options)
17
+ end
18
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "julewire/semantic_logger"
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: julewire-semantic_logger
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexander Grebennik
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: julewire-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: logger
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.7'
40
+ - !ruby/object:Gem::Dependency
41
+ name: semantic_logger
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '4.18'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '4.18'
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 2.8.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 2.8.1
68
+ description: Semantic Logger transport adapter for Julewire destination output.
69
+ email:
70
+ - slbug@users.noreply.github.com
71
+ - sl.bug.sl@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.md
77
+ - LICENSE.txt
78
+ - README.md
79
+ - docs/advanced-configuration.md
80
+ - docs/configuration.md
81
+ - docs/health.md
82
+ - docs/transport.md
83
+ - julewire-semantic_logger.gemspec
84
+ - lib/julewire-semantic_logger.rb
85
+ - lib/julewire/semantic_logger.rb
86
+ - lib/julewire/semantic_logger/appender_health.rb
87
+ - lib/julewire/semantic_logger/destination.rb
88
+ - lib/julewire/semantic_logger/exact_formatter.rb
89
+ - lib/julewire/semantic_logger/lifecycle_warnings.rb
90
+ - lib/julewire/semantic_logger/transport.rb
91
+ - lib/julewire/semantic_logger/version.rb
92
+ homepage: https://github.com/slbug/julewire
93
+ licenses:
94
+ - MIT
95
+ metadata:
96
+ homepage_uri: https://github.com/slbug/julewire
97
+ source_code_uri: https://github.com/slbug/julewire/tree/main/gems/semantic_logger
98
+ changelog_uri: https://github.com/slbug/julewire/blob/main/gems/semantic_logger/CHANGELOG.md
99
+ rubygems_mfa_required: 'true'
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '3.4'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 4.0.14
115
+ specification_version: 4
116
+ summary: Semantic Logger transport adapter for Julewire.
117
+ test_files: []