Skip to content

Commit e871d89

Browse files
committed
more tests and fixes for log formatting
1 parent 3e8e8e2 commit e871d89

File tree

11 files changed

+2630
-35
lines changed

11 files changed

+2630
-35
lines changed

.cspell/project-words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ metaprogramming
4343
mswin
4444
nilability
4545
nilable
46+
nonblock
4647
optparse
4748
parseable
4849
pentester

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ gem "rails", "~> 8.0.2.1", require: false
1919
gem "bigdecimal"
2020
gem "drb" # For ActiveSupport::TestCase
2121
gem "mutex_m"
22+
gem "openssl" # For tapioca SSL certificate verification
2223
gem "ostruct" # For Ruby 3.5+ compatibility
2324

2425
# Sorbet is needed for development

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ GEM
221221
nokogiri (1.18.3-x86_64-linux-musl)
222222
racc (~> 1.4)
223223
observer (0.1.2)
224+
openssl (3.3.2)
224225
ostruct (0.6.1)
225226
parallel (1.26.3)
226227
parser (3.3.7.1)
@@ -467,6 +468,7 @@ DEPENDENCIES
467468
minitest (~> 5.20)
468469
minitest-reporters (~> 1.6)
469470
mutex_m
471+
openssl
470472
ostruct
471473
rails (~> 8.0.2.1)
472474
redcarpet

lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33

44
require "active_support/tagged_logging"
55

6-
# Monkey-patch ActiveSupport::TaggedLogging::Formatter to support hash inputs
7-
# This allows us to pass structured data to the logger and have tags incorporated
8-
# directly into the hash instead of being prepended as strings
6+
# Monkey-patch ActiveSupport::TaggedLogging::Formatter to work with structured logging.
7+
#
8+
# Problem: Rails' TaggedLogging prepends tags as text and converts messages to strings.
9+
# When we pass a hash to super(), Rails does "#{tags_text}#{msg}" which calls .to_s
10+
# on the hash, producing Ruby inspect format {message: "..."} instead of valid JSON.
11+
#
12+
# Solution: When LogStruct is enabled, we add tags as a hash entry and delegate to
13+
# LogStruct::Formatter for proper JSON serialization with filtering and standard fields.
914
module ActiveSupport
1015
module TaggedLogging
1116
extend T::Sig
1217

1318
# Add class-level current_tags method for compatibility with Rails code
14-
# that expects to call ActiveSupport::TaggedLogging.current_tags
15-
# Use thread-local storage directly like Rails does internally
1619
sig { returns(T::Array[T.any(String, Symbol)]) }
1720
def self.current_tags
1821
Thread.current[:activesupport_tagged_logging_tags] || []
@@ -23,39 +26,33 @@ module FormatterExtension
2326
extend T::Helpers
2427
requires_ancestor { ::ActiveSupport::TaggedLogging::Formatter }
2528

26-
# Override the call method to support hash input/output, and wrap
27-
# plain strings in a Hash under a `msg` key.
28-
# The data is then passed to our custom log formatter that transforms it
29-
# into a JSON string before logging.
30-
#
31-
# IMPORTANT: This only applies when LogStruct is enabled. When disabled,
32-
# we preserve the original Rails logging behavior to avoid wrapping
33-
# messages in hashes (which would break default Rails log formatting).
3429
sig { params(severity: T.any(String, Symbol), time: Time, progname: T.untyped, data: T.untyped).returns(String) }
3530
def call(severity, time, progname, data)
36-
# Skip hash wrapping when LogStruct is disabled to preserve default Rails behavior
31+
# Preserve original Rails behavior when LogStruct is disabled
3732
return super unless ::LogStruct.enabled?
3833

39-
# Convert data to a hash if it's not already one
40-
data = if data.is_a?(Hash)
41-
data
42-
elsif data.is_a?(::LogStruct::Log::Interfaces::CommonFields)
43-
# LogStruct structs have a serialize method that returns a proper hash
44-
data.serialize
45-
elsif data.is_a?(T::Struct)
46-
# Other T::Struct types can be serialized to hash
47-
data.serialize
34+
# Get current tags
35+
tags = T.unsafe(self).respond_to?(:current_tags) ? current_tags : (Thread.current[:activesupport_tagged_logging_tags] || [])
36+
37+
# Add tags to data as hash entry (not text prefix)
38+
data_with_tags = if data.is_a?(Hash)
39+
tags.present? ? data.merge(tags: tags) : data
40+
elsif data.is_a?(::LogStruct::Log::Interfaces::CommonFields) || (data.is_a?(T::Struct) && data.respond_to?(:serialize))
41+
hash = T.unsafe(data).serialize
42+
tags.present? ? hash.merge(tags: tags) : hash
4843
else
49-
{message: data.to_s}
44+
tags.present? ? {message: data.to_s, tags: tags} : {message: data.to_s}
5045
end
5146

52-
# Add current tags to the hash if present
53-
# Use thread-local storage directly as fallback if current_tags method doesn't exist
54-
tags = T.unsafe(self).respond_to?(:current_tags) ? current_tags : (Thread.current[:activesupport_tagged_logging_tags] || [])
55-
data[:tags] = tags if tags.present?
47+
# Delegate to LogStruct::Formatter for JSON serialization with filtering
48+
logstruct_formatter.call(severity, time, progname, data_with_tags)
49+
end
50+
51+
private
5652

