Skip to content

Commit 387a01b

Browse files
Add default encoders for JSON and MessagePack
Add JSON and MessagePack encoders to ActiveSupport::EventReporter. This allows applications to serialize events to common formats without needing to implement their own serialization logic in subscribers.
1 parent 6e7a35d commit 387a01b

File tree

4 files changed

+269
-25
lines changed

4 files changed

+269
-25
lines changed

activesupport/CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@
2222
Rails.event.set_context(request_id: "abc123", shop_id: 456)
2323
```
2424

25-
Events are emitted to subscribers. Applications register subscribers
26-
to control how events are serialized and emitted.
25+
Events are emitted to subscribers. Applications register subscribers to
26+
control how events are serialized and emitted. Rails provides several default
27+
encoders that can be used to serialize events to common formats:
2728

2829
```ruby
2930
class MySubscriber
3031
def emit(event)
31-
# Serialize event and export to logging platform
32+
encoded_event = ActiveSupport::EventReporter.encoder(:json).encode(event)
33+
StructuredLogExporter.export(encoded_event)
3234
end
3335
end
3436

activesupport/lib/active_support/event_reporter.rb

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# typed: true
22
# frozen_string_literal: true
33

4+
require_relative "event_reporter/encoders"
5+
46
module ActiveSupport
57
class TagStack # :nodoc:
68
EMPTY_TAGS = {}.freeze
@@ -94,38 +96,37 @@ def clear
9496
# # }
9597
#
9698
# These objects should represent schematized events and be serializable.
97-
# There are no restrictions on what interface these objects should implement, or how they should be
98-
# serialized. Subscribers are expected to know how to serialize their events.
9999
#
100-
# For example, here is an event class:
100+
# ==== Default Encoders
101101
#
102-
# class UserCreatedEvent
103-
# def initialize(id:, name:)
104-
# @id = id
105-
# @name = name
106-
# end
102+
# Rails provides default encoders for common serialization formats. Event objects and tags MUST
103+
# implement +to_h+ to be serialized.
107104
#
108-
# def to_h
109-
# {
110-
# id: @id,
111-
# name: @name,
112-
# }
105+
# class JSONLogSubscriber
106+
# def emit(event)
107+
# # event = { name: "UserCreatedEvent", payload: { UserCreatedEvent: #<UserCreatedEvent:0x111> } }
108+
# json_data = ActiveSupport::EventReporter.encoder(:json).encode(event)
109+
# # => {
110+
# # "name": "UserCreatedEvent",
111+
# # "payload": {
112+
# # "id": 123,
113+
# # "name": "John Doe"
114+
# # }
115+
# # }
116+
# Rails.logger.info(json_data)
113117
# end
114118
# end
115119
#
116-
# And a subscriber implementation that uses this event class:
117-
#
118-
# class EventReporterSubscriber
120+
# class MessagePackSubscriber
119121
# def emit(event)
120-
# name = event[:name]
121-
# event_payload = event[:payload].to_h
122-
# # => { id: 123, name: "John Doe" }
123-
# encoded_data = LogEncoder.encode(name, event_payload)
124-
# ExportService.export(encoded_data)
122+
# msgpack_data = ActiveSupport::EventReporter.encoder(:msgpack).encode(event)
123+
# BatchExporter.export(msgpack_data)
125124
# end
126125
# end
127126
#
128-
# You can also use the +debug+ method to report an event that will only be reported if the
127+
# ==== Debug Events
128+
#
129+
# You can use the +debug+ method to report an event that will only be reported if the
129130
# event reporter is in debug mode:
130131
#
131132
# Rails.event.debug("my_debug_event", { foo: "bar" })
@@ -195,6 +196,31 @@ class EventReporter
195196

196197
class << self
197198
attr_accessor :context_store # :nodoc:
199+
200+
# Lookup an encoder by name or symbol
201+
#
202+
# ActiveSupport::EventReporter.encoder(:json)
203+
# # => ActiveSupport::EventReporter::Encoders::JSON
204+
#
205+
# ActiveSupport::EventReporter.encoder("msgpack")
206+
# # => ActiveSupport::EventReporter::Encoders::MessagePack
207+
#
208+
# ==== Arguments
209+
#
210+
# * +format+ - The encoder format as a symbol or string
211+
#
212+
# ==== Raises
213+
#
214+
# * +KeyError+ - If the encoder format is not found
215+
def encoder(format)
216+
encoders = {
217+
json: Encoders::JSON,
218+
msgpack: Encoders::MessagePack
219+
}
220+
encoders.fetch(format.to_sym) do
221+
raise KeyError, "Unknown encoder format: #{format.inspect}. Available formats: #{encoders.keys.join(', ')}"
222+
end
223+
end
198224
end
199225

200226
self.context_store = EventContext
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
module ActiveSupport
5+
class EventReporter
6+
# = Event Encoders
7+
#
8+
# Default encoders for serializing structured events. These encoders can be used
9+
# by subscribers to convert event data into various formats.
10+
#
11+
# Example usage in a subscriber:
12+
#
13+
# class LogSubscriber
14+
# def emit(event)
15+
# encoded_data = ActiveSupport::EventReporter::Encoders::JSON.encode(event)
16+
# Rails.logger.info(encoded_data)
17+
# end
18+
# end
19+
#
20+
module Encoders
21+
# Base encoder class that other encoders can inherit from
22+
class Base
23+
# Encodes an event hash into a serialized format
24+
#
25+
# @param event [Hash] The event hash containing name, payload, tags, context, timestamp, and source_location
26+
# @return [String] The encoded event data
27+
def self.encode(event)
28+
raise NotImplementedError, "Subclasses must implement #encode"
29+
end
30+
end
31+
32+
# JSON encoder for serializing events to JSON format.
33+
#
34+
# event = { name: "user_created", payload: { id: 123 }, tags: { api: true } }
35+
# ActiveSupport::EventReporter::Encoders::JSON.encode(event)
36+
# # => {
37+
# # "name": "user_created",
38+
# # "payload": {
39+
# # "id": 123
40+
# # },
41+
# # "tags": {
42+
# # "api": true
43+
# # },
44+
# # "context": {}
45+
# # }
46+
#
47+
# Schematized events and tags MUST respond to #to_h to be serialized.
48+
#
49+
# event = { name: "UserCreatedEvent", payload: #<UserCreatedEvent:0x111>, tags: { "GraphqlTag": #<GraphqlTag:0x111> } }
50+
# ActiveSupport::EventReporter::Encoders::JSON.encode(event)
51+
# # => {
52+
# # "name": "UserCreatedEvent",
53+
# # "payload": {
54+
# # "id": 123
55+
# # },
56+
# # "tags": {
57+
# # "GraphqlTag": {
58+
# # "operation_name": "user_created",
59+
# # "operation_type": "mutation"
60+
# # }
61+
# # },
62+
# # "context": {}
63+
# # }
64+
65+
class JSON < Base
66+
def self.encode(event)
67+
event[:payload] = event[:payload].to_h
68+
event[:tags] = event[:tags].transform_values do |value|
69+
value.respond_to?(:to_h) ? value.to_h : value
70+
end
71+
::JSON.dump(event)
72+
end
73+
end
74+
75+
class MessagePack < Base
76+
def self.encode(event)
77+
require "msgpack"
78+
event[:payload] = event[:payload].to_h
79+
event[:tags] = event[:tags].transform_values do |value|
80+
value.respond_to?(:to_h) ? value.to_h : value
81+
end
82+
::MessagePack.pack(event)
83+
rescue LoadError
84+
raise LoadError, "msgpack gem is required for MessagePack encoding. Add 'gem \"msgpack\"' to your Gemfile."
85+
end
86+
end
87+
end
88+
end
89+
end

activesupport/test/event_reporter_test.rb

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
require_relative "abstract_unit"
55
require "active_support/event_reporter/test_helper"
6+
require "json"
67

78
module ActiveSupport
89
class EventReporterTest < ActiveSupport::TestCase
@@ -534,4 +535,130 @@ class ContextStoreTest < ActiveSupport::TestCase
534535
end
535536
end
536537
end
538+
539+
class EncodersTest < ActiveSupport::TestCase
540+
TestEvent = Class.new do
541+
class << self
542+
def name
543+
"TestEvent"
544+
end
545+
end
546+
547+
def initialize(data)
548+
@data = data
549+
end
550+
551+
def to_h
552+
{
553+
data: @data
554+
}
555+
end
556+
end
557+
558+
HttpRequestTag = Class.new do
559+
class << self
560+
def name
561+
"HttpRequestTag"
562+
end
563+
end
564+
565+
def initialize(http_method, http_status)
566+
@http_method = http_method
567+
@http_status = http_status
568+
end
569+
570+
def to_h
571+
{
572+
http_method: @http_method,
573+
http_status: @http_status
574+
}
575+
end
576+
end
577+
578+
setup do
579+
@event = {
580+
name: "test_event",
581+
payload: { id: 123, message: "hello" },
582+
tags: { section: "admin" },
583+
context: { user_id: 456 },
584+
timestamp: 1738964843208679035,
585+
source_location: { filepath: "/path/to/file.rb", lineno: 42, label: "test_method" }
586+
}
587+
end
588+
589+
test "looking up encoder by symbol" do
590+
assert_equal EventReporter::Encoders::JSON, EventReporter.encoder(:json)
591+
assert_equal EventReporter::Encoders::MessagePack, EventReporter.encoder(:msgpack)
592+
end
593+
594+
test "looking up encoder by string" do
595+
assert_equal EventReporter::Encoders::JSON, EventReporter.encoder("json")
596+
assert_equal EventReporter::Encoders::MessagePack, EventReporter.encoder("msgpack")
597+
end
598+
599+
test "looking up nonexistant encoder raises KeyError" do
600+
error = assert_raises(KeyError) do
601+
EventReporter.encoder(:unknown)
602+
end
603+
assert_equal "Unknown encoder format: :unknown. Available formats: json, msgpack", error.message
604+
end
605+
606+
test "Base encoder raises NotImplementedError" do
607+
assert_raises(NotImplementedError) do
608+
EventReporter::Encoders::Base.encode(@event)
609+
end
610+
end
611+
612+
test "JSON encoder encodes event to JSON" do
613+
json_string = EventReporter::Encoders::JSON.encode(@event)
614+
parsed = ::JSON.parse(json_string)
615+
616+
assert_equal "test_event", parsed["name"]
617+
assert_equal({ "id" => 123, "message" => "hello" }, parsed["payload"])
618+
assert_equal({ "section" => "admin" }, parsed["tags"])
619+
assert_equal({ "user_id" => 456 }, parsed["context"])
620+
assert_equal 1738964843208679035, parsed["timestamp"]
621+
assert_equal({ "filepath" => "/path/to/file.rb", "lineno" => 42, "label" => "test_method" }, parsed["source_location"])
622+
end
623+
624+
test "JSON encoder serializes event objects and object tags as hashes" do
625+
@event[:payload] = TestEvent.new("value")
626+
@event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) }
627+
json_string = EventReporter::Encoders::JSON.encode(@event)
628+
parsed = ::JSON.parse(json_string)
629+
630+
assert_equal "value", parsed["payload"]["data"]
631+
assert_equal "GET", parsed["tags"]["HttpRequestTag"]["http_method"]
632+
assert_equal 200, parsed["tags"]["HttpRequestTag"]["http_status"]
633+
end
634+
635+
test "MessagePack encoder encodes event to MessagePack" do
636+
begin
637+
require "msgpack"
638+
rescue LoadError
639+
skip "msgpack gem not available"
640+
end
641+
642+
msgpack_data = EventReporter::Encoders::MessagePack.encode(@event)
643+
parsed = ::MessagePack.unpack(msgpack_data)
644+
645+
assert_equal "test_event", parsed["name"]
646+
assert_equal({ "id" => 123, "message" => "hello" }, parsed["payload"])
647+
assert_equal({ "section" => "admin" }, parsed["tags"])
648+
assert_equal({ "user_id" => 456 }, parsed["context"])
649+
assert_equal 1738964843208679035, parsed["timestamp"]
650+
assert_equal({ "filepath" => "/path/to/file.rb", "lineno" => 42, "label" => "test_method" }, parsed["source_location"])
651+
end
652+
653+
test "MessagePack encoder serializes event objects and object tags as hashes" do
654+
@event[:payload] = TestEvent.new("value")
655+
@event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) }
656+
msgpack_data = EventReporter::Encoders::MessagePack.encode(@event)
657+
parsed = ::MessagePack.unpack(msgpack_data)
658+
659+
assert_equal "value", parsed["payload"]["data"]
660+
assert_equal "GET", parsed["tags"]["HttpRequestTag"]["http_method"]
661+
assert_equal 200, parsed["tags"]["HttpRequestTag"]["http_status"]
662+
end
663+
end
537664
end

0 commit comments

Comments
 (0)