-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for events for Insights (#37)
* Add event support * Flush event buffer at least once per 60 seconds Using a static timeout per event just resets the timer every time an event is emitted, which can continue until the buffer fills up. * Allow passing hashes to the Event constructor * Add support for deleting properties * Don't send an empty payload to the API * Fix spec name Co-authored-by: Rob <[email protected]> --------- Co-authored-by: Rob <[email protected]>
- Loading branch information
Showing
5 changed files
with
305 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
require "../spec_helper" | ||
|
||
record ExampleEventProperty, id : Int32 | ||
|
||
module Honeybadger | ||
describe Event do | ||
it "sets a timestamp" do | ||
event = Event.new | ||
|
||
event.timestamp.should be_a Time | ||
event.to_json.should contain %{"ts":"#{event.timestamp.to_rfc3339(fraction_digits: 3)}"} | ||
end | ||
|
||
it "can have its timestamp set explicitly" do | ||
ts = 1.second.ago | ||
|
||
Event.new(ts).timestamp.should eq ts | ||
end | ||
|
||
it "can have properties set" do | ||
event = Event.new | ||
|
||
event["name"] = "foo" | ||
|
||
event.to_json.should contain %{"name":"foo"} | ||
end | ||
|
||
it "can have properties set at instantiation time" do | ||
event = Event.new(name: "foo") | ||
|
||
event.to_json.should contain %{"name":"foo"} | ||
end | ||
|
||
it "converts array properties" do | ||
event = Event.new(ids: [1, 2, 3]) | ||
|
||
event.to_json.should contain %{"ids":[1,2,3]} | ||
end | ||
|
||
it "converts nested array properties" do | ||
event = Event.new(matrix: [ | ||
[1, 2, 3], | ||
[4, 5, 6], | ||
[7, 8, 9], | ||
]) | ||
|
||
event.to_json.should contain %{"matrix":[[1,2,3],[4,5,6],[7,8,9]]} | ||
end | ||
|
||
it "converts hash properties" do | ||
event = Event.new(user: {"id" => 1}) | ||
|
||
event.to_json.should contain %{"user":{"id":1}} | ||
end | ||
|
||
it "converts nested hash properties" do | ||
event = Event.new(order: {"address" => {"zip" => "12345"}}) | ||
|
||
event.to_json.should contain %{"order":{"address":{"zip":"12345"}}} | ||
end | ||
|
||
it "converts URIs" do | ||
event = Event.new(url: URI.parse("https://example.com")) | ||
|
||
event.to_json.should contain %{"url":"https://example.com"} | ||
end | ||
|
||
it "converts arbitrary objects into their json representation" do | ||
event = Event.new( | ||
property: ExampleEventProperty.new(id: 123), | ||
) | ||
|
||
serialized = event.to_json | ||
|
||
serialized.should contain %{"property":"ExampleEventProperty(@id=123)"} | ||
end | ||
|
||
it "can delete properties from the event" do | ||
event = Event.new(one: 1, two: "two") | ||
event["one"]?.should eq 1 | ||
|
||
event.delete "one" | ||
event["one"]?.should eq nil | ||
end | ||
|
||
it "merges two events together" do | ||
first = Event.new(id: 123, name: "first") | ||
second = Event.new(name: "second") | ||
|
||
data = first.merge(second).to_json | ||
|
||
# Uses the first event's timestamp | ||
data.should contain %{"ts":"#{first.timestamp.to_rfc3339(fraction_digits: 3)}"} | ||
data.should contain %{"id":123} | ||
# The second event's non-timestamp properties override that of the first | ||
data.should contain %{"name":"second"} | ||
end | ||
|
||
it "can take hashes in the constructor" do | ||
event = Event.new({"foo" => "bar"}) | ||
|
||
event["foo"].should eq "bar" | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
require "json" | ||
require "uri" | ||
|
||
module Honeybadger | ||
struct Event | ||
include JSON::Serializable | ||
include JSON::Serializable::Unmapped | ||
|
||
@[JSON::Field(key: "ts", converter: Honeybadger::Event::RFC3339Converter)] | ||
getter timestamp : Time | ||
|
||
def self.new(**properties) | ||
new Time.utc, properties | ||
end | ||
|
||
def self.new(properties : Hash) | ||
new Time.utc, properties | ||
end | ||
|
||
def initialize(@timestamp, properties = NamedTuple.new) | ||
properties.each do |key, value| | ||
self[key.to_s] = value | ||
end | ||
end | ||
|
||
def []=(key : String, value) | ||
json_unmapped[key] = coerce(value) | ||
end | ||
|
||
delegate :[], :[]?, delete, to: json_unmapped | ||
|
||
def merge(other : self) : self | ||
event = self.class.new(timestamp: timestamp) | ||
|
||
json_unmapped.each { |key, value| event[key] = value } | ||
other.json_unmapped.each { |key, value| event[key] = value } | ||
|
||
event | ||
end | ||
|
||
private def coerce(value : JSON::Any) | ||
value | ||
end | ||
|
||
private def coerce(value : JSON::Any::Type) | ||
JSON::Any.new value | ||
end | ||
|
||
private def coerce(value : Array) | ||
coerce value.map { |item| coerce item } | ||
end | ||
|
||
private def coerce(value : Hash) | ||
coerce value.transform_values { |item| coerce item } | ||
end | ||
|
||
private def coerce(value : URI) | ||
coerce value.to_s | ||
end | ||
|
||
# 8-, 16-, and 32-bit integers have to be a special case because the type | ||
# checker doesn't know whether to upcast them to Int64 or Float64 for the | ||
# JSON::Any::Type case. | ||
private def coerce(value : Int) | ||
coerce value.to_i64 | ||
end | ||
|
||
private def coerce(value) | ||
# String#inspect_unquoted escapes unprintable characters | ||
coerce value.to_s.inspect_unquoted | ||
end | ||
|
||
module RFC3339Converter | ||
extend self | ||
|
||
def to_json(timestamp : Time, json : JSON::Builder) | ||
json.string do |io| | ||
timestamp.to_rfc3339 fraction_digits: 3, io: io | ||
end | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
require "./event" | ||
|
||
module Honeybadger | ||
class EventDispatch | ||
# Allow for a spike of events | ||
@channel = Channel(Event).new(1 << 16) | ||
@buffer = IO::Memory.new | ||
|
||
getter? running = false | ||
|
||
def send(event : Event) | ||
ensure_running | ||
|
||
select | ||
when @channel.send event | ||
else # if the buffer is full, we just skip | ||
end | ||
end | ||
|
||
private def ensure_running | ||
sync { start } | ||
end | ||
|
||
private def start | ||
return if running? | ||
|
||
@running = true | ||
spawn run | ||
|
||
at_exit do | ||
# Give the consumer fiber a chance to buffer any further events | ||
Fiber.yield | ||
send @buffer.to_s | ||
end | ||
end | ||
|
||
private def run | ||
next_event = nil | ||
|
||
# Main run loop | ||
# 1. Buffer messages until either 60 seconds have passed or the buffer | ||
# exceeds 5MB | ||
# 2. Flush the buffer to the Honeybadger API | ||
# 3. Clear the buffer | ||
loop do | ||
buffering = true | ||
wait_time = 60.seconds | ||
|
||
while buffering | ||
# If the previous iteration exceeded the buffer cap, we flushed it and | ||
# now we need to place it into the fresh buffer | ||
if next_event | ||
@buffer.puts next_event | ||
next_event = nil | ||
end | ||
|
||
started_waiting = Time.monotonic | ||
select | ||
when event = @channel.receive | ||
# Limits for events endpoint: https://docs.honeybadger.io/api/reporting-events/#limits | ||
|
||
json = event.to_json | ||
# Max event size is 100KB | ||
if json.bytesize <= 100 * 1024 | ||
# Maximum payload size is 5MB | ||
if @buffer.bytesize + json.bytesize < 5 * 1024 * 1024 | ||
@buffer.puts json | ||
next_event = nil | ||
else | ||
next_event = json | ||
buffering = false | ||
end | ||
end | ||
|
||
wait_time -= Time.monotonic - started_waiting | ||
when timeout(wait_time) | ||
buffering = false | ||
next_event = nil | ||
end | ||
end | ||
|
||
unless @buffer.empty? | ||
# This must be computed outside of the spawned fiber so that we don't | ||
# clear it in the ensure block below before it's been sent. | ||
payload = @buffer.to_s | ||
spawn send payload | ||
end | ||
ensure | ||
@buffer.clear | ||
end | ||
end | ||
|
||
@mutex = Mutex.new | ||
|
||
def sync | ||
@mutex.synchronize { yield } | ||
end | ||
|
||
def send(payload : String) : Nil | ||
if Honeybadger.report_data? | ||
Api.new.send(payload, to: "v1/events") | ||
end | ||
end | ||
end | ||
end |