57-
# Call the original formatter with our enhanced data
58-
super
53+
sig { returns(::LogStruct::Formatter) }
54+
def logstruct_formatter
55+
@logstruct_formatter ||= T.let(::LogStruct::Formatter.new, T.nilable(::LogStruct::Formatter))
5956
end
6057
end
6158
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "test_helper"
5+
6+
# Test that ActiveModelSerializers logs are properly formatted as JSON,
7+
# NOT as Ruby hash inspect with tag prefixes like:
8+
# [active_model_serializers] {message: "Rendered ...", tags: ["active_model_serializers"]}
9+
class AmsLoggingTest < ActionDispatch::IntegrationTest
10+
def setup
11+
@log_output = StringIO.new
12+
::SemanticLogger.clear_appenders!
13+
::SemanticLogger.add_appender(io: @log_output, formatter: LogStruct::SemanticLogger::Formatter.new, async: false)
14+
end
15+
16+
# This test reproduces the production bug where AMS logs look like:
17+
# [active_model_serializers] {message: "Rendered SubmissionSerializer...", tags: ["active_model_serializers"]}
18+
def test_ams_logs_are_not_ruby_hash_inspect_format
19+
skip "AMS not available" unless defined?(::ActiveModelSerializers)
20+
21+
# Trigger an AMS serialization by rendering a serialized response
22+
# (Need to add an endpoint that uses AMS)
23+
get "/logging/basic"
24+
25+
::SemanticLogger.flush
26+
@log_output.rewind
27+
output = @log_output.read.to_s
28+
29+
# Capture stdout too since AMS might log there
30+
stdout_output = ""
31+
original_stdout = $stdout
32+
begin
33+
stdout_capture = StringIO.new
34+
$stdout = stdout_capture
35+
36+
get "/logging/basic"
37+
38+
::SemanticLogger.flush
39+
$stdout = original_stdout
40+
stdout_capture.rewind
41+
stdout_output = stdout_capture.read.to_s
42+
ensure
43+
$stdout = original_stdout
44+
end
45+
46+
combined_output = output + stdout_output
47+
48+
# MUST NOT have the broken [tag] {message: ...} format
49+
refute_match(/\[active_model_serializers\]\s*\{message:/,
50+
combined_output,
51+
"AMS logs must NOT be in broken [tag] {message: ...} format")
52+
53+
# MUST NOT have Ruby hash inspect format
54+
refute_match(/\{message:\s*"Rendered/,
55+
combined_output,
56+
"AMS logs must NOT use Ruby hash inspect format")
57+
end
58+
59+
def test_ams_logs_do_not_pollute_with_default_format
60+
skip "AMS not available" unless defined?(::ActiveModelSerializers)
61+
62+
# Capture both SemanticLogger output and stdout
63+
original_stdout = $stdout
64+
stdout_capture = StringIO.new
65+
$stdout = stdout_capture
66+
67+
get "/logging/basic"
68+
69+
::SemanticLogger.flush
70+
$stdout = original_stdout
71+
stdout_capture.rewind
72+
stdout_output = stdout_capture.read.to_s
73+
74+
# AMS default logs like "Rendered X with Y (Zms)" should NOT appear in stdout
75+
refute_match(/Rendered\s+\S+Serializer\s+with/,
76+
stdout_output,
77+
"AMS default log format should not appear in stdout")
78+
end
79+
end
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# typed: true
2+
3+
require "test_helper"
4+
require "open3"
5+
require "timeout"
6+
require "net/http"
7+
8+
class RequestLoggingE2eTest < ActiveSupport::TestCase
9+
# Test that real HTTP requests through Puma produce valid JSON request logs
10+
def test_real_http_request_produces_json_request_log
11+
port = 32125
12+
env = {
13+
"LOGSTRUCT_ENABLED" => "true",
14+
"RAILS_ENV" => "test",
15+
"RAILS_LOG_TO_STDOUT" => "1"
16+
}
17+
18+
cmd = ["bundle", "exec", "rails", "server", "-p", port.to_s]
19+
20+
Open3.popen3(env, *cmd) do |_stdin, stdout, stderr, wait_thr|
21+
lines = []
22+
begin
23+
# Wait for server to be ready
24+
Timeout.timeout(15) do
25+
while (line = stdout.gets)
26+
lines << line.strip
27+
break if line.include?("Use Ctrl-C to stop") || line.include?("Listening on")
28+
end
29+
end
30+
31+
# Make a real HTTP request to a controller action (not proc) to trigger Lograge
32+
uri = URI("http://127.0.0.1:#{port}/logging/basic")
33+
response = Net::HTTP.get_response(uri)
34+
35+
assert_equal "200", response.code, "Request should succeed"
36+
37+
# Give logs time to flush
38+
sleep 0.5
39+
40+
# Collect any additional output non-blocking
41+
loop do
42+
line = stdout.read_nonblock(4096)
43+
lines.concat(line.split("\n").map(&:strip))
44+
rescue IO::WaitReadable, EOFError
45+
break
46+
end
47+
rescue Timeout::Error
48+
flunk "Server did not start in time. Output: #{lines.join("\n")}\nSTDERR: #{stderr.read}"
49+
ensure
50+
begin
51+
Process.kill("TERM", wait_thr.pid)
52+
rescue Errno::ESRCH
53+
# already dead
54+
end
55+
56+
# Collect shutdown output
57+
begin
58+
Timeout.timeout(5) do
59+
while (line = stdout.gets)
60+
lines << line.strip
61+
end
62+
end
63+
rescue Timeout::Error
64+
# ignore
65+
end
66+
end
67+
68+
output = lines.join("\n")
69+
stderr_output = stderr.read
70+
71+
# Parse all JSON log lines
72+
json_lines = lines.filter_map do |l|
73+
JSON.parse(l) if l.strip.start_with?("{")
74+
rescue JSON::ParserError
75+
nil
76+
end
77+
78+
# Find request logs (Lograge output has evt:"request")
79+
request_logs = json_lines.select { |h| h["evt"] == "request" }
80+
81+
assert_predicate request_logs,
82+
:any?,
83+
"Expected at least one request log.\nJSON logs: #{json_lines.map(&:inspect).join("\n")}\nAll output: #{output}\nSTDERR: #{stderr_output}"
84+
85+
request_log = request_logs.first
86+
87+
# Verify it's valid JSON with proper LogStruct fields
88+
assert_equal "rails", request_log["src"], "Source must be 'rails'"
89+
assert_equal "request", request_log["evt"], "Event must be 'request'"
90+
assert request_log.key?("path"), "Must have 'path' field"
91+
assert request_log.key?("status"), "Must have 'status' field"
92+
assert request_log.key?("duration_ms"), "Must have 'duration_ms' field"
93+
94+
# MUST NOT be Ruby hash inspect format
95+
request_line = lines.find { |l| l.include?('"evt":"request"') }
96+
97+
assert request_line, "Should find request log line"
98+
refute_match(/\{message:/, request_line, "Request log must NOT be Ruby hash inspect format")
99+
refute_match(/\{:message=>/, request_line, "Request log must NOT be Ruby hash rocket format")
100+
end
101+
end
102+
103+
# Test that AMS-style tagged logging doesn't produce broken format
104+
def test_tagged_logging_does_not_produce_ruby_hash_format
105+
port = 32126
106+
env = {
107+
"LOGSTRUCT_ENABLED" => "true",
108+
"RAILS_ENV" => "test",
109+
"RAILS_LOG_TO_STDOUT" => "1"
110+
}
111+
112+
cmd = ["bundle", "exec", "rails", "server", "-p", port.to_s]
113+
114+
Open3.popen3(env, *cmd) do |_stdin, stdout, stderr, wait_thr|
115+
lines = []
116+
begin
117+
# Wait for server to be ready
118+
Timeout.timeout(15) do
119+
while (line = stdout.gets)
120+
lines << line.strip
121+
break if line.include?("Use Ctrl-C to stop") || line.include?("Listening on")
122+
end
123+
end
124+
125+
# Make a real HTTP request to a controller action to trigger logging
126+
uri = URI("http://127.0.0.1:#{port}/logging/basic")
127+
Net::HTTP.get_response(uri)
128+
129+
# Give logs time to flush
130+
sleep 0.5
131+
132+
# Collect any additional output non-blocking
133+
loop do
134+
line = stdout.read_nonblock(4096)
135+
lines.concat(line.split("\n").map(&:strip))
136+
rescue IO::WaitReadable, EOFError
137+
break
138+
end
139+
rescue Timeout::Error
140+
flunk "Server did not start in time. Output: #{lines.join("\n")}\nSTDERR: #{stderr.read}"
141+
ensure
142+
begin
143+
Process.kill("TERM", wait_thr.pid)
144+
rescue Errno::ESRCH
145+
# already dead
146+
end
147+
148+
begin
149+
Timeout.timeout(5) do
150+
while (line = stdout.gets)
151+
lines << line.strip
152+
end
153+
end
154+
rescue Timeout::Error
155+
# ignore
156+
end
157+
end
158+
159+
# NO line should have the broken [tag] {message: ...} format
160+
lines.each do |line|
161+
refute_match(
162+
/\[[\w_]+\]\s*\{message:/,
163+
line,
164+
"No log line should have broken [tag] {message: ...} format. Line: #{line}"
165+
)
166+
refute_match(
167+
/\{message:.*tags:/,
168+
line,
169+
"No log line should have {message: ..., tags: ...} Ruby format. Line: #{line}"
170+
)
171+
end
172+
173+
# All JSON lines should be valid JSON (not Ruby hash inspect)
174+
json_lines = lines.select { |l| l.strip.start_with?("{") }
175+
json_lines.each do |line|
176+
parsed = JSON.parse(line)
177+
178+
assert parsed, "Line should be valid JSON: #{line}"
179+
rescue JSON::ParserError => e
180+
flunk "Line should be valid JSON but got parse error: #{e.message}\nLine: #{line}"
181+
end
182+
end
183+
end
184+
end

0 commit comments

Comments
 (0)