lib/firetail.rb in firetail-0.0.0 vs lib/firetail.rb in firetail-0.0.1.pre.alpha
- old
+ new
@@ -1,6 +1,272 @@
require "firetail/version"
+require "rack"
+require 'objspace'
+require 'yaml'
+require 'json'
+require 'net/http'
+require 'case_sensitive_headers' # a hack because firetail API headers is case-sensitive
+require "async"
+require 'digest/sha1'
+require 'jwt'
+require 'logger'
+require 'background_tasks'
+require 'committee'
+# If the library detects rails, it will load rail's methods
+if defined?(Rails)
+ require 'action_dispatch'
+ require 'action_pack'
+ require 'railtie'
+end
+
module Firetail
class Error < StandardError; end
- # Your code goes here...
+
+ class Run
+ MAX_BULK_SIZE_IN_BYTES = 1 * 1024 * 1024 # 1 MB
+
+ def initialize app
+ @app = app
+ @reqres ||= [] # request data in stored in array memory
+ @init_time ||= Time.now # initialize time
+ end
+
+ def call(env)
+ # This block initialises the configuration and checks
+ # sets the values for certain necessary configuration
+ # If it is Rails
+ if defined?(Rails)
+ begin
+ default_location = File.join(Rails.root, "config/firetail.yml")
+ config = YAML.load_file(default_location)
+ rescue Errno::ENOENT
+ # error message if firetail is not installed
+ puts ""
+ puts "Please run 'rails generate firetail:install' first"
+ puts ""
+ end
+ else # other frameworks
+ config = YAML.load_file("firetail.yml")
+ end
+
+ raise Error.new "Please run 'rails generate firetail:install' first" if config.nil?
+ raise Error.new "API Key is missing from firetail.yml configuration" if config['api_key'].nil?
+
+ @api_key = config['api_key']
+ @url = config['url'] ? config['url'] : "https://api.logging.eu-west-1.sandbox.firetail.app/logs/bulk" # default goes to dev
+ @log_drains_timeout = config['log_drains_timeout'] ? config['log_drains_timeout'] : 5
+ @network_timeout = config['network_timeout'] ? config['network_timeout'] : 10
+ @number_of_retries = config['number_of_retries'] ? config['number_of_retries'] : 4
+ @retry_timeout = config['retry_timeout'] ? config['retry_timeout'] : 2
+ # End of configuration initialization
+
+ # Gets the rack middleware requests
+ @request = Rack::Request.new(env)
+ started_on = Time.now
+ begin
+ status, client_headers, body = response = @app.call(env)
+ log(env, status, body, started_on, Time.now)
+ rescue Exception => exception
+ log(env, status, body, started_on, Time.now, exception)
+ raise exception
+ end
+
+ response
+ end
+
+ def log(env,
+ status,
+ body,
+ started_on,
+ ended_on,
+ exception = nil)
+
+ # request values
+ time_spent = ended_on - started_on
+ request_ip = defined?(Rails) ? env['action_dispatch.remote_ip'].calculate_ip : env['REMOTE_ADDR']
+ request_method = env['REQUEST_METHOD']
+ request_path = env['REQUEST_PATH']
+ request_http_version = env['HTTP_VERSION']
+
+ # get the resource parameters if it is rails
+ if defined?(Rails)
+ resource = Rails.application.routes.recognize_path(request_path)
+ #Firetail.logger.debug "res: #{resource}"
+ # sample hash of the above resource:
+ # example url: /posts/1/comments/2/options/3
+ # hash = {:controller=>"options", :action=>"show", :comment_id => 3, :post_id=>"1", :id=>"1"}
+ # take the resource hash above, get keys, conver to string, split "_" to get name at first index, together
+ # with the key, to string and camelcase route id name and keys that only include "id", compact (remove nil) and add "s" to the key
+ rmap = resource.map {|k,v| [k.to_s.split("_")[0], "{#{k.to_s.camelize(:lower)}}"] if k.to_s.include? "id" }
+ .compact.map {|k,v| [k.to_s + "s", v] if k != "id" }
+
+ if resource.key? :id
+ # It will appear like: [["comments", "commentId"], ["posts", "postId"], ["id", "id"]],
+ # but we want post to be first in order, so we reverse sort, and drop "id", which will be first in array
+ # after being sorted
+ reverse_resource = rmap.reverse.drop(1)
+ resource_path = "/" + reverse_resource * "/" + "/" + resource[:controller] + "/" + "{id}"
+ # rebuild the resource path
+ # reverse_resource * "/" will loop the array and add "/"
+ #resource_path = "/" + reverse_resource * "/" + "/" + resource[:controller] + "/" + "{id}"
+ # end result is /posts/{postId}/comments/{commentId}/options/{id}
+ else
+ if rmap.empty?
+ # if resoruce is empty, means we are at the first level of the url path, so no need extra paths
+ resource_path = "/" + rmap * "/" + resource[:controller]
+ else
+ # resource path from rmap above without the [:id] key (which is the last parameter in URL)
+ # only used for index, create which does not have id
+ resource_path = "/" + rmap * "/" + "/" + resource[:controller]
+ end
+ end
+ else
+ resource_path = nil
+ end
+
+ #Firetail.logger.debug("resource path: #{resource_path}")
+ # select those with "HTTP_" prefix, these are request headers
+ request_headers = env.select {|key,val| key.start_with? 'HTTP_' } # find HTTP_ prefixes, these are requests only
+ .collect {|key, val| { "#{key.sub(/^HTTP_/, '')}": [val] }} # remove HTTP_ prefix
+ .reduce({}, :merge) # reduce from [{key:val},{key2: val2}] to {key: val, key2: val2}
+
+ # do the inverse of the above and get rack specific keys
+ response_headers = env.select {|key,val| !key.start_with? 'HTTP_' } # only keys with no HTTP_ prefix
+ .select {|key, val| key =~ /^[A-Z._]*$/} # select keys with uppercase and underline
+ .map {|key, val| { "#{key}": [val] }} # map to firetail api format
+ .reduce({}, :merge) # reduce from [{key:val},{key2: val2}] to {key: val, key2: val2}
+
+ # get the jwt "sub" information
+ if request_headers[:AUTHORIZATION]
+ subject = self.jwt_decoder(request_headers[:AUTHORIZATION])
+ else
+ subject = nil
+ end
+
+ # default time spent in ruby is in seconds, so multiple by 1000 to ms
+ time_spent_in_ms = time_spent * 1000
+ #Firetail.logger.debug "request params: #{@request.params.inspect}"
+ # add the request and response data
+ # to array of data for batching up
+ @request.body.rewind
+ if body.is_a? Array
+ body = body[0]
+ else
+ body = body.body
+ end
+ @reqres.push({
+ version: "1.0.0-alpha",
+ dateCreated: Time.now.utc.to_i,
+ executionTime: time_spent_in_ms,
+ request: {
+ httpProtocol: request_http_version,
+ headers: request_headers, # headers must be in: headers: {"key": ["value"]}, array in object
+ method: request_method,
+ body: @request.body.read,
+ ip: request_ip,
+ resource: resource_path,
+ uri: @request.url
+ },
+ response: {
+ statusCode: status,
+ body: body,
+ headers: response_headers,
+ },
+ oauth: {
+ subject: subject ? sha1_hash(subject) : nil,
+ }
+ })
+ @request.body.rewind
+ #Firetail.logger.debug "Request: #{body}"
+
+ # the time we calculate if request that is
+ # buffered max is 120 seconds
+ current_time = Time.now
+ # duration in millseconds
+ duration = (current_time - @init_time)
+
+ #Firetail.logger.debug "size in bytes #{ObjectSpace.memsize_of(@request_data.to_s)}"
+ #request data size in bytes
+ request_data_size = ObjectSpace.memsize_of(@request_data)
+ # It is difficult to calculate the object size in bytes,
+ # seems to not return the accurate values
+
+ # This will send the data we need in batches of 5 requests or when it is more than 120 seconds
+ # if there are more than 5 requests or is more than
+ # 2 minutes, then send to backend - this is for testing
+ if @reqres.length >= 5 || duration > 120
+ #Firetail.logger.debug "request data #{@reqres}"
+ # we parse the data hash into json-nl (json-newlines)
+ payload = @reqres.map { |data| JSON.generate(data) }.join("\n")
+
+ # send the data to backend API
+ # This is an async task
+ BackgroundTasks.http_task(@url,
+ @network_timeout,
+ @api_key,
+ @number_of_tries,
+ payload)
+
+ # reset back to the initial conditions
+ payload = nil
+ @reqres = []
+ @init_time = Time.now
+ end
+ rescue Exception => exception
+ Firetail.logger.error(exception.message)
+ end
+
+ def sha1_hash(value)
+ encode_utf8 = value.encode(Encoding::UTF_8)
+ hash = Digest::SHA1.hexdigest(encode_utf8)
+ sha1 = "sha1: #{hash}"
+ end
+
+ def jwt_decoder(value)
+ bearer_string = value
+ # if authorization exists, get the value at index
+ # split the values which has a space (example: "Bearer 123") and
+ # get the value at index 1
+ token = bearer_string.split(" ")[1]
+ # decode the token
+ jwt_value = JWT.decode token, nil, false
+ # get the subject value
+ subject = jwt_value[0]["sub"]
+ #Firetail.logger.debug("subject value: #{subject}")
+ end
+ end
+
+ def self.logger
+ @@logger ||= defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
+ end
+
+ def self.logger=(logger)
+ @@logger = logger
+ end
+
+ # custom error message
+ # https://blog.frankel.ch/structured-errors-http-apis/
+ # https://www.rfc-editor.org/rfc/rfc7807
+ class Committee::ValidationError
+ def error_body
+ {
+ errors: [
+ {
+ type: "#{request.env['rack.url_scheme']}://#{request.env['HTTP_HOST']}#{request.env['REQUEST_URI']}",
+ title: id,
+ detail: message,
+ status: status
+ }
+ ]
+ }
+ end
+
+ def render
+ [
+ status,
+ { "Content-Type" => "application/json" },
+ [JSON.generate(error_body)]
+ ]
+ end
+ end
end