diff --git a/spec/honeybadger/event_spec.cr b/spec/honeybadger/event_spec.cr new file mode 100644 index 0000000..32dca7f --- /dev/null +++ b/spec/honeybadger/event_spec.cr @@ -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 diff --git a/src/honeybadger.cr b/src/honeybadger.cr index 0b64672..920330f 100644 --- a/src/honeybadger.cr +++ b/src/honeybadger.cr @@ -30,4 +30,14 @@ module Honeybadger Dispatch.send payload, synchronous: synchronous end + + private EVENT_DISPATCH = EventDispatch.new + + def self.event(**properties) + send Event.new(**properties) + end + + def self.send(event : Event) + EVENT_DISPATCH.send event + end end diff --git a/src/honeybadger/api.cr b/src/honeybadger/api.cr index 622f5d5..6044c9b 100644 --- a/src/honeybadger/api.cr +++ b/src/honeybadger/api.cr @@ -15,8 +15,8 @@ module Honeybadger end # Sends a payload to the exception reporting api endpoint. - def send(payload) - Response.new request("v1/notices", payload) + def send(payload, to path : String = "v1/notices") + Response.new request(path, payload) end # :nodoc: diff --git a/src/honeybadger/event.cr b/src/honeybadger/event.cr new file mode 100644 index 0000000..28e499f --- /dev/null +++ b/src/honeybadger/event.cr @@ -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 diff --git a/src/honeybadger/event_dispatch.cr b/src/honeybadger/event_dispatch.cr new file mode 100644 index 0000000..7acd762 --- /dev/null +++ b/src/honeybadger/event_dispatch.cr @@ -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