# frozen_string_literal: true require "net/http" require "json" require "zlib" module PlainApm class Transport ## # HTTP transport class, mostly a wrapper for errors and timeout-handling. # TODO: tune these. HTTP_READ_TIMEOUT_SECONDS = 8 # default is 60 HTTP_WRITE_TIMEOUT_SECONDS = 8 # default is 60 HTTP_OPEN_TIMEOUT_SECONDS = 8 # default is 60 HTTP_TIMEOUTS = [ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout ].compact.freeze ERRNO_ERRORS = [ Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTDOWN, Errno::EHOSTUNREACH, Errno::EINVAL, Errno::ENETUNREACH, Errno::EPIPE ].freeze ## # @param endpoint [String] http URL to send the event to # @param app_key [String] api key / token identifying this app # @param http_client [Net::HTTP] for dependency injection in tests def initialize(endpoint:, app_key:, http_client: Net::HTTP) @uri = URI.parse(endpoint) @app_key = app_key @http = http_client.new(uri.host, uri.port) # Forgotten /, e.g. https://example.com:3000 uri.path = "/" if uri.path.nil? || uri.path.empty? # TODO: our own CA bundle? http.use_ssl = uri.scheme == "https" http.open_timeout = HTTP_OPEN_TIMEOUT_SECONDS http.read_timeout = HTTP_READ_TIMEOUT_SECONDS http.write_timeout = HTTP_WRITE_TIMEOUT_SECONDS at_exit { shutdown } end ## # Performs the actual HTTP request. # # @param data [String] serialized payload to POST # @return [Array] [response, error, retriable] def deliver(data, meta = {}) http_response do http_request(http, uri.path, data, meta) end end private attr_reader :http, :uri, :app_key ## # Opens the underlying TCP connection. def start http.start unless http.started? end ## # Close the connection at exit. def shutdown http.finish if http.started? end def http_request(http, path, body, meta) request = Net::HTTP::Post.new(path, http_headers(meta)) http.request(request, Zlib::Deflate.deflate(JSON.generate(body))) end def http_headers(meta) meta_headers = meta.map do |k, v| ["X-PlainApm-#{k.to_s.split("_").map(&:capitalize).join("-")}", v.to_s] end.to_h { "Content-Type" => "application/json, charset=UTF-8", "Content-Encoding" => "gzip", "X-PlainApm-Key" => app_key }.merge(meta_headers) end def http_response # Opening the connection here allows us to rescue socket and SSL errors. # It'll be a NO-OP if already connected. start response = yield case response when Net::HTTPSuccess [response, nil, false] when Net::HTTPServerError, Net::HTTPTooManyRequests [response, nil, true] when Net::HTTPBadRequest, Net::HTTPUnauthorized, Net::HTTPRequestEntityTooLarge [response, nil, false] else # Caveat: this includes redirects. [response, nil, false] end rescue *HTTP_TIMEOUTS, *ERRNO_ERRORS, IOError => e # Lowlevel libc6, connection errors. [nil, e, true] rescue Exception => e # standard:disable Lint/RescueException # SSL errors, socket errors, http protocol errors from Net. To avoid the # caller thread loop dying. [nil, e, false] end end end