Skip to content

Commit a1a6d20

Browse files
authored
Merge pull request #196 from DFE-Digital/add-api-request-event
Add support for api_request events
2 parents 24fb7c4 + 36a8dce commit a1a6d20

File tree

9 files changed

+286
-34
lines changed

9 files changed

+286
-34
lines changed

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ gem 'dfe-analytics'
235235
gem 'globalize' require: ['dfe-analytics', 'globalize']
236236
```
237237
238-
### 6. Send web request events
238+
### 6. Send request events
239239
240240
#### Web requests
241241
@@ -259,7 +259,29 @@ class ApplicationController < ActionController::Base
259259
end
260260
```
261261

262-
Web request events will try to add a `user_id` to the event data sent to
262+
#### API requests
263+
264+
Including `DfE::Analytics::ApiRequests` in your `ApiController` will cause
265+
it and all inheriting controllers to send api request events to BigQuery. (This
266+
is probably what you want).
267+
268+
```ruby
269+
class ApiController < ActionController::API
270+
include DfE::Analytics::ApiRequests
271+
272+
# This method MAY be present in your controller, returning
273+
# either nil or an object implementing an .id method.
274+
#
275+
# def current_user; end
276+
277+
# This method MAY be present in your controller. If so, it should
278+
# return a string - return value will be attached to api_request events.
279+
#
280+
# def current_namespace; end
281+
end
282+
```
283+
284+
Request events will try to add a `user_id` to the event data sent to
263285
BigQuery. The `user_id` will only be populated if the controller defines a
264286
`current_user` method whose return value responds to `.id`.
265287

@@ -270,7 +292,7 @@ user identifier proc can be defined in `config/initializers/dfe_analytics.rb`:
270292
DfE::Analytics.config.user_identifier = proc { |user| user&.uid }
271293
```
272294

273-
You can specify paths that should be excluded from logging using the skip_web_requests configuration option. This is useful for endpoints like health checks that are frequently hit and do not need to be logged.
295+
You can specify paths that should be excluded from sending request events using the excluded_paths configuration option. This is useful for endpoints like health checks that are frequently hit and do not need to generate an event.
274296

275297
```ruby
276298
DfE::Analytics.configure do |config|

lib/dfe/analytics.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
require 'dfe/analytics/services/generic_checksum_calculator'
1313
require 'dfe/analytics/services/postgres_checksum_calculator'
1414
require 'dfe/analytics/shared/service_pattern'
15+
require 'dfe/analytics/concerns/requestable'
1516
require 'dfe/analytics/event'
1617
require 'dfe/analytics/event_matcher'
1718
require 'dfe/analytics/analytics_job'
@@ -29,6 +30,7 @@
2930
require 'dfe/analytics/big_query_legacy_api'
3031
require 'dfe/analytics/azure_federated_auth'
3132
require 'dfe/analytics/transaction_changes'
33+
require 'dfe/analytics/api_requests'
3234

3335
module DfE
3436
module Analytics

lib/dfe/analytics/api_requests.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
module DfE
4+
module Analytics
5+
# This module provides functionality to generate api_request events through an after action controller callback
6+
module ApiRequests
7+
extend ActiveSupport::Concern
8+
9+
included do
10+
after_action :trigger_api_request_event
11+
end
12+
13+
include Dfe::Analytics::Concerns::Requestable
14+
15+
def trigger_api_request_event
16+
trigger_request_event('api_request')
17+
end
18+
end
19+
end
20+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
module Dfe
4+
module Analytics
5+
module Concerns
6+
# This module provides common functionality to generate request events for the given event type
7+
module Requestable
8+
def trigger_request_event(event_type)
9+
return unless DfE::Analytics.enabled?
10+
return if path_excluded?
11+
12+
request_event = DfE::Analytics::Event.new
13+
.with_type(event_type)
14+
.with_request_details(request)
15+
.with_response_details(response)
16+
.with_request_uuid(RequestLocals.fetch(:dfe_analytics_request_id) { nil })
17+
18+
request_event.with_user(current_user) if respond_to?(:current_user, true)
19+
request_event.with_namespace(current_namespace) if respond_to?(:current_namespace, true)
20+
21+
DfE::Analytics::SendEvents.do([request_event.as_json])
22+
end
23+
24+
private
25+
26+
def path_excluded?
27+
excluded_path = DfE::Analytics.config.excluded_paths
28+
excluded_path.any? do |path|
29+
if path.is_a?(Regexp)
30+
path.match?(request.fullpath)
31+
else
32+
request.fullpath.start_with?(path)
33+
end
34+
end
35+
end
36+
end
37+
end
38+
end
39+
end

