Skip to content

Commit

Permalink
Add support for events for Insights (#37)
Browse files Browse the repository at this point in the history
* 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
jgaskins and robacarp authored Jan 8, 2025
1 parent 863a105 commit fc28a2f
Show file tree
Hide file tree
Showing 5 changed files with 305 additions and 2 deletions.
105 changes: 105 additions & 0 deletions spec/honeybadger/event_spec.cr
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
10 changes: 10 additions & 0 deletions src/honeybadger.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions src/honeybadger/api.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions src/honeybadger/event.cr
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
105 changes: 105 additions & 0 deletions src/honeybadger/event_dispatch.cr
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

0 comments on commit fc28a2f

Please sign in to comment.