Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/.bundle/
/gemfiles/.bundle/
/.yardoc
/_yardoc/
/coverage/
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,47 @@ end

This will guarantee that the log is always persisted, even in case of errors.

### Using Faraday

If you use [Faraday](https://github.com/lostisland/faraday) for HTTP requests, `from_response` works seamlessly with `Faraday::Response` objects:

```ruby
conn = Faraday.new(url: 'https://api.example.com')

log = RailsApiLogger::OutboundRequestLog.create(
path: "https://api.example.com/users",
method: "POST",
request_body: { name: "John" },
started_at: Time.current
)

response = conn.post('/users', { name: 'John' }.to_json)

log.from_response(response)
log.ended_at = Time.current
log.save!
```

For automatic logging of all requests, use the provided middleware:

```ruby
conn = Faraday.new(url: 'https://api.example.com') do |f|
f.use RailsApiLogger::FaradayMiddleware
f.adapter Faraday.default_adapter
end

response = conn.post('/users', { name: 'John' }.to_json)
```

The middleware accepts the following options:

```ruby
f.use RailsApiLogger::FaradayMiddleware,
loggable: current_user, # associate log with a model
skip_request_body: true, # don't log request body
skip_response_body: true # don't log response body
```

### Database Transactions Caveats

If you log your outbound requests inside of parent app transactions, your logs will not be persisted if
Expand Down
22 changes: 22 additions & 0 deletions app/middlewares/rails_api_logger/faraday_middleware.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module RailsApiLogger
class FaradayMiddleware < Faraday::Middleware
def initialize(app, options = {})
super(app)
@options = options
end

def call(env)
log = OutboundRequestLog.from_request(env, loggable: @options[:loggable], skip_request_body: @options[:skip_request_body])

@app.call(env).on_complete do |response_env|
log.from_response(response_env.response, skip_response_body: @options[:skip_response_body])
end
rescue => e
log.response_body = {error: e.message} if log
raise
ensure
log.ended_at = Time.current
log.save!
end
end
end
32 changes: 32 additions & 0 deletions app/models/rails_api_logger/request_log.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class RequestLog < ActiveRecord::Base
validates :path, presence: true

def self.from_request(request, loggable: nil, skip_request_body: false)
request = normalize_request(request)
if skip_request_body
body = "[Skipped]"
else
Expand All @@ -35,11 +36,42 @@ def self.from_request(request, loggable: nil, skip_request_body: false)
end

def from_response(response, skip_response_body: false)
response = self.class.normalize_response(response)
self.response_code = response.code
self.response_body = skip_response_body ? "[Skipped]" : manipulate_body(response.body)
self
end

def self.normalize_request(request)
return request unless faraday_request?(request)

NormalizedRequest.new(
path: request.url.to_s,
body: request.request_body,
method: request.method.to_s.upcase
)
end

def self.normalize_response(response)
return response unless faraday_response?(response)

NormalizedResponse.new(
code: response.status,
body: response.body
)
end

def self.faraday_request?(request)
defined?(Faraday::Env) && request.is_a?(Faraday::Env)
end

def self.faraday_response?(response)
defined?(Faraday::Response) && response.is_a?(Faraday::Response)
end

NormalizedRequest = Struct.new(:path, :body, :method, keyword_init: true)
NormalizedResponse = Struct.new(:code, :body, keyword_init: true)

def formatted_request_body
formatted_body(request_body)
end
Expand Down
1 change: 1 addition & 0 deletions gemfiles/rails_6.1.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ gem "rspec-rails", "~> 6.1.0"
gem "sqlite3", "~> 1.7.3"
gem "ammeter", "~> 1.1"
gem "webmock", "~> 3.25.0"
gem "faraday", "~> 2.0"

gemspec path: "../"
1 change: 1 addition & 0 deletions gemfiles/rails_7.0.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ gem "rspec-rails", "~> 7.1.0"
gem "sqlite3", "~> 1.7.3"
gem "ammeter", "~> 1.1"
gem "webmock", "~> 3.25.0"
gem "faraday", "~> 2.0"

gemspec path: "../"
1 change: 1 addition & 0 deletions gemfiles/rails_7.1.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ gem "rspec-rails", "~> 7.1.0"
gem "sqlite3", "~> 1.7.3"
gem "ammeter", "~> 1.1"
gem "webmock", "~> 3.25.0"
gem "faraday", "~> 2.0"

gemspec path: "../"
1 change: 1 addition & 0 deletions gemfiles/rails_7.2.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ gem "rspec-rails", "~> 7.1.0"
gem "sqlite3", "~> 1.7.3"
gem "ammeter", "~> 1.1"
gem "webmock", "~> 3.25.0"
gem "faraday", "~> 2.0"

gemspec path: "../"
1 change: 1 addition & 0 deletions gemfiles/rails_8.0.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ gem "sqlite3", "~> 2.1.0"
gem "ammeter", "~> 1.1"
gem "puma"
gem "webmock", "~> 3.25.0"
gem "faraday", "~> 2.0"

gemspec path: "../"
9 changes: 9 additions & 0 deletions gemfiles/rails_8.0.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ GEM
drb (2.2.3)
erb (5.0.1)
erubi (1.13.1)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
globalid (1.2.1)
activesupport (>= 6.1)
hashdiff (1.2.0)
Expand All @@ -135,6 +141,8 @@ GEM
mini_mime (1.1.5)
minitest (5.25.5)
mysql2 (0.5.6)
net-http (0.8.0)
uri (>= 0.11.1)
net-imap (0.5.8)
date
net-protocol
Expand Down Expand Up @@ -282,6 +290,7 @@ DEPENDENCIES
ammeter (~> 1.1)
appraisal (~> 2.5)
byebug
faraday (~> 2.0)
mysql2 (~> 0.5.6)
pg (~> 1.5.4)
puma
Expand Down
1 change: 1 addition & 0 deletions gemfiles/rails_webrick_8.0.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ gem "sqlite3", "~> 2.1.0"
gem "ammeter", "~> 1.1"
gem "webrick", "~> 1.9.1"
gem "webmock", "~> 3.25.0"
gem "faraday", "~> 2.0"

gemspec path: "../"
5 changes: 5 additions & 0 deletions lib/rails_api_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@

loader = Zeitwerk::Loader.for_gem
loader.ignore("#{__dir__}/generators")
loader.ignore("#{__dir__}/../app/middlewares/rails_api_logger/faraday_middleware.rb")
loader.setup

module RailsApiLogger
mattr_accessor :connects_to
end

if defined?(Faraday)
require_relative "../app/middlewares/rails_api_logger/faraday_middleware"
end
94 changes: 94 additions & 0 deletions spec/faraday_middleware_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
require "spec_helper"
require "faraday"

RSpec.describe RailsApiLogger::FaradayMiddleware do
before do
RailsApiLogger::OutboundRequestLog.delete_all
end

let(:connection) do
Faraday.new(url: "https://example.com") do |f|
f.use described_class, options
f.adapter :test do |stub|
stub.post("/users") { [201, {}, '{"id": 1, "name": "John"}'] }
stub.get("/users/1") { [200, {}, '{"id": 1, "name": "John"}'] }
stub.get("/error") { raise Faraday::ConnectionFailed, "connection failed" }
end
end
end

let(:options) { {} }

describe "successful request" do
it "logs the request and response" do
response = connection.post("/users", {name: "John"}.to_json)

expect(response.status).to eq(201)
expect(RailsApiLogger::OutboundRequestLog.count).to eq(1)

log = RailsApiLogger::OutboundRequestLog.last
expect(log.path).to eq("https://example.com/users")
expect(log.method).to eq("POST")
expect(log.response_code).to eq(201)
expect(log.started_at).to be_present
expect(log.ended_at).to be_present
end

it "logs GET requests" do
response = connection.get("/users/1")

expect(response.status).to eq(200)

log = RailsApiLogger::OutboundRequestLog.last
expect(log.path).to eq("https://example.com/users/1")
expect(log.method).to eq("GET")
expect(log.response_code).to eq(200)
end
end

describe "with skip_request_body option" do
let(:options) { {skip_request_body: true} }

it "does not log the request body" do
connection.post("/users", {name: "John"}.to_json)

log = RailsApiLogger::OutboundRequestLog.last
expect(log.request_body).to eq("[Skipped]")
end
end

describe "with skip_response_body option" do
let(:options) { {skip_response_body: true} }

it "does not log the response body" do
connection.post("/users", {name: "John"}.to_json)

log = RailsApiLogger::OutboundRequestLog.last
expect(log.response_body).to eq("[Skipped]")
end
end

describe "with loggable option" do
let(:book) { Book.create!(title: "Test Book", author: "Author") }
let(:options) { {loggable: book} }

it "associates the log with the loggable" do
connection.get("/users/1")

log = RailsApiLogger::OutboundRequestLog.last
expect(log.loggable).to eq(book)
end
end

describe "when request fails" do
it "logs the error and re-raises" do
expect {
connection.get("/error")
}.to raise_error(Faraday::ConnectionFailed)

log = RailsApiLogger::OutboundRequestLog.last
expect(log.response_body).to eq({"error" => "connection failed"})
expect(log.ended_at).to be_present
end
end
end
Loading