Skip to content

Commit d524060

Browse files
committed
Added support for broadcast_to
1 parent 493aa0c commit d524060

File tree

3 files changed

+207
-15
lines changed

3 files changed

+207
-15
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module LogStruct
5+
module SemanticLogger
6+
module Concerns
7+
module LogMethods
8+
extend T::Sig
9+
extend T::Helpers
10+
requires_ancestor { LogStruct::SemanticLogger::Logger }
11+
12+
# Override log methods to handle LogStruct types and broadcast
13+
sig { params(message: T.untyped, payload: T.untyped, block: T.nilable(T.proc.returns(String))).returns(T::Boolean) }
14+
def debug(message = nil, payload = nil, &block)
15+
result = if message.is_a?(LogStruct::Log::Interfaces::CommonFields) || message.is_a?(T::Struct) || message.is_a?(Hash)
16+
super(nil, payload: message, &block)
17+
else
18+
super
19+
end
20+
broadcasts.each do |logger|
21+
next unless logger.respond_to?(:debug)
22+
message.is_a?(String) ? logger.debug(message) : (logger.debug(&block) if block)
23+
end
24+
result
25+
end
26+
27+
sig { params(message: T.untyped, payload: T.untyped, block: T.nilable(T.proc.returns(String))).returns(T::Boolean) }
28+
def info(message = nil, payload = nil, &block)
29+
result = if message.is_a?(LogStruct::Log::Interfaces::CommonFields) || message.is_a?(T::Struct) || message.is_a?(Hash)
30+
super(nil, payload: message, &block)
31+
else
32+
super
33+
end
34+
broadcasts.each do |logger|
35+
next unless logger.respond_to?(:info)
36+
message.is_a?(String) ? logger.info(message) : (logger.info(&block) if block)
37+
end
38+
result
39+
end
40+
41+
sig { params(message: T.untyped, payload: T.untyped, block: T.nilable(T.proc.returns(String))).returns(T::Boolean) }
42+
def warn(message = nil, payload = nil, &block)
43+
result = if message.is_a?(LogStruct::Log::Interfaces::CommonFields) || message.is_a?(T::Struct) || message.is_a?(Hash)
44+
super(nil, payload: message, &block)
45+
else
46+
super
47+
end
48+
broadcasts.each do |logger|
49+
next unless logger.respond_to?(:warn)
50+
message.is_a?(String) ? logger.warn(message) : (logger.warn(&block) if block)
51+
end
52+
result
53+
end
54+
55+
sig { params(message: T.untyped, payload: T.untyped, block: T.nilable(T.proc.returns(String))).returns(T::Boolean) }
56+
def error(message = nil, payload = nil, &block)
57+
result = if message.is_a?(LogStruct::Log::Interfaces::CommonFields) || message.is_a?(T::Struct) || message.is_a?(Hash)
58+
super(nil, payload: message, &block)
59+
else
60+
super
61+
end
62+
broadcasts.each do |logger|
63+
next unless logger.respond_to?(:error)
64+
message.is_a?(String) ? logger.error(message) : (logger.error(&block) if block)
65+
end
66+
result
67+
end
68+
69+
sig { params(message: T.untyped, payload: T.untyped, block: T.nilable(T.proc.returns(String))).returns(T::Boolean) }
70+
def fatal(message = nil, payload = nil, &block)
71+
result = if message.is_a?(LogStruct::Log::Interfaces::CommonFields) || message.is_a?(T::Struct) || message.is_a?(Hash)
72+
super(nil, payload: message, &block)
73+
else
74+
super
75+
end
76+
broadcasts.each do |logger|
77+
next unless logger.respond_to?(:fatal)
78+
message.is_a?(String) ? logger.fatal(message) : (logger.fatal(&block) if block)
79+
end
80+
result
81+
end
82+
end
83+
end
84+
end
85+
end

lib/log_struct/semantic_logger/logger.rb

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# frozen_string_literal: true
33

44
require "semantic_logger"
5+
require_relative "concerns/log_methods"
56

67
module LogStruct
78
module SemanticLogger
@@ -69,25 +70,31 @@ class Logger < ::SemanticLogger::Logger
6970
def initialize(name = "Application", level: nil, filter: nil)
7071
# SemanticLogger::Logger expects positional arguments, not named arguments
7172
super(name, level, filter)
73+
# T.untyped because users can pass any logger: ::Logger, ActiveSupport::Logger,
74+
# custom loggers (FakeLogger in tests), or third-party loggers
75+
@broadcasts = T.let([], T::Array[T.untyped])
7276
end
7377

