Streaming POST Requests to Elastic APM in Ruby with http.rb and IO.pipe

To keep the memory footprint of our Elastic APM agents small, the agents stream events as they happen via long-running POST requests to APM Server The server accepts ndjson which is JSON objects separated with newline \n characters. Can’t get simpler than that.

Streaming in Ruby can be tricky — Ruby is synchronous, so with regular HTTP requests, the program will block and hold everything else back until the request is finished. This is of course not what we want in this case, as our app should continue to serve our users while it is sending its events one by one to APM Server.

We instead start the request in a thread and pipe the events from our main program thread to the request thread using Ruby’s built-in IO.pipe The wonderful Rubygem http.rb supports IO objects so we’ll depend on that.

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'http'
end

read_pipe, write_pipe = IO.pipe

# Unfortunately for our case Http.rb calls `rewind` on the IO
# object after the request ends but pipes don't have such a method.
# We'll add an method stub to circumvent this:
read_pipe.define_singleton_method(:rewind) { nil }

# First we make an HTTP client and set the Transfer-Encoding
# header to let the server know that we'll be sending in chunks
client = HTTP.headers({ 'Transfer-Encoding' => 'chunked' }) 

# In a thread we open the request and give it the read pipe
request_thread = Thread.new do
  # This thread is blocked on the request below while the main
  # program thread continues on
  client.post('https://example.com', body: read_pipe).flush
end

# Next let's simulate 10 events with a bit of pause between them
1.upto(10).each do |number|
  data = { number: number }
  write_pipe.write("#{data.to_json}\r\n")
  sleep 0.2
end

# Then when we're done sending our data, we'll close the pipe
# which will also end the request
write_pipe.close

# When the request's body is closed the request thread will reach
# its end. We'll wait for it before we end the script.
request_thread.join

This is a constructed example script of course. In a real-world instance of the Ruby APM agent we have timeouts and byte size limits for the requests that, when reached, ends the current request and starts a new one.

We also have GZip – let’s add that to our example:

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'http'
end

# We'll need the built-in zlib library
require 'zlib'

read_pipe, write_pipe = IO.pipe

read_pipe.define_singleton_method(:rewind) { nil }

# Toggle binary mode for the write pipe
write_pipe.binmode
client = HTTP.headers({
  'Transfer-Encoding' => 'chunked',
  # Add Content-Encoding header to let the server know we are
  # gzip'ing now
  'Content-Encoding' => 'gzip'
}) 
request_thread = Thread.new do
  client.post('https://example.com/', body: read_pipe).flush
end

# Initialize a new GZip pipe and put it in front of our existing
# write pipe
gzip_pipe = Zlib::GzipWriter.new(write_pipe)
(1..10).each do |number|
  data = { number: number }
  # Writing looks the same but uses the gzip pipe instead
  gzip_pipe.write("#{data.to_json}\r\n")
  sleep 0.2
end

# Close the gzip pipe instead
gzip_pipe.close

request_thread.join

There you have it

Streaming requests in Ruby might require a bit more effort than in, say, JavaScript with its evented nature, but this isn’t too bad, is it? Doing this, the agent can release its objects as soon as they are sent. The Ruby APM agent uses this approach from 2.0 and onwards.

Adding Elastic APM to your Ruby app is as easy as ever, requiring as little as adding the gem to your Gemfile. Learn how and get the agent streaming metrics for your app today. As always, if you have any questions or feedback, start a new thread on our APM forum.