diff --git a/docs/track.md b/docs/track.md new file mode 100644 index 00000000..11914395 --- /dev/null +++ b/docs/track.md @@ -0,0 +1,55 @@ +# Tracking events + +`Aikido::Zen.track_user_event` lets you record things happening in your app — like failed logins, signups, or password resets. Zen sends these to Aikido so patterns can be detected, like someone failing to log in 50 times in a minute. + +```ruby +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + private + + def authenticate_user! + # Your authentication logic here + # ... + + unless current_user + Aikido::Zen.track_user_event("user.login_failed") + return + end + + Aikido::Zen.set_user( + id: current_user.id, + name: current_user.name + ) + + Aikido::Zen.track_user_event("user.login_succeeded") + end +end +``` + +Zen automatically picks up the IP address, user agent, and current user (if you called [`setUser`](./user.md)) from the request — you don't need to pass those yourself. + +## More examples + +```ruby +Aikido::Zen.track_user_event("user.signed_up") +Aikido::Zen.track_user_event("user.password_reset_requested") +Aikido::Zen.track_user_event("plan.invite_sent") +Aikido::Zen.track_user_event("payment.failed") +``` + +## Naming events + +Use lowercase with dots to group related events: + +- `user.login_failed` +- `user.login_succeeded` +- `user.signed_up` +- `user.password_reset_requested` +- `payment.failed` +- `plan.invite_sent` + +## Things to know + +`Aikido::Zen.track_user_event` only works inside an HTTP request. If you call it in a background job or a script, nothing gets sent and you'll see a warning in the console. + +If you haven't called `Aikido::Zen.set_user` yet, the event still goes through — it just won't have a user ID attached. diff --git a/lib/aikido/zen.rb b/lib/aikido/zen.rb index 491c4b25..a0665945 100644 --- a/lib/aikido/zen.rb +++ b/lib/aikido/zen.rb @@ -216,6 +216,25 @@ class << self alias_method :set_user, :track_user end + # Track user event with name + # + # @param name [String] + # @return [void] + def self.track_user_event(name) + context = current_context + return unless context + + request = context.request + + event = Aikido::Zen::UserEvent.new( + name: name, + user_id: request.actor&.id, + ip_address: request.client_ip + ) + + agent.send_user_event(event) + end + # @return [Aikido::Zen::AttackWave::Detector] the attack wave detector. def self.attack_wave_detector @attack_wave_detector ||= AttackWave::Detector.new diff --git a/lib/aikido/zen/agent.rb b/lib/aikido/zen/agent.rb index ace75d83..6ccd507d 100644 --- a/lib/aikido/zen/agent.rb +++ b/lib/aikido/zen/agent.rb @@ -163,6 +163,19 @@ def report(event) end end + # @param event [Aikido::Zen::Tracked] + # @return [void] + def send_user_event(event) + return unless @api_client.can_make_requests? + + @worker.perform do + response = @api_client.send_user_event(event) + yield response if response && block_given? + rescue Aikido::Zen::APIError, Aikido::Zen::NetworkError => err + @config.logger.error(err.message) + end + end + # @api private # # Atomically flushes all the stats stored by the agent, and sends a diff --git a/lib/aikido/zen/api_client.rb b/lib/aikido/zen/api_client.rb index b6fdd406..bc3406c8 100644 --- a/lib/aikido/zen/api_client.rb +++ b/lib/aikido/zen/api_client.rb @@ -117,6 +117,30 @@ def report(event) raise end + def send_user_event(event) + event_type = "user_event" + + if @rate_limiter.throttle?(event_type) + @config.logger.error("Not reporting #{event_type.upcase} event due to rate limiting") + return + end + + @config.logger.debug("Reporting #{event_type.upcase} event") + + req = Net::HTTP::Post.new("/api/runtime/events", default_headers) + req.content_type = "application/json" + req.body = if event.respond_to?(:as_json) + @config.json_encoder.call(event.as_json) + else + @config.json_encoder.call(event) + end + + request(req, base_url: @config.realtime_settings_updates_endpoint) + rescue Aikido::Zen::RateLimitedError + @rate_limiter.open! + raise + end + # Perform an HTTP request against one of our API endpoints, and process the # response. # @@ -133,6 +157,8 @@ def report(event) response = http.request(request) case response + when Net::HTTPNoContent + # empty when Net::HTTPSuccess begin body = decode(response.body, response["Content-Encoding"]) diff --git a/lib/aikido/zen/event.rb b/lib/aikido/zen/event.rb index 5e1bdd42..9c4f4018 100644 --- a/lib/aikido/zen/event.rb +++ b/lib/aikido/zen/event.rb @@ -96,4 +96,20 @@ def as_json end end end + + class UserEvent + def initialize(name:, user_id:, ip_address:) + @name = name + @user_id = user_id + @ip_address = ip_address + end + + def as_json + { + name: @name, + userId: @user_id, + ipAddress: @ip_address + } + end + end end diff --git a/test/aikido/zen_test.rb b/test/aikido/zen_test.rb index e9c89d22..47e95f2e 100644 --- a/test/aikido/zen_test.rb +++ b/test/aikido/zen_test.rb @@ -129,4 +129,65 @@ class Aikido::ZenTest < ActiveSupport::TestCase assert_equal "block required", err.message end + + class TrackUserEvent < ActiveSupport::TestCase + include StubsCurrentContext + include WorkerHelpers + + # Override StubCurrentContext#current_context to provide a request with the + # IP address for Aikdio::Zen::UserEvent. + def current_context + @current_context ||= Aikido::Zen::Context.from_rack_env({ + "REMOTE_ADDR" => "1.2.3.4" + }) + end + + class MockAPIStream < Aikido::Zen::APIStream + def work + nil + end + end + + setup do + Aikido::Zen.config.api_token = "TOKEN" + + # Replace the Aikido::Zen::Agent to prevent the agent from doing work. + + @worker = MockWorker.new + @api_stream = Minitest::Mock.new(MockAPIStream.new) + + @agent = Aikido::Zen::Agent.new( + worker: @worker, + api_stream: @api_stream + ) + + Aikido::Zen.instance_variable_set(:@agent, @agent) + end + + test ".track_user_event sends the named user event to the realtime API" do + request = stub_request(:post, "#{Aikido::Zen.config.realtime_settings_updates_endpoint}/api/runtime/events") + .with( + body: { + "name" => "user.login_failed", + "userId" => "418", + "ipAddress" => "1.2.3.4" + }.to_json, + headers: { + "Content-Type" => "application/json", + "Authorization" => "TOKEN", + "Accept" => "application/json" + } + ) + .to_return(status: 204, body: "") + + Aikido::Zen.set_user( + id: "418", + name: "I. A. Teapot" + ) + + Aikido::Zen.track_user_event("user.login_failed") + + assert_requested request + end + end end