74-
# Override log methods to handle LogStruct types
75-
%i[debug info warn error fatal].each do |level|
76-
define_method(level) do |message = nil, payload = nil, &block|
77-
# If message is a LogStruct type, use it as payload
78-
if message.is_a?(LogStruct::Log::Interfaces::CommonFields) ||
79-
message.is_a?(T::Struct) ||
80-
message.is_a?(Hash)
81-
payload = message
82-
message = nil
83-
super(message, payload: payload, &block)
84-
else
85-
# For plain string messages, pass them through normally
86-
super(message, payload, &block)
87-
end
88-
end
78+
# ActiveSupport::BroadcastLogger compatibility
79+
# These methods allow Rails.logger to broadcast to multiple loggers
80+
sig { returns(T::Array[T.untyped]) }
81+
attr_reader :broadcasts
82+
83+
# T.untyped for logger param because we accept any logger-like object:
84+
# ::Logger, ActiveSupport::Logger, test doubles, etc.
85+
sig { params(logger: T.untyped).returns(T.untyped) }
86+
def broadcast_to(logger)
87+
@broadcasts << logger
88+
logger
89+
end
90+
91+
sig { params(logger: T.untyped).void }
92+
def stop_broadcasting_to(logger)
93+
@broadcasts.delete(logger)
8994
end
9095

96+
include Concerns::LogMethods
97+
9198
# Support for tagged logging
9299
sig { params(tags: T.untyped, block: T.proc.returns(T.untyped)).returns(T.untyped) }
93100
def tagged(*tags, &block)
@@ -124,6 +131,14 @@ def push_tags(*tags)
124131
def pop_tags(count = 1)
125132
::SemanticLogger.pop_tags(count)
126133
end
134+
135+
# Support for << operator (used by RailsLogSplitter)
136+
sig { params(msg: String).returns(T.self_type) }
137+
def <<(msg)
138+
info(msg)
139+
@broadcasts.each { |logger| logger << msg if logger.respond_to?(:<<) }
140+
self
141+
end
127142
end
128143
end
129144
end

test/log_struct/semantic_logger_test.rb

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,98 @@ class SemanticLoggerTest < ActiveSupport::TestCase
120120
assert_respond_to @logger, :push_tags
121121
assert_respond_to @logger, :pop_tags
122122
assert_respond_to @logger, :clear_tags!
123+
# ActiveSupport::BroadcastLogger compatibility
124+
assert_respond_to @logger, :broadcast_to
125+
assert_respond_to @logger, :stop_broadcasting_to
126+
assert_respond_to @logger, :broadcasts
127+
assert_respond_to @logger, :<<
128+
end
129+
130+
test "supports broadcast_to for multiple loggers" do
131+
# Create a secondary logger to broadcast to
132+
secondary_io = StringIO.new
133+
secondary_logger = Logger.new(secondary_io)
134+
135+
# Add secondary logger to broadcasts
136+
@logger.broadcast_to(secondary_logger)
137+
138+
# Log a message
139+
@logger.info("Test broadcast message")
140+
::SemanticLogger.flush
141+
142+
# Check main logger output
143+
main_output = @io.string
144+
145+
assert_includes main_output, "Test broadcast message"
146+
147+
# Check secondary logger output
148+
secondary_output = secondary_io.string
149+
150+
assert_includes secondary_output, "Test broadcast message"
151+
end
152+
153+
test "stop_broadcasting_to removes logger from broadcasts" do
154+
secondary_io = StringIO.new
155+
secondary_logger = Logger.new(secondary_io)
156+
157+
@logger.broadcast_to(secondary_logger)
158+
@logger.stop_broadcasting_to(secondary_logger)
159+
160+
@logger.info("After removal")
161+
::SemanticLogger.flush
162+
163+
# Main logger should have output
164+
assert_includes @io.string, "After removal"
165+
166+
# Secondary logger should NOT have output
167+
assert_empty secondary_io.string
168+
end
169+
170+
test "broadcasts array returns all broadcast loggers" do
171+
logger1 = Logger.new(StringIO.new)
172+
logger2 = Logger.new(StringIO.new)
173+
174+
@logger.broadcast_to(logger1)
175+
@logger.broadcast_to(logger2)
176+
177+
assert_equal 2, @logger.broadcasts.length
178+
assert_includes @logger.broadcasts, logger1
179+
assert_includes @logger.broadcasts, logger2
180+
end
181+
182+
test "supports << operator for appending messages" do
183+
secondary_io = StringIO.new
184+
secondary_logger = Logger.new(secondary_io)
185+
186+
@logger.broadcast_to(secondary_logger)
187+
@logger << "Appended message"
188+
::SemanticLogger.flush
189+
190+
# Check main logger output
191+
assert_includes @io.string, "Appended message"
192+
193+
# Check secondary logger output
194+
assert_includes secondary_io.string, "Appended message"
195+
end
196+
197+
test "broadcast works with different log levels" do
198+
secondary_io = StringIO.new
199+
secondary_logger = Logger.new(secondary_io)
200+
201+
@logger.broadcast_to(secondary_logger)
202+
203+
@logger.debug("Debug message")
204+
@logger.info("Info message")
205+
@logger.warn("Warn message")
206+
@logger.error("Error message")
207+
::SemanticLogger.flush
208+
209+
secondary_output = secondary_io.string
210+
211+
assert_includes secondary_output, "Debug message"
212+
assert_includes secondary_output, "Info message"
213+
assert_includes secondary_output, "Warn message"
214+
assert_includes secondary_output, "Error message"
123215
end
124216

125217
test "handles errors gracefully" do

0 commit comments

Comments
 (0)