lib/dfe/analytics/event.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module DfE
66
module Analytics
77
class Event
88
EVENT_TYPES = %w[
9-
web_request create_entity update_entity delete_entity import_entity initialise_analytics entity_table_check import_entity_table_check
9+
web_request create_entity update_entity delete_entity import_entity initialise_analytics entity_table_check import_entity_table_check api_request
1010
].freeze
1111

1212
def initialize

lib/dfe/analytics/requests.rb

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,18 @@
22

33
module DfE
44
module Analytics
5+
# This module provides functionality to generate web_request events through an after action controller callback
56
module Requests
67
extend ActiveSupport::Concern
78

89
included do
9-
after_action :trigger_request_event
10+
after_action :trigger_web_request_event
1011
end
1112

12-
def trigger_request_event
13-
return unless DfE::Analytics.enabled?
14-
return if path_excluded?
13+
include Dfe::Analytics::Concerns::Requestable
1514

16-
request_event = DfE::Analytics::Event.new
17-
.with_type('web_request')
18-
.with_request_details(request)
19-
.with_response_details(response)
20-
.with_request_uuid(RequestLocals.fetch(:dfe_analytics_request_id) { nil })
21-
22-
request_event.with_user(current_user) if respond_to?(:current_user, true)
23-
request_event.with_namespace(current_namespace) if respond_to?(:current_namespace, true)
24-
25-
DfE::Analytics::SendEvents.do([request_event.as_json])
26-
end
27-
28-
private
29-
30-
def path_excluded?
31-
excluded_path = DfE::Analytics.config.excluded_paths
32-
excluded_path.any? do |path|
33-
if path.is_a?(Regexp)
34-
path.match?(request.fullpath)
35-
else
36-
request.fullpath.start_with?(path)
37-
end
38-
end
15+
def trigger_web_request_event
16+
trigger_request_event('web_request')
3917
end
4018
end
4119
end
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe DfE::Analytics::ApiRequests, type: :request do
4+
before do
5+
controller = Class.new(ApplicationController) do
6+
include DfE::Analytics::ApiRequests
7+
8+
def index
9+
render plain: 'hello'
10+
end
11+
12+
private
13+
14+
def current_user
15+
Struct.new(:id).new(1)
16+
end
17+
18+
def current_namespace
19+
'example_namespace'
20+
end
21+
end
22+
23+
unauthenticated_controller = Class.new(ApplicationController) do
24+
include DfE::Analytics::ApiRequests
25+
26+
def index
27+
render plain: 'hello'
28+
end
29+
end
30+
31+
stub_const('TestController', controller)
32+
stub_const('TestUnauthenticatedController', unauthenticated_controller)
33+
end
34+
35+
around do |ex|
36+
Rails.application.routes.draw do
37+
get '/example/path' => 'test#index'
38+
get '/unauthenticated_example' => 'test_unauthenticated#index'
39+
get '/healthcheck' => 'test#index'
40+
get '/regex_path/test' => 'test#index'
41+
get '/some/path/with/regex_path' => 'test#index'
42+
get '/another/path/to/regex_path' => 'test#index'
43+
get '/included/path' => 'test#index'
44+
end
45+
46+
ex.run
47+
ensure
48+
Rails.application.routes_reloader.reload!
49+
end
50+
51+
let!(:event) do
52+
{ environment: 'test',
53+
event_type: 'api_request',
54+
request_user_agent: 'Test agent',
55+
request_method: 'GET',
56+
request_path: '/example/path',
57+
request_query: [{ key: 'page',
58+
value: ['1'] },
59+
{ key: 'per_page',
60+
value: ['25'] },
61+
{ key: 'array_param[]',
62+
value: %w[1 2] }],
63+
request_referer: nil,
64+
anonymised_user_agent_and_ip: '6b3f52c670279e133a78a03e34eea436c65395417ec264eb9f8c1e6da4f5ed56',
65+
response_content_type: 'text/plain; charset=utf-8',
66+
response_status: 200,
67+
namespace: 'example_namespace',
68+
user_id: 1 }
69+
end
70+
71+
before do
72+
DfE::Analytics.configure do |config|
73+
config.excluded_paths = ['/healthcheck', %r{^/regex_path/.*$}, /regex_path$/]
74+
end
75+
end
76+
77+
it 'sends request data to BigQuery' do
78+
request = stub_analytics_event_submission
79+
DfE::Analytics::Testing.webmock! do
80+
perform_enqueued_jobs do
81+
get('/example/path',
82+
params: { page: '1', per_page: '25', array_param: %w[1 2] },
83+
headers: { 'HTTP_USER_AGENT' => 'Test agent', 'X-REAL-IP' => '1.2.3.4' })
84+
end
85+
end
86+
87+
expect(request.with do |req|
88+
body = JSON.parse(req.body)
89+
payload = body['rows'].first['json']
90+
expect(payload.except('occurred_at', 'request_uuid')).to match(event.deep_stringify_keys)
91+
end).to have_been_made
92+
end
93+
94+
describe 'an event without user or namespace' do
95+
let!(:event) do
96+
{ environment: 'test',
97+
event_type: 'api_request',
98+
request_user_agent: nil,
99+
request_method: 'GET',
100+
request_path: '/unauthenticated_example',
101+
request_query: [],
102+
request_referer: nil,
103+
anonymised_user_agent_and_ip: '6694f83c9f476da31f5df6bcc520034e7e57d421d247b9d34f49edbfc84a764c',
104+
response_content_type: 'text/plain; charset=utf-8',
105+
response_status: 200 }
106+
end
107+
108+
it 'does not require a user' do
109+
request = stub_analytics_event_submission
110+
111+
DfE::Analytics::Testing.webmock! do
112+
perform_enqueued_jobs do
113+
get('/unauthenticated_example',
114+
headers: { 'X-REAL-IP' => '1.2.3.4' })
115+
end
116+
end
117+
118+
expect(request.with do |req|
119+
body = JSON.parse(req.body)
120+
payload = body['rows'].first['json']
121+
expect(payload.except('occurred_at', 'request_uuid')).to match(event.deep_stringify_keys)
122+
end).to have_been_made
123+
end
124+
end
125+
126+
context 'when request path is in the skip list' do
127+
it 'does not send healthcheck request data to BigQuery' do
128+
request = stub_analytics_event_submission
129+
130+
DfE::Analytics::Testing.webmock! do
131+
perform_enqueued_jobs do
132+
get('/healthcheck')
133+
end
134+
end
135+
136+
expect(request).not_to have_been_made
137+
end
138+
139+
it 'does not send regex_path/test request data to BigQuery' do
140+
request = stub_analytics_event_submission
141+
142+
DfE::Analytics::Testing.webmock! do
143+
perform_enqueued_jobs do
144+
get('/regex_path/test')
145+
end
146+
end
147+
148+
expect(request).not_to have_been_made
149+
end
150+
151+
it 'does not send some/path/with/regex_path request data to BigQuery' do
152+
request = stub_analytics_event_submission
153+
154+
DfE::Analytics::Testing.webmock! do
155+
perform_enqueued_jobs do
156+
get('/some/path/with/regex_path')
157+
end
158+
end
159+
160+
expect(request).not_to have_been_made
161+
end
162+
163+
it 'does not send another/path/to/regex_path request data to BigQuery' do
164+
request = stub_analytics_event_submission
165+
166+
DfE::Analytics::Testing.webmock! do
167+
perform_enqueued_jobs do
168+
get('/another/path/to/regex_path')
169+
end
170+
end
171+
172+
expect(request).not_to have_been_made
173+
end
174+
end
175+
end

spec/dfe/analytics/rspec/matchers/have_been_enqueued_as_analytics_events_spec.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,21 @@ def perform(events)
1414
end
1515

1616
let(:web_request_event) { DfE::Analytics::Event.new.with_type('web_request') }
17+
let(:api_request_event) { DfE::Analytics::Event.new.with_type('api_request') }
1718
let(:update_entity_event) { DfE::Analytics::Event.new.with_type('update_entity') }
1819

19-
it 'passes when the given event type was triggered' do
20+
it 'passes when the web request event type was triggered' do
2021
DfE::Analytics::SendEvents.do([web_request_event.as_json])
2122

2223
expect(:web_request).to have_been_enqueued_as_analytics_events
2324
end
2425

26+
it 'passes when the api request event type was triggered' do
27+
DfE::Analytics::SendEvents.do([api_request_event.as_json])
28+
29+
expect(:api_request).to have_been_enqueued_as_analytics_events
30+
end
31+
2532
it 'accepts multiple event types' do
2633
DfE::Analytics::SendEvents.do([web_request_event.as_json])
2734
DfE::Analytics::SendEvents.do([update_entity_event.as_json])
@@ -49,9 +56,11 @@ def perform(events)
4956
it 'passes if other analytics events were triggered in addition to the specified analytics type' do
5057
DfE::Analytics::SendEvents.do([update_entity_event.as_json])
5158
DfE::Analytics::SendEvents.do([web_request_event.as_json])
59+
DfE::Analytics::SendEvents.do([api_request_event.as_json])
5260

5361
expect(:update_entity).to have_been_enqueued_as_analytics_events
5462
expect(:web_request).to have_been_enqueued_as_analytics_events
63+
expect(:api_request).to have_been_enqueued_as_analytics_events
5564
end
5665

5766
it 'passes if other jobs were triggered in addition to the specified analytics type' do

0 commit comments

Comments
 (0)