Skip to content

Instantly share code, notes, and snippets.

@jdelStrother
Last active July 2, 2024 13:21
Show Gist options
  • Save jdelStrother/c37082b04ee27622dd3715484177e0b5 to your computer and use it in GitHub Desktop.
Save jdelStrother/c37082b04ee27622dd3715484177e0b5 to your computer and use it in GitHub Desktop.
# frozen_string_literal: true
class BeSemanticLog
include RSpec::Matchers::Composable
def initialize(expected)
@expected = expected.is_a?(String) ? { message: expected } : expected
end
def matches?(event)
matches_event?(event, **@expected)
end
def matches_event?(event, level: nil, name: nil, message: nil, message_including: nil,
payload: nil, payload_including: nil, including: nil,
thread_name: nil, tags: nil, named_tags: nil, context: nil,
metric: nil, metric_amount: nil, dimensions: nil)
if !event
@failure = :no_event
return false
end
if message && (event.message != message)
@failure = :bad_message
return false
end
if message_including && !event.message&.include?(message_including)
@failure = :bad_message
return false
end
if name && (event.name != name)
@failure = :bad_name
return false
end
if name && (event.level != level)
@failure = :bad_level
return false
end
if payload_including && event.payload
payload_including.each_pair do |key, expected_value|
value = event.payload[key]
if value != expected_value
@failure = :bad_payload
return false
end
end
elsif payload && event.payload != payload
@failure = :bad_payload
return false
end
# this is pretty handwavey - I wanted a way to make sure we weren't exposing personal data in either the message or payload.
if including
all_content = [event.message, event.payload].compact.to_json
if !all_content.match?(including)
@failure = :bad_payload_or_message
return false
end
end
if thread_name && event.thread_name != thread_name
@failure = :bad_thread
return false
end
if named_tags && event.named_tags != named_tags
@failure = :bad_named_tags
return false
end
if tags && event.tags != tags
@failure = :bad_tags
return false
end
if context && event.context != context
@failure = :bad_context
return false
end
if metric && event.metric != metric
@failure = :bad_metric
return false
end
if metric_amount && event.metric_amount != metric_amount
@failure = :bad_metric_amount
return false
end
if dimensions && event.dimensions != dimensions
@failure = :bad_dimensions
return false
end
true
end
def description = "be a semantic log event matching #{@expected}"
def failure_message
"expected #{description}, but had #{@failure}"
end
def failure_message_when_negated
"expected not to #{description}"
end
end
# expect {
# User.logger.info("hello")
# }.to log_event(message: "hello", on: User)
#
# logs = capture_logs {
# post "/login", params: { username: "bob" }
# }
# expect(logs[0]).to be_semantic_log(message: "Started")
# expect(logs).to include(a_semantic_log(payload_including: { params: { "username" => "bob" } })
#
RSpec::Matchers.define :log_event do |expected|
supports_block_expectations
match do |block|
on_class = expected.delete(:on) if expected.is_a?(Hash)
@actual = capture_logs(on_class) do
block.call
end
@actual.any? { BeSemanticLog.new(expected).matches?(_1) }
end
failure_message do
"Expected logs to match '#{expected.inspect}'. Received:\n #{@actual.map { _1.to_h(nil, nil, nil).inspect }.join("\n")}"
end
end
module SemanticLogMatchers
def capture_logs(on_class = nil)
logger = SemanticLogger::Test::CaptureLogEvents.new
if on_class
allow(on_class).to receive(:logger).and_return(logger)
else
allow(SemanticLogger::Logger).to receive(:processor).and_return(logger)
end
yield
logger.events
ensure
# Is this the best way of unstubbing?
if on_class
allow(on_class).to receive(:logger).and_call_original
else
allow(SemanticLogger::Logger).to receive(:processor).and_call_original
end
end
def a_semantic_log(...)
BeSemanticLog.new(...)
end
alias be_semantic_log a_semantic_log
end
RSpec.configure { _1.include(SemanticLogMatchers) }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment