Skip to content
Open
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
55 changes: 55 additions & 0 deletions docs/track.md
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
marksmith marked this conversation as resolved.
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.
19 changes: 19 additions & 0 deletions lib/aikido/zen.rb
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,25 @@ class << self
alias_method :set_user, :track_user
end

# Track user event with name
#
Comment thread
marksmith marked this conversation as resolved.
# @param name [String]
# @return [void]
def self.track_user_event(name)
context = current_context
return unless context
Comment thread
timokoessler marked this conversation as resolved.

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
Expand Down
13 changes: 13 additions & 0 deletions lib/aikido/zen/agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,19 @@ def report(event)
end
end

# @param event [Aikido::Zen::Tracked]
Comment thread
marksmith marked this conversation as resolved.
Comment thread
marksmith marked this conversation as resolved.
# @return [void]
def send_user_event(event)
Comment thread
marksmith marked this conversation as resolved.
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
Expand Down
26 changes: 26 additions & 0 deletions lib/aikido/zen/api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,30 @@ def report(event)
raise
end

def send_user_event(event)
Comment thread
marksmith marked this conversation as resolved.
Comment thread
marksmith marked this conversation as resolved.
event_type = "user_event"

if @rate_limiter.throttle?(event_type)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As these events are not send to Aikido Core, maybe not re-use the same rate limiter? (I think this is the case)

@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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These events can only be sent to zen.aikido.dev, the old runtime API does not support this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's quite an important point for the other agents too. If the agent is unable to connect to zen.aikido.dev, we need to make it really clear that the user events aren't going to work. I wonder if we need a log here, @timokoessler?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, makes sense. Maybe log if user calls the track() API for the first time? cc. @hansott.

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.
#
Expand All @@ -133,6 +157,8 @@ def report(event)
response = http.request(request)

case response
when Net::HTTPNoContent
# empty
Comment thread
marksmith marked this conversation as resolved.
when Net::HTTPSuccess
begin
body = decode(response.body, response["Content-Encoding"])
Expand Down
16 changes: 16 additions & 0 deletions lib/aikido/zen/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions test/aikido/zen_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading