legion-logging 1.5.5 → 1.5.19

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd5e1bffbdc433aedb24653ea515080063cb456de0c5a0fd5a57cdbff91d371d
4
- data.tar.gz: 0b015f1b0c5d12511f231bfa32325e2ebcc41ca902b92d8293bdcf6359d8a7ac
3
+ metadata.gz: '037790df316dd01a4f090a1d02a4b07fab65feac2d0c70a501d949d57fd45276'
4
+ data.tar.gz: af6fda1d1af5fe004fe8e4b76c225aa997f4075aed19b8d557f4090cec9e7770
5
5
  SHA512:
6
- metadata.gz: 0bad8e3a31fb86de2b1a2749457b506e7042606e7e060813a0adcaf81f2d0afdf8998e3ba57692fabe5814ab7efcb3dcadfb9c8e985a77b6f3c4e44aedcccf35
7
- data.tar.gz: 83475ff76aae03a25c24e1b464695f68ef6b4514309e6290485e513066f3d9f4ff9367e2b87e087f89dd1f1b6b3fafa4176ca5fcae226224859356d23189a80f
6
+ metadata.gz: 659f1952bc1c331c74528153276b9a593eb1d775c67fbd57797434e78f1f016612ca2a2e0322ab6ad041f7101efede0a94e803c388dedf96608727431b5c303c
7
+ data.tar.gz: 9f4150366ac4f64da3b1c14a429058820de2cb639dd810974f6448a5ca78fa7e32d5c13f23100eb0d21802143091df232b0ffd89263313f00b7df1355341b547
data/CHANGELOG.md CHANGED
@@ -1,5 +1,99 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.5.19] - 2026-10-29
4
+
5
+ ### Changed
6
+ - Document the complete async contract: bounded backpressure, producer snapshots, worker-side preparation, lifecycle deadlines, reentrant logging, and explicit synchronous boundaries (#22).
7
+ - Correct writer callback signatures and describe delivery acknowledgement limits, retained Shipper retries, and raw Logger compatibility (#22).
8
+
9
+ ## [1.5.18] - 2026-10-29
10
+
11
+ ### Fixed
12
+ - Preserve producer-side raw Logger admission and message/progname normalization: accepted `with_level` records are not filtered again, and a block's nil result reaches the formatter unchanged (#22).
13
+ - Pass captured producer time to raw Logger formatters instead of the later worker dispatch time (#22).
14
+
15
+ ## [1.5.17] - 2026-10-29
16
+
17
+ ### Fixed
18
+ - Make async regression cleanup nil-safe so setup failures are not replaced by secondary errors while releasing worker gates (#22; PR #23 review feedback).
19
+
20
+ ## [1.5.16] - 2026-10-29
21
+
22
+ ### Fixed
23
+ - Preserve producer timestamps in text/JSON output and producer thread IDs in JSON, including queued standard Logger calls (#22).
24
+ - Snapshot mutable raw Logger messages and context before enqueueing so later caller mutation cannot change accepted output.
25
+
26
+ ## [1.5.15] - 2026-10-29
27
+
28
+ ### Changed
29
+ - Move normal log and both exception APIs' formatting, redaction, event enrichment, fingerprinting, and envelope preparation onto the async worker (#22).
30
+ - Capture producer call sites, timestamps, thread/session context, and shallow input snapshots before enqueueing; preserve component-level overrides during worker output.
31
+ - Honor configurable forwarding levels at `logging.transport.log_levels`, retaining warn/error/fatal by default and avoiding duplicate synchronous event construction.
32
+
33
+ ### Fixed
34
+ - Avoid implicitly loading Settings from logging and shipping; early redaction defaults no longer hide custom patterns loaded later.
35
+
36
+ ## [1.5.14] - 2026-10-29
37
+
38
+ ### Fixed
39
+ - Serialize logger-owned lifecycle transitions so concurrent startup cannot orphan a writer and discard its accepted backlog (#22).
40
+ - Reject contending lifecycle calls without blocking callbacks behind their own shutdown join.
41
+ - Retry retained shipping batches on a subsequent shutdown call after a previous delivery failure.
42
+
43
+ ## [1.5.13] - 2026-10-29
44
+
45
+ ### Fixed
46
+ - Keep startup and shutdown draining on the writer, including accepted work without a running consumer; apply shutdown deadlines without synchronous fallback (#22).
47
+ - Retain timed-out workers and queues, reject writes after shutdown, and avoid joining the writer from its own callback.
48
+ - Stop the existing writer before reconfiguring outputs; a failed shutdown leaves the configuration intact instead of opening a synchronous bypass.
49
+
50
+ ## [1.5.12] - 2026-10-29
51
+
52
+ ### Fixed
53
+ - Route opt-in MethodTracer output through configured async logging instead of direct producer-thread stdout writes (#22).
54
+ - Keep failed shipping diagnostics out of the forwarding pipeline to avoid logging-to-shipper feedback loops, and add explicit HTTP write timeouts.
55
+ - Clarify that direct transport/export APIs, MultiIO, and hook dispatch are synchronous primitives; their async callers own scheduling.
56
+
57
+ ## [1.5.11] - 2026-10-29
58
+
59
+ ### Fixed
60
+ - Move threshold-triggered and explicit Shipper delivery onto one background worker; `flush(timeout:)` waits for delivery without performing sink IO on its caller (#22).
61
+ - Bound accepted and in-flight shipping events with `logging.shipper.buffer_size` (default 10,000), preserve failed batches for retry, and wake the worker for flush/shutdown instead of sleeping through requests.
62
+
63
+ ## [1.5.10] - 2026-10-29
64
+
65
+ ### Fixed
66
+ - Prevent writer callbacks and hooks from waiting on their own full queue: nested messages are written locally on the writer without recursively forwarding them (#22).
67
+ - Preserve nested exception events as local JSON lines and bypass recursive formatting when necessary, without growing an overflow queue or dropping nested messages.
68
+
69
+ ## [1.5.9] - 2026-10-29
70
+
71
+ ### Fixed
72
+ - Keep the standard Ruby Logger interface returned by `log`, including `add`, `log`, and raw append, on the configured async writer; preserve producer context for Sinatra/APM consumers (#22).
73
+ - Route instance tracing through async logging and raise `ClosedQueueError` for rejected async writes instead of silently performing producer-thread IO.
74
+
75
+ ## [1.5.8] - 2026-10-29
76
+
77
+ ### Fixed
78
+ - Queue structured exception publication for both `log_exception` and Helper `handle_exception`, keeping exception-writer callbacks off producer threads when async logging is enabled (#22).
79
+ - Preserve captured exception metadata and callback envelopes while retaining synchronous publication for explicit synchronous logging.
80
+
81
+ ## [1.5.7] - 2026-10-29
82
+
83
+ ### Changed
84
+ - Document bounded, lossless-in-process queue backpressure as the intentional async saturation policy: producers wait rather than silently dropping logs or growing the queue without a bound (#22).
85
+ - Correct async fatal routing and the buffer-size configuration example.
86
+
87
+ ## [1.5.6] - 2026-06-01
88
+
89
+ ### Added
90
+ - `task_id:`, `conv_id:`, `request_id:` optional kwargs on all log level methods (`.debug`, `.info`, `.warn`, `.error`, `.fatal`, `.unknown`) in both `Methods` and `TaggedLogger`
91
+ - Text formatter appends context ID (`conv_id` or `task_id` fallback) as `{conv_12345}` after the method segment in the prefix
92
+ - Text formatter inserts `request_id=<value>` between severity and message when present
93
+ - JSON formatter includes `conversation_id` and `request_id` fields when present
94
+ - `AsyncWriter::LogEntry` carries `conv_id` and `request_id` for correct propagation to the writer thread
95
+ - Thread-local `legion_log_conv_id` and `legion_log_request_id` for block-scoped context without per-call kwargs
96
+
3
97
  ## [1.5.5] - 2026-05-27
4
98
 
5
99
  ### Fixed
data/CLAUDE.md CHANGED
@@ -2,45 +2,53 @@
2
2
 
3
3
  Structured logging framework for LegionIO. Provides colorized console output (Rainbow), structured JSON logging, and a consistent interface across all Legion gems and extensions.
4
4
 
5
- **GitHub**: https://github.com/LegionIO/legion-logging
6
- **Version**: 1.5.3
7
-
8
5
  ## Architecture
9
6
 
10
7
  ```
11
8
  Legion::Logging (singleton module)
12
9
  ├── Methods # debug, info, warn, error, fatal, unknown; log_exception
13
10
  ├── Builder # Output destination, log level, formatter, async: keyword
14
- ├── AsyncWriter # Non-blocking SizedQueue-backed writer thread
11
+ ├── AsyncWriter # Bounded, backpressured SizedQueue-backed writer thread
12
+ ├── OutputLogger # Queues standard Logger add/log/<< calls
15
13
  ├── Hooks # Callback registry for fatal/error/warn events
16
14
  ├── EventBuilder # Structured event payload + fingerprint for dedup
17
15
  ├── Helper # Injectable log mixin for LEX extensions
18
16
  ├── TaggedLogger # Prepends structured tags to each message
19
17
  ├── CategoryRegistry # Named log categories with expected_fields
20
18
  ├── SIEMExporter # PHI-redacting SIEM export (Splunk HEC, ELK)
21
- ├── Shipper # Buffered forwarding (FileTransport, HttpTransport)
19
+ ├── Shipper # Bounded batch-delivery worker (file/HTTP transports)
22
20
  ├── Redactor # PII/PHI + secret pattern redaction (opt-in)
23
- └── MultiIO # Write to multiple destinations simultaneously
21
+ └── MultiIO # Synchronous, sequential writes to multiple destinations
24
22
 
25
23
  # Module-level writer lambdas (pluggable forwarding slots)
26
- Legion::Logging.log_writer # ->(event, routing_key:) {}
24
+ Legion::Logging.log_writer # ->(event, routing_key:, headers:, properties:) {}
27
25
  Legion::Logging.exception_writer # ->(event, routing_key:, headers:, properties:) {}
28
26
  ```
29
27
 
30
28
  ## Key Patterns
31
29
 
32
30
  - **Singleton module** — `class << self`; called directly: `Legion::Logging.info("msg")`
33
- - **Async by default** — `setup` enables async logging; fatal bypasses queue. Buffer size via `Settings[:logging][:async][:buffer_size]` (default 10,000). Back-pressure blocks callers when full.
31
+ - **Async by default** — `setup` queues all levels, including fatal, plus structured exception publication and raw Logger calls. Buffer size via `Settings[:logging][:async][:buffer_size]` (default 10,000 entries, not bytes). Saturation blocks producers rather than dropping logs or growing the queue without bound.
34
32
  - **Structured JSON** — `format: :json` outputs machine-parseable JSON lines (disables color)
33
+ - **Producer/worker boundary** — Producers evaluate message blocks and capture caller/context plus shallow snapshots. Formatting, event enrichment, fingerprinting, and publication run on the worker. Nested payload objects are not deep-copied. Built-in output retains producer time/thread; raw Logger output retains admission, normalized payload, and formatter time.
34
+ - **Lifecycle** — `stop_async_writer(timeout: 2)` closes admission and drains on the worker; timeout or lifecycle contention returns false. No caller-thread drain or synchronous rejection fallback. Stopped async loggers raise `ClosedQueueError` until restart or explicit `setup(async: false)`. Setup file operations remain synchronous.
35
+ - **Delivery semantics** — Successful shutdown means processing finished, not durable delivery or external acknowledgement. AsyncWriter diagnoses failures locally without retry; Shipper retains failed batches for retry.
35
36
  - **Helper mixin** — `Legion::Logging::Helper` injects into LEX extensions; derives tags from `segments`, `lex_filename`, or class name
36
37
  - **Writer lambdas** — `log_writer` and `exception_writer` are module-level lambda slots for forwarding to external systems (AMQP, etc.). Default no-ops.
37
38
  - **Redactor** — Opt-in (`logging.redaction.enabled: true`). Patterns: SSN, phone, MRN, DOB, Vault tokens, JWTs, bearer tokens, lease IDs. Guards against Settings recursive init.
38
39
  - **Hook callbacks** — `on_fatal`, `on_error`, `on_warn` register procs; gated by `enable_hooks!`/`disable_hooks!`; fire on async writer thread
40
+ - **Reentrant logging** — Callback/hook logs stay local on the worker without recursively forwarding or waiting on its own queue. Nested exception events and recursive formatter diagnostics use local JSON lines.
41
+ - **Forwarding levels** — `logging.transport.log_levels` accepts strings/symbols; absent or empty lists default to warn/error/fatal. Settings guards use defaults while Settings is unloaded rather than initializing it.
42
+ - **Synchronous APIs** — `MultiIO`, direct transport delivery, SIEM export, and direct hook dispatch remain synchronous. Shipper filtering/redaction run on its caller; bounded batch delivery runs on its worker.
39
43
  - **EventBuilder** — Structured event hashes from log context (caller, exception, lex identity). `fingerprint` produces MD5 for dedup.
40
44
 
41
45
  ## Role in LegionIO
42
46
 
43
47
  Foundational gem — dependency of `legion-cache`, `legion-data`, and `LegionIO`. First module initialized during `Legion::Service` startup.
44
48
 
45
- ---
46
- **Maintained By**: Matthew Iverson (@Esity)
49
+ ## Backtrace Limit
50
+
51
+ - `settings[:logging][:backtrace_limit]` — `nil` = full trace, `0` = suppress entirely, `N` = cap at N frames
52
+ - Per-call override: `log_exception(err, backtrace_limit: 5)`
53
+ - Read at runtime via `Legion::Settings.dig(:logging, :backtrace_limit)` after checking Settings is loaded; otherwise use the default without triggering initialization.
54
+
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Logging module for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides colorized console output via Rainbow, structured JSON logging, multi-output IO, and a consistent logging interface across all Legion gems and extensions.
4
4
 
5
- **Version**: 1.5.2
5
+ **Version**: 1.5.19
6
6
 
7
7
  ## Installation
8
8
 
@@ -33,7 +33,7 @@ Legion::Logging.fatal('critical failure')
33
33
 
34
34
  ### Async Logging
35
35
 
36
- By default, `setup` enables async logging — log calls push to a background writer thread and return immediately. Fatal calls always bypass the queue and write synchronously.
36
+ By default, `setup` enables async logging: log calls enqueue output for a background writer, including fatal messages. Async does not mean wait-free. The queue holds at most 10,000 entries by default, and producers wait for capacity when it fills.
37
37
 
38
38
  ```ruby
39
39
  # Async is on by default
@@ -43,10 +43,30 @@ Legion::Logging.setup(level: 'info')
43
43
  Legion::Logging.setup(level: 'info', async: false)
44
44
 
45
45
  # Configure buffer size via Legion::Settings
46
- # Legion::Settings[:logging, :async, :buffer_size] = 20_000
46
+ # Legion::Settings[:logging][:async] = { buffer_size: 20_000 }
47
47
  ```
48
48
 
49
- When the buffer is full, callers block until the writer drains — this preserves log completeness. Use `Legion::Logging.stop_async_writer` during shutdown to flush and stop the writer thread.
49
+ Bounded backpressure is intentional: saturation blocks callers rather than dropping logs or allowing unbounded memory growth. A stalled destination can therefore stall producers once the queue fills. Buffer capacity limits queued entries, not their total byte size, and does not guarantee delivery across process termination.
50
+
51
+ Use `Legion::Logging.stop_async_writer(timeout: 2)` during shutdown. It closes admission and asks the worker to drain accepted entries, returning `true` only after that worker exits. A deadline expiry returns `false` without killing the worker or draining on the caller; release the blocked destination and retry shutdown. Calling shutdown from a writer callback requests closure but returns `false` rather than joining itself.
52
+
53
+ A successful stop means queued entries have been processed, not that a sink durably stored them or an external service acknowledged them. AsyncWriter reports sink/callback failures locally and does not automatically retry them. The Shipper's retained-batch retry behavior is separate.
54
+
55
+ Stopped async loggers reject new writes until explicitly restarted or configured with `setup(async: false)`. Reconfiguration stops the previous worker before replacing its output. `setup(shutdown_timeout: 2, ...)` returns `false` if that drain times out and does not change the existing output; it does not silently enter synchronous mode.
56
+
57
+ Lifecycle transitions are serialized per logger. A competing setup/start/stop returns `false` instead of creating overlapping workers or blocking a callback behind its own shutdown. Retry the control operation after the active transition completes.
58
+
59
+ Configuration is a synchronous control path: opening or closing owned log files can still block in `setup`. Shutdown deadlines bound worker drain/join, not every filesystem operation.
60
+
61
+ Producers evaluate message blocks and capture caller/context plus shallow snapshots of strings, arrays, hashes, and exceptions. The worker performs formatting, redaction, structured-event enrichment, fingerprinting, and envelope construction. Nested payload objects are not deep-copied. Explicit caller tracing and application-provided message/settings methods can still perform producer-side work.
62
+
63
+ Text and JSON timestamps represent enqueue time, not time spent waiting for a destination. JSON thread IDs identify the producer. Standard Logger calls use the same shallow snapshot policy for messages and context; custom raw Logger formatters receive the producer timestamp. The worker preserves producer-side `with_level` admission and already-resolved message/progname values, including a block's nil result.
64
+
65
+ Forwarding defaults to warn/error/fatal. Set `logging.transport.log_levels` to an array of strings or symbols to change which locally emitted levels reach `log_writer` and Hooks; absent or empty lists retain the defaults. Library-owned Settings lookups use defaults while Settings is unloaded rather than triggering initialization.
66
+
67
+ The standard Ruby Logger interface returned by `Legion::Logging.log` also queues output in async mode, including `add`, `log`, and `<<`. Instance tracing uses the same async path. If an async writer is unavailable or rejects an entry during shutdown, logging raises `ClosedQueueError` rather than falling back to synchronous IO. Explicit synchronous mode retains direct output.
68
+
69
+ Logs generated by a writer callback or hook are written locally on that worker rather than queued behind themselves or forwarded recursively. Nested exception events and recursive formatter diagnostics use local JSON lines. This keeps bounded backpressure for producers without allowing the sole consumer to deadlock on its own queue.
50
70
 
51
71
  ### Structured JSON Output
52
72
 
@@ -60,12 +80,26 @@ This is useful for log aggregation pipelines (Elasticsearch, Splunk, etc.).
60
80
 
61
81
  ### Multi-Output IO
62
82
 
63
- `Legion::Logging::MultiIO` writes to multiple destinations simultaneously — for example, stdout and a file at the same time. Used internally by the Builder when `log_file` is set alongside console output.
83
+ `Legion::Logging::MultiIO` writes to destinations sequentially—for example, stdout followed by a file. It is a synchronous IO primitive used by the async writer, not a parallel executor. A slow destination delays that worker and can eventually cause queue backpressure.
84
+
85
+ Opt-in `MethodTracer` output uses the configured logging queue rather than writing directly to the traced application's stdout. Hook dispatch is synchronous on its invoking thread; normal async logging invokes hooks on the writer, not on producers.
86
+
87
+ ### Buffered Shipping
88
+
89
+ Explicitly require `legion/logging/shipper` and enable `logging.shipper.enabled` to use the file or HTTP batch shipper. `Shipper.ship(event)` starts its worker automatically and never performs threshold-triggered delivery on the producer. Filtering and redaction still run on the calling thread; batch delivery runs on the worker. Set `logging.shipper.transport` to `file` or `http`.
90
+
91
+ Defaults: `batch_size: 100`, `flush_interval: 5` seconds, and `buffer_size: 10_000`. The capacity includes in-flight events; producers wait when it is full. Failed batches remain queued in order for retry.
92
+
93
+ `Shipper.flush(timeout: 5)` requests worker delivery and waits up to the supplied deadline; it returns `false` on timeout or delivery failure. `Shipper.stop(timeout: 5)` wakes and joins the worker without moving delivery onto the caller. A timeout retains the worker and accepted events; check the return value before assuming delivery completed. If shutdown exits after a delivery failure, retrying `stop` starts a drainer for the retained batch.
94
+
95
+ `FileTransport.ship_batch` and `HttpTransport.ship_batch` are intentionally synchronous delivery primitives; use `Shipper.ship` for producer-side asynchronous shipping. Sink failures are reported locally to stderr instead of being forwarded back into the failing shipper.
64
96
 
65
97
  ### SIEM Export
66
98
 
67
99
  `Legion::Logging::SIEMExporter` provides PHI-redacting export helpers for security event pipelines:
68
100
 
101
+ `export_to_splunk` is an explicitly synchronous request/response API. Use the HTTP Shipper for asynchronous delivery. Both HTTP implementations configure 5-second connection and 10-second read/write timeouts; these are phase timeouts, not a total request deadline.
102
+
69
103
  ```ruby
70
104
  # Redact PHI patterns (SSN, phone, MRN, DOB) from a string
71
105
  clean = Legion::Logging::SIEMExporter.redact_phi(raw_message)
@@ -96,7 +130,7 @@ end
96
130
 
97
131
  ### Exception Logging
98
132
 
99
- `log_exception` provides a single call for complete exception logging with component context. It writes a human-readable exception line through the configured logger and publishes a structured exception event when an `exception_writer` is configured.
133
+ `log_exception` provides a single call for complete exception logging with component context. It writes a human-readable exception line and publishes a structured exception event when an `exception_writer` is configured. With async logging enabled, both output and exception publication run on the writer thread; Helper `handle_exception` uses the same publication path. Bounded queue backpressure still applies.
100
134
 
101
135
  ```ruby
102
136
  begin
@@ -112,7 +146,7 @@ rescue StandardError => e
112
146
  end
113
147
  ```
114
148
 
115
- The synchronous log line includes the full Ruby backtrace. Legion does not truncate it to a fixed frame count or replace the tail with `... N more`, because the missing frames are often the useful part of production failures.
149
+ The human-readable log line includes the full Ruby backtrace unless `backtrace_limit` is configured. Legion does not replace the tail with `... N more`.
116
150
 
117
151
  Structured exception events include:
118
152
 
@@ -126,15 +160,15 @@ Structured exception events include:
126
160
 
127
161
  ### Writer Lambdas
128
162
 
129
- `log_writer` and `exception_writer` are pluggable lambda slots that replace the old Hooks system. Assign them to forward events to external systems:
163
+ `log_writer` and `exception_writer` are pluggable lambda slots for forwarding events to external systems. They coexist with Hooks; normal async logging invokes both on the writer thread:
130
164
 
131
165
  ```ruby
132
166
  Legion::Logging.exception_writer = lambda do |payload, routing_key:, headers:, properties:|
133
167
  publish_to_amqp(payload, routing_key:, headers:, properties:)
134
168
  end
135
169
 
136
- Legion::Logging.log_writer = lambda do |context, routing_key:|
137
- publish_log(context, routing_key:)
170
+ Legion::Logging.log_writer = lambda do |context, routing_key:, headers:, properties:|
171
+ publish_log(context, routing_key:, headers:, properties:)
138
172
  end
139
173
  ```
140
174
 
@@ -1,15 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require_relative 'methods'
4
5
 
5
6
  module Legion
6
7
  module Logging
7
8
  class AsyncWriter
8
- LogEntry = ::Data.define(:level, :message, :writer_context, :segments, :method_ctx, :caller_trace)
9
- SHUTDOWN = :shutdown
9
+ LogEntry = ::Data.define(:level, :message, :writer_context, :segments, :method_ctx, :caller_trace,
10
+ :conv_id, :request_id, :exchange_id, :chain_id, :force)
11
+ EventEntry = ::Data.define(:callback, :event, :options)
12
+ CallEntry = ::Data.define(:receiver, :method_name, :arguments, :keywords, :context)
13
+ OutputEntry = ::Data.define(:severity, :message, :progname, :segments, :method_ctx, :caller_trace,
14
+ :conv_id, :request_id, :exchange_id, :chain_id, :origin)
15
+ THREAD_KEYS = %i[
16
+ legion_log_segments legion_log_method legion_log_caller
17
+ legion_log_conv_id legion_log_request_id legion_log_exchange_id legion_log_chain_id
18
+ ].freeze
19
+ CALL_THREAD_KEYS = (THREAD_KEYS + %i[legion_context legion_session_context legion_log_origin]).freeze
10
20
 
11
21
  attr_reader :logger
12
22
 
23
+ def self.snapshot_value(value:)
24
+ return value if value.nil? || value.frozen?
25
+ return value unless value.is_a?(String) || value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(Exception)
26
+
27
+ value.dup.freeze
28
+ end
29
+
13
30
  def initialize(logger, buffer_size: 10_000)
14
31
  @logger = logger
15
32
  @buffer_size = buffer_size
@@ -19,36 +36,55 @@ module Legion
19
36
  @accepting = true
20
37
  end
21
38
 
39
+ # rubocop:disable Naming/PredicateMethod
22
40
  def start
23
- return if @thread&.alive?
24
-
25
- @state_mutex.synchronize { @accepting = true }
26
- drain
27
- @queue = SizedQueue.new(@buffer_size)
28
- @thread = Thread.new { consume }
29
- @thread.name = 'legion-log-writer'
30
- @thread.abort_on_exception = false
41
+ @state_mutex.synchronize do
42
+ return @accepting if @thread&.alive?
43
+
44
+ if @queue.closed?
45
+ queue = SizedQueue.new(@buffer_size)
46
+ queue << @queue.pop(true) until @queue.empty?
47
+ @queue = queue
48
+ end
49
+ @accepting = true
50
+ @thread = Thread.new { consume(queue: @state_mutex.synchronize { @queue }) }
51
+ @thread.name = 'legion-log-writer'
52
+ @thread.abort_on_exception = false
53
+ end
54
+ true
31
55
  end
32
56
 
33
- # rubocop:disable Naming/PredicateMethod
34
57
  def stop(timeout: 2)
35
- @state_mutex.synchronize { @accepting = false }
36
-
37
- unless @thread&.alive?
38
- drain
39
- @thread = nil
40
- return true
58
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
59
+ thread = @state_mutex.synchronize do
60
+ @accepting = false
61
+ @queue.close
62
+ if !@thread&.alive? && !@queue.empty?
63
+ @thread = Thread.new { consume(queue: @state_mutex.synchronize { @queue }) }
64
+ @thread.name = 'legion-log-writer'
65
+ @thread.abort_on_exception = false
66
+ end
67
+ @thread
41
68
  end
69
+ return true unless thread
70
+ return false if Thread.current.equal?(thread)
42
71
 
43
- @queue.close
44
- timeout ? @thread.join(timeout) : @thread.join
45
- return false if @thread&.alive?
72
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
73
+ thread.join(remaining)
74
+ return false if thread.alive?
46
75
 
47
- @thread = nil
76
+ @state_mutex.synchronize { @thread = nil if @thread.equal?(thread) }
48
77
  true
49
78
  end
50
79
 
51
80
  def push(entry)
81
+ if current_thread?
82
+ return write_reentrant_entry(entry) unless @preparing
83
+
84
+ write_entry(entry)
85
+ return true
86
+ end
87
+
52
88
  return false unless accepting?
53
89
 
54
90
  @queue.push(entry)
@@ -62,41 +98,101 @@ module Legion
62
98
  @thread&.alive? || false
63
99
  end
64
100
 
101
+ def current_thread?
102
+ Thread.current.equal?(@thread)
103
+ end
104
+
65
105
  private
66
106
 
67
- def consume
107
+ def consume(queue:)
68
108
  loop do
69
- entry = @queue.pop
70
- break if entry.nil? || entry == SHUTDOWN
109
+ entry = queue.pop
110
+ break if entry.nil?
71
111
 
72
112
  write_entry(entry)
73
113
  end
74
114
  end
75
115
 
76
- def write_entry(entry)
77
- prev_segments = Thread.current[:legion_log_segments]
78
- prev_method_ctx = Thread.current[:legion_log_method]
79
- prev_caller = Thread.current[:legion_log_caller]
80
- Thread.current[:legion_log_segments] = entry.segments
81
- Thread.current[:legion_log_method] = entry.method_ctx
82
- Thread.current[:legion_log_caller] = entry.caller_trace
83
- @logger.send(entry.level, entry.message)
84
- fire_writer(entry) if entry.writer_context
116
+ def write_entry(entry, forward: true)
117
+ previous_preparing = @preparing
118
+ @preparing = entry.is_a?(CallEntry)
119
+ if @preparing
120
+ write_call_entry(entry)
121
+ return
122
+ end
123
+
124
+ if entry.is_a?(EventEntry)
125
+ callback_origin = Thread.current[:legion_log_origin]
126
+ Thread.current[:legion_log_origin] = nil
127
+ entry.callback.call(entry.event, **entry.options)
128
+ return
129
+ end
130
+
131
+ with_entry_context(entry) do
132
+ if entry.is_a?(OutputEntry)
133
+ write_output_entry(entry)
134
+ else
135
+ if entry.force
136
+ formatter = @logger.formatter || ::Logger::Formatter.new
137
+ severity = entry.level == :unknown ? 'ANY' : entry.level.to_s.upcase
138
+ @logger << formatter.call(severity, Time.now, nil, entry.message)
139
+ else
140
+ @logger.send(entry.level, entry.message)
141
+ end
142
+ fire_writer(entry) if forward && entry.writer_context
143
+ end
144
+ end
85
145
  rescue StandardError => e
86
146
  warn("legion-log-writer error: #{e.message} (#{e.backtrace&.first})")
87
147
  ensure
88
- Thread.current[:legion_log_segments] = prev_segments
89
- Thread.current[:legion_log_method] = prev_method_ctx
90
- Thread.current[:legion_log_caller] = prev_caller
148
+ @preparing = previous_preparing
149
+ Thread.current[:legion_log_origin] = callback_origin if entry.is_a?(EventEntry)
91
150
  end
92
151
 
93
- def drain
94
- until @queue.empty?
95
- entry = @queue.pop(true)
96
- write_entry(entry) unless entry == SHUTDOWN
152
+ def write_output_entry(entry)
153
+ return @logger << entry.message if entry.severity.nil?
154
+
155
+ severity = @logger.send(:format_severity, entry.severity)
156
+ @logger << @logger.send(:format_message, severity, entry.origin[:timestamp].getlocal, entry.progname, entry.message)
157
+ end
158
+
159
+ def write_reentrant_entry(entry)
160
+ previous = @reentrant_write
161
+ @reentrant_write = true
162
+ if previous || entry.is_a?(EventEntry)
163
+ event = entry.is_a?(EventEntry) ? entry.event : { message: entry.message.to_s }
164
+ @logger << "#{::JSON.generate(event)}\n"
165
+ else
166
+ write_entry(entry, forward: false)
97
167
  end
98
- rescue ThreadError
99
- nil
168
+ true
169
+ ensure
170
+ @reentrant_write = previous
171
+ end
172
+
173
+ def write_call_entry(entry)
174
+ previous = entry.context.map { |key, _| [key, Thread.current[key]] }
175
+ entry.context.each { |key, value| Thread.current[key] = value }
176
+ entry.receiver.public_send(entry.method_name, *entry.arguments, **entry.keywords)
177
+ ensure
178
+ previous&.each { |key, value| Thread.current[key] = value }
179
+ end
180
+
181
+ def with_entry_context(entry)
182
+ prev = THREAD_KEYS.map { |k| [k, Thread.current[k]] }
183
+ previous_origin = Thread.current[:legion_log_origin]
184
+ Thread.current[:legion_log_origin] = entry.origin if entry.is_a?(OutputEntry)
185
+ Thread.current[:legion_log_segments] = entry.segments
186
+ Thread.current[:legion_log_method] = entry.method_ctx
187
+ Thread.current[:legion_log_caller] = entry.caller_trace
188
+ Thread.current[:legion_log_conv_id] = entry.conv_id
189
+ Thread.current[:legion_log_request_id] = entry.request_id
190
+ Thread.current[:legion_log_exchange_id] = entry.exchange_id
191
+ Thread.current[:legion_log_chain_id] = entry.chain_id
192
+ yield
193
+ ensure
194
+ prev&.each { |k, v| Thread.current[k] = v }
195
+ Thread.current[:legion_log_origin] = previous_origin
100
196
  end
101
197
 
102
198
  def accepting?
@@ -104,6 +200,7 @@ module Legion
104
200
  end
105
201
 
106
202
  def fire_writer(entry)
203
+ previous_origin = Thread.current[:legion_log_origin]
107
204
  ctx = entry.writer_context
108
205
  event = ctx[:event]
109
206
  level = ctx[:level]
@@ -112,10 +209,13 @@ module Legion
112
209
  routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
113
210
  headers = Legion::Logging.send(:build_log_headers, event, component, level)
114
211
  properties = Legion::Logging.send(:build_log_properties, level)
212
+ Thread.current[:legion_log_origin] = nil
115
213
  Legion::Logging.log_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
116
214
  Legion::Logging::Hooks.fire(level, entry.message, event) if defined?(Legion::Logging::Hooks)
117
215
  rescue StandardError => e
118
216
  warn("legion-log-writer writer error: #{e.message}")
217
+ ensure
218
+ Thread.current[:legion_log_origin] = previous_origin
119
219
  end
120
220
  end
121
221
  end