From b2a0d064d1ae1f2e11eaca24c78330821499a9c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 11 Sep 2026 15:09:51 +0200 Subject: [PATCH 1/6] Add track() API for custom event tracking --- .../main/java/dev/aikido/agent_api/Track.java | 51 ++++++++++++ .../cloud/api/events/CustomEvent.java | 45 +++++++++++ agent_api/src/test/java/TrackTest.java | 81 +++++++++++++++++++ .../background/cloud/api/CustomEventTest.java | 64 +++++++++++++++ docs/track.md | 48 +++++++++++ 5 files changed, 289 insertions(+) create mode 100644 agent_api/src/main/java/dev/aikido/agent_api/Track.java create mode 100644 agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java create mode 100644 agent_api/src/test/java/TrackTest.java create mode 100644 agent_api/src/test/java/background/cloud/api/CustomEventTest.java create mode 100644 docs/track.md diff --git a/agent_api/src/main/java/dev/aikido/agent_api/Track.java b/agent_api/src/main/java/dev/aikido/agent_api/Track.java new file mode 100644 index 000000000..ff4b48878 --- /dev/null +++ b/agent_api/src/main/java/dev/aikido/agent_api/Track.java @@ -0,0 +1,51 @@ +package dev.aikido.agent_api; + +import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; +import dev.aikido.agent_api.context.Context; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.helpers.logging.LogManager; +import dev.aikido.agent_api.helpers.logging.Logger; +import dev.aikido.agent_api.storage.AttackQueue; + +public final class Track { + private Track() {} + private static final Logger logger = LogManager.getLogger(Track.class); + private static boolean loggedWarningTrackCalledWithoutContext = false; + + /** + * External function for applications to track a custom event, e.g. a + * failed login or a signup. Only works inside an HTTP request. + */ + public static void track(String eventName) { + if (eventName == null || eventName.isEmpty()) { + logger.info("track(...) expects a non-empty string as event name."); + return; + } + + ContextObject currentContext = Context.get(); + if (currentContext == null) { + logWarningTrackCalledWithoutContext(); + return; + } + + AttackQueue.add(CustomEvent.createAPIEvent(eventName, currentContext)); + } + + private static void logWarningTrackCalledWithoutContext() { + if (loggedWarningTrackCalledWithoutContext) { + return; + } + logger.warn( + "track(...) was called without a context. The event will not be tracked. " + + "Make sure to call track(...) within an HTTP request." + ); + loggedWarningTrackCalledWithoutContext = true; + } + + /** + * Resets internal warning state. Only intended for use in tests. + */ + public static void reset() { + loggedWarningTrackCalledWithoutContext = false; + } +} diff --git a/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java new file mode 100644 index 000000000..bd15007ff --- /dev/null +++ b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java @@ -0,0 +1,45 @@ +package dev.aikido.agent_api.background.cloud.api.events; + +import dev.aikido.agent_api.background.cloud.GetManagerInfo; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.context.User; + +import static dev.aikido.agent_api.background.cloud.GetManagerInfo.getManagerInfo; +import static dev.aikido.agent_api.helpers.UnixTimeMS.getUnixTimeMS; + +public final class CustomEvent { + private CustomEvent() {} + public record CustomEventEvent( + String type, + String name, + DetectedAttack.RequestData request, + GetManagerInfo.ManagerInfo agent, + User user, + long time + ) implements APIEvent {} + + public static CustomEventEvent createAPIEvent(String eventName, ContextObject context) { + return new CustomEventEvent( + "custom", // type + eventName, // name + buildRequestData(context), // request + getManagerInfo(), // agent + context != null ? context.getUser() : null, // user + getUnixTimeMS() // time + ); + } + + private static DetectedAttack.RequestData buildRequestData(ContextObject context) { + if (context == null) { + return null; + } + return new DetectedAttack.RequestData( + context.getMethod(), + context.getRemoteAddress(), + context.getHeader("user-agent"), + context.getUrl(), + context.getSource(), + context.getRoute() + ); + } +} diff --git a/agent_api/src/test/java/TrackTest.java b/agent_api/src/test/java/TrackTest.java new file mode 100644 index 000000000..6896c78fc --- /dev/null +++ b/agent_api/src/test/java/TrackTest.java @@ -0,0 +1,81 @@ +import dev.aikido.agent_api.Track; +import dev.aikido.agent_api.background.cloud.api.events.APIEvent; +import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; +import dev.aikido.agent_api.context.Context; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.storage.AttackQueue; +import org.junit.jupiter.api.*; +import org.junitpioneer.jupiter.SetEnvironmentVariable; +import org.junitpioneer.jupiter.StdIo; +import org.junitpioneer.jupiter.StdOut; +import utils.EmptySampleContextObject; + +import static org.junit.jupiter.api.Assertions.*; + +@SetEnvironmentVariable(key = "AIKIDO_LOG_LEVEL", value = "trace") +@SetEnvironmentVariable(key = "AIKIDO_TOKEN", value = "invalid-token-2") +public class TrackTest { + @BeforeEach + public void setup() { + Context.set(null); + AttackQueue.clear(); + Track.reset(); + } + + @AfterEach + public void tearDown() { + Context.set(null); + AttackQueue.clear(); + Track.reset(); + } + + @Test + @StdIo + public void testTrackWithInvalidEventName(StdOut out) throws Exception { + Track.track(""); + assertTrue(out.capturedString().contains("expects a non-empty string as event name.")); + assertEquals(0, AttackQueue.getSize()); + } + + @Test + @StdIo + public void testTrackWithNullEventName(StdOut out) throws Exception { + Track.track(null); + assertTrue(out.capturedString().contains("expects a non-empty string as event name.")); + assertEquals(0, AttackQueue.getSize()); + } + + @Test + @StdIo + public void testTrackWithoutContext(StdOut out) throws Exception { + Track.track("my-event"); + assertTrue(out.capturedString().contains("track(...) was called without a context.")); + assertEquals(0, AttackQueue.getSize()); + } + + @Test + @StdIo + public void testTrackWithoutContextOnlyLogsOnce(StdOut out) throws Exception { + Track.track("my-event"); + Track.track("my-event"); + int occurrences = out.capturedString().split("track\\(\\.\\.\\.\\) was called without a context\\.", -1).length - 1; + assertEquals(1, occurrences); + } + + @Test + public void testTrackSendsEventToQueue() throws InterruptedException { + ContextObject context = new EmptySampleContextObject("test", "/track-me", "POST"); + Context.set(context); + + Track.track("my-custom-event"); + + assertEquals(1, AttackQueue.getSize()); + APIEvent event = AttackQueue.get(); + assertInstanceOf(CustomEvent.CustomEventEvent.class, event); + CustomEvent.CustomEventEvent customEvent = (CustomEvent.CustomEventEvent) event; + assertEquals("custom", customEvent.type()); + assertEquals("my-custom-event", customEvent.name()); + assertEquals("POST", customEvent.request().method()); + assertEquals("/track-me", customEvent.request().route()); + } +} diff --git a/agent_api/src/test/java/background/cloud/api/CustomEventTest.java b/agent_api/src/test/java/background/cloud/api/CustomEventTest.java new file mode 100644 index 000000000..d561dfb33 --- /dev/null +++ b/agent_api/src/test/java/background/cloud/api/CustomEventTest.java @@ -0,0 +1,64 @@ +package background.cloud.api; + +import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.context.User; +import org.junit.jupiter.api.Test; +import utils.EmptySampleContextObject; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomEventTest { + + @Test + void createAPIEvent_WithValidContext_ReturnsCustomEventEvent() { + // Arrange + ContextObject context = new EmptySampleContextObject("test", "/api/resource", "POST"); + context.setUser(new User("user-1", "Jane Doe", "192.168.1.1", 1000L)); + + // Act + CustomEvent.CustomEventEvent event = CustomEvent.createAPIEvent("my-custom-event", context); + + // Assert + assertNotNull(event); + assertEquals("custom", event.type()); + assertEquals("my-custom-event", event.name()); + assertNotNull(event.request()); + assertEquals("POST", event.request().method()); + assertEquals("web", event.request().source()); + assertEquals("/api/resource", event.request().route()); + assertEquals("192.168.1.1", event.request().ipAddress()); + assertNotNull(event.agent()); + assertNotNull(event.user()); + assertEquals("user-1", event.user().id()); + assertEquals("Jane Doe", event.user().name()); + assertTrue(event.time() > 0); + } + + @Test + void createAPIEvent_WithNullContext_ReturnsCustomEventEventWithNullRequestAndUser() { + // Act + CustomEvent.CustomEventEvent event = CustomEvent.createAPIEvent("my-custom-event", null); + + // Assert + assertNotNull(event); + assertEquals("custom", event.type()); + assertEquals("my-custom-event", event.name()); + assertNull(event.request()); + assertNull(event.user()); + assertNotNull(event.agent()); + assertTrue(event.time() > 0); + } + + @Test + void createAPIEvent_WithContextButNoUser_ReturnsCustomEventEventWithNullUser() { + // Arrange + ContextObject context = new EmptySampleContextObject("test", "/api/resource", "GET"); + + // Act + CustomEvent.CustomEventEvent event = CustomEvent.createAPIEvent("my-custom-event", context); + + // Assert + assertNull(event.user()); + } +} diff --git a/docs/track.md b/docs/track.md new file mode 100644 index 000000000..92732919e --- /dev/null +++ b/docs/track.md @@ -0,0 +1,48 @@ +# Tracking events + +`track` 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. + +```java +import dev.aikido.agent_api.Track; +import dev.aikido.agent_api.SetUser; + +public void login(HttpServletRequest request) { + User user = authenticate(request); + + if (user == null) { + Track.track("user.login_failed"); + throw new UnauthorizedException(); + } + + SetUser.setUser(new SetUser.UserObject(user.getId(), user.getName())); + Track.track("user.login_succeeded"); +} +``` + +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 + +```java +Track.track("user.signed_up"); +Track.track("user.password_reset_requested"); +Track.track("plan.invite_sent"); +Track.track("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 + +`track` only works inside an HTTP request. If you call it in a background job or outside of a request, nothing gets sent and you'll see a warning in the console. + +If you haven't called `setUser` yet, the event still goes through — it just won't have a user attached. From 524925692111a70ffc93ed0a9e07a10219721282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 2 Oct 2026 15:28:21 +0200 Subject: [PATCH 2/6] Align with latest spec --- .../main/java/dev/aikido/agent_api/Track.java | 9 +++++++ .../cloud/api/events/CustomEvent.java | 16 +++++++++--- .../agent_api/context/ContextObject.java | 9 +++++++ agent_api/src/test/java/TrackTest.java | 26 +++++++++++++++++++ docs/track.md | 10 ++++--- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/agent_api/src/main/java/dev/aikido/agent_api/Track.java b/agent_api/src/main/java/dev/aikido/agent_api/Track.java index ff4b48878..d5e7918e5 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/Track.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/Track.java @@ -10,6 +10,7 @@ public final class Track { private Track() {} private static final Logger logger = LogManager.getLogger(Track.class); + private static final int MAX_EVENTS_PER_REQUEST = 25; private static boolean loggedWarningTrackCalledWithoutContext = false; /** @@ -28,6 +29,14 @@ public static void track(String eventName) { return; } + if (currentContext.incrementTrackedEventCount() > MAX_EVENTS_PER_REQUEST) { + logger.warn( + "track(...) was called more than %d times during this request. Dropping event: %s", + MAX_EVENTS_PER_REQUEST, eventName + ); + return; + } + AttackQueue.add(CustomEvent.createAPIEvent(eventName, currentContext)); } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java index bd15007ff..eba74c011 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java @@ -9,10 +9,19 @@ public final class CustomEvent { private CustomEvent() {} + public record RequestData( + // note that URL is not included in the request metadata + String method, + String ipAddress, + String userAgent, + String source, + String route + ) {} + public record CustomEventEvent( String type, String name, - DetectedAttack.RequestData request, + RequestData request, GetManagerInfo.ManagerInfo agent, User user, long time @@ -29,15 +38,14 @@ public static CustomEventEvent createAPIEvent(String eventName, ContextObject co ); } - private static DetectedAttack.RequestData buildRequestData(ContextObject context) { + private static RequestData buildRequestData(ContextObject context) { if (context == null) { return null; } - return new DetectedAttack.RequestData( + return new RequestData( context.getMethod(), context.getRemoteAddress(), context.getHeader("user-agent"), - context.getUrl(), context.getSource(), context.getRoute() ); diff --git a/agent_api/src/main/java/dev/aikido/agent_api/context/ContextObject.java b/agent_api/src/main/java/dev/aikido/agent_api/context/ContextObject.java index 339358384..787432230 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/context/ContextObject.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/context/ContextObject.java @@ -24,6 +24,7 @@ public class ContextObject { protected transient ArrayList redirectStartNodes; protected transient Map> cache = new HashMap<>(); protected transient Optional forcedProtectionOff = Optional.empty(); + protected transient int trackedEventCount = 0; public boolean middlewareExecuted() {return executedMiddleware; } public void setExecutedMiddleware(boolean value) { executedMiddleware = value; } @@ -104,6 +105,14 @@ public RouteMetadata getRouteMetadata() { return new RouteMetadata(route, url, method); } + /** + * Increments the number of custom events tracked during this request and + * returns the new count. Used to enforce a per-request limit on track(...) calls. + */ + public int incrementTrackedEventCount() { + return ++trackedEventCount; + } + @Override public String toString() { return "ContextObject{" + diff --git a/agent_api/src/test/java/TrackTest.java b/agent_api/src/test/java/TrackTest.java index 6896c78fc..457996e60 100644 --- a/agent_api/src/test/java/TrackTest.java +++ b/agent_api/src/test/java/TrackTest.java @@ -78,4 +78,30 @@ public void testTrackSendsEventToQueue() throws InterruptedException { assertEquals("POST", customEvent.request().method()); assertEquals("/track-me", customEvent.request().route()); } + + @Test + public void testTrackAllowsUpToLimitPerRequest() { + ContextObject context = new EmptySampleContextObject("test", "/track-me", "POST"); + Context.set(context); + + for (int i = 0; i < 25; i++) { + Track.track("event-" + i); + } + + assertEquals(25, AttackQueue.getSize()); + } + + @Test + @StdIo + public void testTrackDropsEventsOverLimitPerRequest(StdOut out) { + ContextObject context = new EmptySampleContextObject("test", "/track-me", "POST"); + Context.set(context); + + for (int i = 0; i < 26; i++) { + Track.track("event-" + i); + } + + assertEquals(25, AttackQueue.getSize()); + assertTrue(out.capturedString().contains("Dropping event: event-25")); + } } diff --git a/docs/track.md b/docs/track.md index 92732919e..69680f647 100644 --- a/docs/track.md +++ b/docs/track.md @@ -1,6 +1,6 @@ # Tracking events -`track` 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. +`track` lets you record things happening in your app — like failed logins, signups, or password resets. [Playbooks](https://help.aikido.dev/zen-firewall/zen-features/playbooks) can act when an event occurs repeatedly, for example by blocking an IP after three failed logins in five minutes. ```java import dev.aikido.agent_api.Track; @@ -19,7 +19,9 @@ public void login(HttpServletRequest request) { } ``` -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. +After adding `Track.track(...)`, trigger the event at least once. It will then appear on the Playbooks page in the Aikido dashboard. From there, you can create a playbook and choose what should happen when the event occurs. Calling `Track.track(...)` by itself does not create a playbook or block anything. + +Call `Track.track(...)` while handling an HTTP request. Zen associates the event with the request's IP address, and playbook counts are per IP, not across your whole app. Zen also picks up the user agent and current user (if you called [`setUser`](./user.md)) — you don't need to pass those yourself. Calling `setUser` is optional; events without a user are still tracked. ## More examples @@ -32,7 +34,7 @@ Track.track("payment.failed"); ## Naming events -Use lowercase with dots to group related events: +Event names can use any format. We recommend lowercase, dot-separated names to group related events, such as: - `user.login_failed` - `user.login_succeeded` @@ -45,4 +47,4 @@ Use lowercase with dots to group related events: `track` only works inside an HTTP request. If you call it in a background job or outside of a request, nothing gets sent and you'll see a warning in the console. -If you haven't called `setUser` yet, the event still goes through — it just won't have a user attached. +Each request accepts up to 25 tracked events. Any further calls within the same request are dropped and logged as a warning. From 7ca7dd1b896be774fe6d1b66a5d9506306e0b33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 2 Oct 2026 18:40:05 +0200 Subject: [PATCH 3/6] Fix review comments --- README.md | 5 +- .../main/java/dev/aikido/agent_api/Track.java | 34 +++++++---- .../cloud/api/events/CustomEvent.java | 59 +++++++++---------- agent_api/src/test/java/TrackTest.java | 30 ++++++++-- .../background/cloud/api/CustomEventTest.java | 21 +++---- docs/track.md | 32 ++-------- 6 files changed, 92 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index ff89b0252..84f4e9053 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ To activate Zen you then just have to add the following `-javaagent` to your Jav ``` java -javaagent:/opt/zen/agent.jar -jar build/myapp.jar ``` -To use user-blocking and/or rate-limiting features, you will have to include the following Jarfile into your repository +To use user blocking, rate limiting, or custom event tracking, include `agent_api.jar` in your application. ### Gradle Add the following code to your `build.gradle` file. ```gradle @@ -130,7 +130,8 @@ See [Reporting to Aikido](#reporting-to-your-aikido-security-dashboard) to learn ## Additional configuration -[Configure Zen using environment variables for authentication, mode settings, debugging, and more.](https://help.aikido.dev/doc/configuration-via-env-vars/docrSItUkeR9) +* [Track custom events](./docs/track.md): trigger events that Playbooks can act on +* [Configure Zen using environment variables for authentication, mode settings, debugging, and more](https://help.aikido.dev/doc/configuration-via-env-vars/docrSItUkeR9) ## License diff --git a/agent_api/src/main/java/dev/aikido/agent_api/Track.java b/agent_api/src/main/java/dev/aikido/agent_api/Track.java index d5e7918e5..c758e8abf 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/Track.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/Track.java @@ -3,15 +3,18 @@ import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; import dev.aikido.agent_api.context.Context; import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.helpers.env.Token; import dev.aikido.agent_api.helpers.logging.LogManager; import dev.aikido.agent_api.helpers.logging.Logger; import dev.aikido.agent_api.storage.AttackQueue; +import java.util.concurrent.atomic.AtomicBoolean; public final class Track { private Track() {} + private static final Logger logger = LogManager.getLogger(Track.class); private static final int MAX_EVENTS_PER_REQUEST = 25; - private static boolean loggedWarningTrackCalledWithoutContext = false; + private static final AtomicBoolean loggedWarningTrackCalledWithoutContext = new AtomicBoolean(false); /** * External function for applications to track a custom event, e.g. a @@ -29,11 +32,19 @@ public static void track(String eventName) { return; } - if (currentContext.incrementTrackedEventCount() > MAX_EVENTS_PER_REQUEST) { - logger.warn( - "track(...) was called more than %d times during this request. Dropping event: %s", - MAX_EVENTS_PER_REQUEST, eventName - ); + // No reporting worker runs without a token, so queued events would remain in memory. + if (Token.fromEnv() == null) { + return; + } + + int trackedEventCount = currentContext.incrementTrackedEventCount(); + if (trackedEventCount > MAX_EVENTS_PER_REQUEST) { + if (trackedEventCount == MAX_EVENTS_PER_REQUEST + 1) { + logger.warn( + "track(...) was called more than %d times during this request. " + + "Only the first %d events were tracked.", + MAX_EVENTS_PER_REQUEST, MAX_EVENTS_PER_REQUEST); + } return; } @@ -41,20 +52,17 @@ public static void track(String eventName) { } private static void logWarningTrackCalledWithoutContext() { - if (loggedWarningTrackCalledWithoutContext) { + if (!loggedWarningTrackCalledWithoutContext.compareAndSet(false, true)) { return; } - logger.warn( - "track(...) was called without a context. The event will not be tracked. " + - "Make sure to call track(...) within an HTTP request." - ); - loggedWarningTrackCalledWithoutContext = true; + logger.warn("track(...) was called without a context. The event will not be tracked. " + + "Make sure to call track(...) within an HTTP request."); } /** * Resets internal warning state. Only intended for use in tests. */ public static void reset() { - loggedWarningTrackCalledWithoutContext = false; + loggedWarningTrackCalledWithoutContext.set(false); } } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java index eba74c011..11cee571d 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java @@ -1,41 +1,41 @@ package dev.aikido.agent_api.background.cloud.api.events; +import static dev.aikido.agent_api.background.cloud.GetManagerInfo.getManagerInfo; +import static dev.aikido.agent_api.helpers.UnixTimeMS.getUnixTimeMS; + import dev.aikido.agent_api.background.cloud.GetManagerInfo; import dev.aikido.agent_api.context.ContextObject; import dev.aikido.agent_api.context.User; -import static dev.aikido.agent_api.background.cloud.GetManagerInfo.getManagerInfo; -import static dev.aikido.agent_api.helpers.UnixTimeMS.getUnixTimeMS; - public final class CustomEvent { private CustomEvent() {} + public record RequestData( - // note that URL is not included in the request metadata - String method, - String ipAddress, - String userAgent, - String source, - String route - ) {} + // note that URL is not included in the request metadata + String method, String ipAddress, String userAgent, String source, String route) {} + + public record UserData(String id, String name) {} public record CustomEventEvent( - String type, - String name, - RequestData request, - GetManagerInfo.ManagerInfo agent, - User user, - long time - ) implements APIEvent {} + String type, String name, RequestData request, GetManagerInfo.ManagerInfo agent, UserData user, long time) + implements APIEvent {} public static CustomEventEvent createAPIEvent(String eventName, ContextObject context) { return new CustomEventEvent( - "custom", // type - eventName, // name - buildRequestData(context), // request - getManagerInfo(), // agent - context != null ? context.getUser() : null, // user - getUnixTimeMS() // time - ); + "custom", + eventName, + buildRequestData(context), + getManagerInfo(), + buildUserData(context), + getUnixTimeMS()); + } + + private static UserData buildUserData(ContextObject context) { + if (context == null || context.getUser() == null) { + return null; + } + User user = context.getUser(); + return new UserData(user.id(), user.name()); } private static RequestData buildRequestData(ContextObject context) { @@ -43,11 +43,10 @@ private static RequestData buildRequestData(ContextObject context) { return null; } return new RequestData( - context.getMethod(), - context.getRemoteAddress(), - context.getHeader("user-agent"), - context.getSource(), - context.getRoute() - ); + context.getMethod(), + context.getRemoteAddress(), + context.getHeader("user-agent"), + context.getSource(), + context.getRoute()); } } diff --git a/agent_api/src/test/java/TrackTest.java b/agent_api/src/test/java/TrackTest.java index 457996e60..40cc34b22 100644 --- a/agent_api/src/test/java/TrackTest.java +++ b/agent_api/src/test/java/TrackTest.java @@ -1,3 +1,5 @@ +import static org.junit.jupiter.api.Assertions.*; + import dev.aikido.agent_api.Track; import dev.aikido.agent_api.background.cloud.api.events.APIEvent; import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; @@ -5,13 +7,12 @@ import dev.aikido.agent_api.context.ContextObject; import dev.aikido.agent_api.storage.AttackQueue; import org.junit.jupiter.api.*; +import org.junitpioneer.jupiter.ClearEnvironmentVariable; import org.junitpioneer.jupiter.SetEnvironmentVariable; import org.junitpioneer.jupiter.StdIo; import org.junitpioneer.jupiter.StdOut; import utils.EmptySampleContextObject; -import static org.junit.jupiter.api.Assertions.*; - @SetEnvironmentVariable(key = "AIKIDO_LOG_LEVEL", value = "trace") @SetEnvironmentVariable(key = "AIKIDO_TOKEN", value = "invalid-token-2") public class TrackTest { @@ -58,10 +59,22 @@ public void testTrackWithoutContext(StdOut out) throws Exception { public void testTrackWithoutContextOnlyLogsOnce(StdOut out) throws Exception { Track.track("my-event"); Track.track("my-event"); - int occurrences = out.capturedString().split("track\\(\\.\\.\\.\\) was called without a context\\.", -1).length - 1; + int occurrences = + out.capturedString().split("track\\(\\.\\.\\.\\) was called without a context\\.", -1).length - 1; assertEquals(1, occurrences); } + @Test + @ClearEnvironmentVariable(key = "AIKIDO_TOKEN") + public void testTrackWithoutTokenDoesNotQueueEvent() { + ContextObject context = new EmptySampleContextObject("test", "/track-me", "POST"); + Context.set(context); + + Track.track("my-custom-event"); + + assertEquals(0, AttackQueue.getSize()); + } + @Test public void testTrackSendsEventToQueue() throws InterruptedException { ContextObject context = new EmptySampleContextObject("test", "/track-me", "POST"); @@ -93,15 +106,20 @@ public void testTrackAllowsUpToLimitPerRequest() { @Test @StdIo - public void testTrackDropsEventsOverLimitPerRequest(StdOut out) { + public void testTrackDropsEventsOverLimitAndLogsWarningOnlyOnce(StdOut out) { ContextObject context = new EmptySampleContextObject("test", "/track-me", "POST"); Context.set(context); - for (int i = 0; i < 26; i++) { + for (int i = 0; i < 30; i++) { Track.track("event-" + i); } assertEquals(25, AttackQueue.getSize()); - assertTrue(out.capturedString().contains("Dropping event: event-25")); + long warningCount = out.capturedString() + .lines() + .filter(line -> line.contains("Only the first 25 events were tracked.")) + .count(); + assertEquals(1, warningCount); + assertFalse(out.capturedString().contains("event-25")); } } diff --git a/agent_api/src/test/java/background/cloud/api/CustomEventTest.java b/agent_api/src/test/java/background/cloud/api/CustomEventTest.java index d561dfb33..6692daebc 100644 --- a/agent_api/src/test/java/background/cloud/api/CustomEventTest.java +++ b/agent_api/src/test/java/background/cloud/api/CustomEventTest.java @@ -1,25 +1,25 @@ package background.cloud.api; +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; import dev.aikido.agent_api.context.ContextObject; import dev.aikido.agent_api.context.User; +import java.util.Set; import org.junit.jupiter.api.Test; import utils.EmptySampleContextObject; -import static org.junit.jupiter.api.Assertions.*; - class CustomEventTest { @Test void createAPIEvent_WithValidContext_ReturnsCustomEventEvent() { - // Arrange ContextObject context = new EmptySampleContextObject("test", "/api/resource", "POST"); context.setUser(new User("user-1", "Jane Doe", "192.168.1.1", 1000L)); - // Act CustomEvent.CustomEventEvent event = CustomEvent.createAPIEvent("my-custom-event", context); - // Assert assertNotNull(event); assertEquals("custom", event.type()); assertEquals("my-custom-event", event.name()); @@ -33,14 +33,18 @@ void createAPIEvent_WithValidContext_ReturnsCustomEventEvent() { assertEquals("user-1", event.user().id()); assertEquals("Jane Doe", event.user().name()); assertTrue(event.time() > 0); + + JsonObject serializedUser = + new Gson().toJsonTree(event).getAsJsonObject().getAsJsonObject("user"); + assertEquals(Set.of("id", "name"), serializedUser.keySet()); + assertEquals("user-1", serializedUser.get("id").getAsString()); + assertEquals("Jane Doe", serializedUser.get("name").getAsString()); } @Test void createAPIEvent_WithNullContext_ReturnsCustomEventEventWithNullRequestAndUser() { - // Act CustomEvent.CustomEventEvent event = CustomEvent.createAPIEvent("my-custom-event", null); - // Assert assertNotNull(event); assertEquals("custom", event.type()); assertEquals("my-custom-event", event.name()); @@ -52,13 +56,10 @@ void createAPIEvent_WithNullContext_ReturnsCustomEventEventWithNullRequestAndUse @Test void createAPIEvent_WithContextButNoUser_ReturnsCustomEventEventWithNullUser() { - // Arrange ContextObject context = new EmptySampleContextObject("test", "/api/resource", "GET"); - // Act CustomEvent.CustomEventEvent event = CustomEvent.createAPIEvent("my-custom-event", context); - // Assert assertNull(event.user()); } } diff --git a/docs/track.md b/docs/track.md index 69680f647..221f78f87 100644 --- a/docs/track.md +++ b/docs/track.md @@ -1,6 +1,6 @@ -# Tracking events +# Track custom events -`track` lets you record things happening in your app — like failed logins, signups, or password resets. [Playbooks](https://help.aikido.dev/zen-firewall/zen-features/playbooks) can act when an event occurs repeatedly, for example by blocking an IP after three failed logins in five minutes. +Use `Track.track(...)` to report events that only your application knows about, such as failed logins. [Playbooks](https://help.aikido.dev/zen-firewall/zen-features/playbooks) can act when an event occurs repeatedly, for example by blocking an IP after three failed logins in five minutes. ```java import dev.aikido.agent_api.Track; @@ -21,30 +21,6 @@ public void login(HttpServletRequest request) { After adding `Track.track(...)`, trigger the event at least once. It will then appear on the Playbooks page in the Aikido dashboard. From there, you can create a playbook and choose what should happen when the event occurs. Calling `Track.track(...)` by itself does not create a playbook or block anything. -Call `Track.track(...)` while handling an HTTP request. Zen associates the event with the request's IP address, and playbook counts are per IP, not across your whole app. Zen also picks up the user agent and current user (if you called [`setUser`](./user.md)) — you don't need to pass those yourself. Calling `setUser` is optional; events without a user are still tracked. +Call `Track.track(...)` while handling an HTTP request. Zen associates the event with the request's IP address. Playbook counts are per IP, not across your whole app. If you call [`setUser`](./user.md) before `Track.track(...)`, Zen also includes the current user. Calling `setUser` is optional — events without a user are still tracked. -## More examples - -```java -Track.track("user.signed_up"); -Track.track("user.password_reset_requested"); -Track.track("plan.invite_sent"); -Track.track("payment.failed"); -``` - -## Naming events - -Event names can use any format. We recommend lowercase, dot-separated names to group related events, such as: - -- `user.login_failed` -- `user.login_succeeded` -- `user.signed_up` -- `user.password_reset_requested` -- `payment.failed` -- `plan.invite_sent` - -## Things to know - -`track` only works inside an HTTP request. If you call it in a background job or outside of a request, nothing gets sent and you'll see a warning in the console. - -Each request accepts up to 25 tracked events. Any further calls within the same request are dropped and logged as a warning. +Event names can use any format. We recommend lowercase, dot-separated names such as `user.login_failed`. From f65225ba0fd39e61f17287bfbf0fe814ea3eb579 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 9 Oct 2026 13:05:05 +0200 Subject: [PATCH 4/6] Improvements --- .../main/java/dev/aikido/agent_api/Track.java | 22 +++++++- .../cloud/api/events/CustomEvent.java | 5 +- .../aikido/agent_api/storage/AttackQueue.java | 4 +- .../background/cloud/api/CustomEventTest.java | 13 ----- .../{ => dev/aikido/agent_api}/TrackTest.java | 50 +++++++++++++++---- .../test/java/storage/AttackQueueTest.java | 25 ++++++++++ docs/track.md | 3 ++ end2end/spring_boot_postgres.py | 4 ++ end2end/utils/test_custom_events.py | 33 ++++++++++++ .../com/example/demo/TrackController.java | 14 ++++++ 10 files changed, 143 insertions(+), 30 deletions(-) rename agent_api/src/test/java/{ => dev/aikido/agent_api}/TrackTest.java (71%) create mode 100644 agent_api/src/test/java/storage/AttackQueueTest.java create mode 100644 end2end/utils/test_custom_events.py create mode 100644 sample-apps/SpringBootPostgres/src/main/java/com/example/demo/TrackController.java diff --git a/agent_api/src/main/java/dev/aikido/agent_api/Track.java b/agent_api/src/main/java/dev/aikido/agent_api/Track.java index c758e8abf..d3be8ac73 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/Track.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/Track.java @@ -3,6 +3,7 @@ import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; import dev.aikido.agent_api.context.Context; import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.context.SpringWebfluxContextObject; import dev.aikido.agent_api.helpers.env.Token; import dev.aikido.agent_api.helpers.logging.LogManager; import dev.aikido.agent_api.helpers.logging.Logger; @@ -15,6 +16,7 @@ private Track() {} private static final Logger logger = LogManager.getLogger(Track.class); private static final int MAX_EVENTS_PER_REQUEST = 25; private static final AtomicBoolean loggedWarningTrackCalledWithoutContext = new AtomicBoolean(false); + private static final AtomicBoolean loggedWarningWebfluxNotSupported = new AtomicBoolean(false); /** * External function for applications to track a custom event, e.g. a @@ -28,7 +30,15 @@ public static void track(String eventName) { ContextObject currentContext = Context.get(); if (currentContext == null) { - logWarningTrackCalledWithoutContext(); + if (!Context.isBypassed()) { + logWarningTrackCalledWithoutContext(); + } + return; + } + + // WebFlux's shared threads can hold another request's context, so we'd send the wrong IP and user. + if (currentContext instanceof SpringWebfluxContextObject) { + logWarningWebfluxNotSupported(); return; } @@ -59,10 +69,18 @@ private static void logWarningTrackCalledWithoutContext() { + "Make sure to call track(...) within an HTTP request."); } + private static void logWarningWebfluxNotSupported() { + if (!loggedWarningWebfluxNotSupported.compareAndSet(false, true)) { + return; + } + logger.warn("track(...) is not supported for Spring WebFlux yet. The event will not be tracked."); + } + /** * Resets internal warning state. Only intended for use in tests. */ - public static void reset() { + static void reset() { loggedWarningTrackCalledWithoutContext.set(false); + loggedWarningWebfluxNotSupported.set(false); } } diff --git a/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java index 11cee571d..53725a0ec 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/CustomEvent.java @@ -31,7 +31,7 @@ public static CustomEventEvent createAPIEvent(String eventName, ContextObject co } private static UserData buildUserData(ContextObject context) { - if (context == null || context.getUser() == null) { + if (context.getUser() == null) { return null; } User user = context.getUser(); @@ -39,9 +39,6 @@ private static UserData buildUserData(ContextObject context) { } private static RequestData buildRequestData(ContextObject context) { - if (context == null) { - return null; - } return new RequestData( context.getMethod(), context.getRemoteAddress(), diff --git a/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java b/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java index 8bde5a36e..86a0fd5f8 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java @@ -14,7 +14,9 @@ public final class AttackQueue { private static final Logger logger = LogManager.getLogger(AttackQueue.class); - private static final BlockingQueue queue = new LinkedBlockingQueue<>(); + // Events pile up while Aikido's API is slow or down, so cap memory use. add() drops events when full. + private static final int MAX_QUEUE_SIZE = 100; + private static final BlockingQueue queue = new LinkedBlockingQueue<>(MAX_QUEUE_SIZE); private AttackQueue() { diff --git a/agent_api/src/test/java/background/cloud/api/CustomEventTest.java b/agent_api/src/test/java/background/cloud/api/CustomEventTest.java index 6692daebc..872838280 100644 --- a/agent_api/src/test/java/background/cloud/api/CustomEventTest.java +++ b/agent_api/src/test/java/background/cloud/api/CustomEventTest.java @@ -41,19 +41,6 @@ void createAPIEvent_WithValidContext_ReturnsCustomEventEvent() { assertEquals("Jane Doe", serializedUser.get("name").getAsString()); } - @Test - void createAPIEvent_WithNullContext_ReturnsCustomEventEventWithNullRequestAndUser() { - CustomEvent.CustomEventEvent event = CustomEvent.createAPIEvent("my-custom-event", null); - - assertNotNull(event); - assertEquals("custom", event.type()); - assertEquals("my-custom-event", event.name()); - assertNull(event.request()); - assertNull(event.user()); - assertNotNull(event.agent()); - assertTrue(event.time() > 0); - } - @Test void createAPIEvent_WithContextButNoUser_ReturnsCustomEventEventWithNullUser() { ContextObject context = new EmptySampleContextObject("test", "/api/resource", "GET"); diff --git a/agent_api/src/test/java/TrackTest.java b/agent_api/src/test/java/dev/aikido/agent_api/TrackTest.java similarity index 71% rename from agent_api/src/test/java/TrackTest.java rename to agent_api/src/test/java/dev/aikido/agent_api/TrackTest.java index 40cc34b22..3fa196688 100644 --- a/agent_api/src/test/java/TrackTest.java +++ b/agent_api/src/test/java/dev/aikido/agent_api/TrackTest.java @@ -1,11 +1,22 @@ +package dev.aikido.agent_api; + +import static dev.aikido.agent_api.helpers.UnixTimeMS.getUnixTimeMS; import static org.junit.jupiter.api.Assertions.*; +import static utils.EmptyAPIResponses.emptyAPIResponse; -import dev.aikido.agent_api.Track; +import dev.aikido.agent_api.background.cloud.api.APIResponse; import dev.aikido.agent_api.background.cloud.api.events.APIEvent; import dev.aikido.agent_api.background.cloud.api.events.CustomEvent; +import dev.aikido.agent_api.collectors.WebRequestCollector; import dev.aikido.agent_api.context.Context; import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.context.SpringWebfluxContextObject; import dev.aikido.agent_api.storage.AttackQueue; +import dev.aikido.agent_api.storage.ServiceConfigStore; +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.*; import org.junitpioneer.jupiter.ClearEnvironmentVariable; import org.junitpioneer.jupiter.SetEnvironmentVariable; @@ -17,16 +28,11 @@ @SetEnvironmentVariable(key = "AIKIDO_TOKEN", value = "invalid-token-2") public class TrackTest { @BeforeEach - public void setup() { - Context.set(null); - AttackQueue.clear(); - Track.reset(); - } - @AfterEach - public void tearDown() { - Context.set(null); + public void resetState() { + Context.reset(); AttackQueue.clear(); + ServiceConfigStore.updateFromAPIResponse(emptyAPIResponse); Track.reset(); } @@ -64,6 +70,31 @@ public void testTrackWithoutContextOnlyLogsOnce(StdOut out) throws Exception { assertEquals(1, occurrences); } + @Test + @StdIo + public void testTrackForBypassedIpDoesNotQueueOrWarn(StdOut out) { + List bypassedIps = List.of("192.168.1.1"); + ServiceConfigStore.updateFromAPIResponse(new APIResponse( + true, "", getUnixTimeMS(), List.of(), List.of(), bypassedIps, false, null, true, false, List.of(), 0)); + WebRequestCollector.report(new EmptySampleContextObject("test", "/track-me", "POST")); + + Track.track("my-custom-event"); + + assertEquals(0, AttackQueue.getSize()); + assertFalse(out.capturedString().contains("track(...) was called without a context.")); + } + + @Test + public void testTrackDoesNotQueueEventForWebfluxRequest() { + Context.set(new SpringWebfluxContextObject( + "POST", "http://localhost/login", new InetSocketAddress("1.2.3.4", 443), + new HashMap<>(), Map.of(), Map.of())); + + Track.track("my-custom-event"); + + assertEquals(0, AttackQueue.getSize()); + } + @Test @ClearEnvironmentVariable(key = "AIKIDO_TOKEN") public void testTrackWithoutTokenDoesNotQueueEvent() { @@ -120,6 +151,5 @@ public void testTrackDropsEventsOverLimitAndLogsWarningOnlyOnce(StdOut out) { .filter(line -> line.contains("Only the first 25 events were tracked.")) .count(); assertEquals(1, warningCount); - assertFalse(out.capturedString().contains("event-25")); } } diff --git a/agent_api/src/test/java/storage/AttackQueueTest.java b/agent_api/src/test/java/storage/AttackQueueTest.java new file mode 100644 index 000000000..6d6258d98 --- /dev/null +++ b/agent_api/src/test/java/storage/AttackQueueTest.java @@ -0,0 +1,25 @@ +package storage; + +import dev.aikido.agent_api.background.cloud.api.events.APIEvent; +import dev.aikido.agent_api.storage.AttackQueue; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AttackQueueTest { + @BeforeEach + @AfterEach + public void clearQueue() { + AttackQueue.clear(); + } + + @Test + public void testDropsEventsWhenFull() { + for (int i = 0; i < 150; i++) { + AttackQueue.add(new APIEvent() {}); + } + assertEquals(100, AttackQueue.getSize()); + } +} diff --git a/docs/track.md b/docs/track.md index 221f78f87..3ee89a70f 100644 --- a/docs/track.md +++ b/docs/track.md @@ -23,4 +23,7 @@ After adding `Track.track(...)`, trigger the event at least once. It will then a Call `Track.track(...)` while handling an HTTP request. Zen associates the event with the request's IP address. Playbook counts are per IP, not across your whole app. If you call [`setUser`](./user.md) before `Track.track(...)`, Zen also includes the current user. Calling `setUser` is optional — events without a user are still tracked. +> [!NOTE] +> `Track.track(...)` doesn't support Spring WebFlux yet. + Event names can use any format. We recommend lowercase, dot-separated names such as `user.login_failed`. diff --git a/end2end/spring_boot_postgres.py b/end2end/spring_boot_postgres.py index a5ce1100a..d201e0a99 100644 --- a/end2end/spring_boot_postgres.py +++ b/end2end/spring_boot_postgres.py @@ -1,4 +1,5 @@ from utils import App, Request +from utils.test_custom_events import test_custom_events spring_boot_postgres_app = App(8080) @@ -21,3 +22,6 @@ ) spring_boot_postgres_app.test_all_payloads() + +test_custom_events(spring_boot_postgres_app.urls["enabled"], spring_boot_postgres_app.event_handler) +print("✅ Tested custom events") diff --git a/end2end/utils/test_custom_events.py b/end2end/utils/test_custom_events.py new file mode 100644 index 000000000..04e3cb0e7 --- /dev/null +++ b/end2end/utils/test_custom_events.py @@ -0,0 +1,33 @@ +import time +import requests +from utils.assert_equals import assert_eq +from utils.EventHandler import filter_on_event_type + +def wait_for_custom_events(event_handler, timeout=10): + deadline = time.time() + timeout + while time.time() < deadline: + custom_events = filter_on_event_type(event_handler.fetch_events_from_mock(), "custom") + if custom_events: + return custom_events + time.sleep(0.5) + return [] + +def test_custom_events(url, event_handler): + event_handler.reset() + + res = requests.post(url + "/api/track", headers={ + "X-Forwarded-For": "203.0.113.7", + "User-Agent": "zen-e2e", + }) + assert_eq(res.status_code, equals=200) + + custom_events = wait_for_custom_events(event_handler) + assert_eq(len(custom_events), equals=1) + event = custom_events[0] + assert_eq(event["name"], equals="user.login_failed") + assert_eq(event["request"]["ipAddress"], equals="203.0.113.7") + assert_eq(event["request"]["userAgent"], equals="zen-e2e") + assert_eq(event["request"]["method"], equals="POST") + assert_eq(event["request"]["route"], equals="/api/track") + assert_eq(event["agent"]["library"], equals="firewall-java") + assert "user" not in event diff --git a/sample-apps/SpringBootPostgres/src/main/java/com/example/demo/TrackController.java b/sample-apps/SpringBootPostgres/src/main/java/com/example/demo/TrackController.java new file mode 100644 index 000000000..117b55917 --- /dev/null +++ b/sample-apps/SpringBootPostgres/src/main/java/com/example/demo/TrackController.java @@ -0,0 +1,14 @@ +package com.example.demo; + +import dev.aikido.agent_api.Track; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/track") +public class TrackController { + @PostMapping + public String track() { + Track.track("user.login_failed"); + return "OK"; + } +} From d0cbadd27730f62099d5ededb91e21a034d651f5 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Tue, 20 Oct 2026 13:32:43 +0200 Subject: [PATCH 5/6] Align track docs with the other agents --- docs/track.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/docs/track.md b/docs/track.md index 3ee89a70f..09bf173eb 100644 --- a/docs/track.md +++ b/docs/track.md @@ -1,29 +1,18 @@ # Track custom events -Use `Track.track(...)` to report events that only your application knows about, such as failed logins. [Playbooks](https://help.aikido.dev/zen-firewall/zen-features/playbooks) can act when an event occurs repeatedly, for example by blocking an IP after three failed logins in five minutes. +Use `Track.track()` to report events that only your application knows about, such as failed logins. [Playbooks](https://help.aikido.dev/zen-firewall/zen-features/playbooks) can act when an event occurs repeatedly, for example by blocking an IP after three failed logins in five minutes. ```java import dev.aikido.agent_api.Track; -import dev.aikido.agent_api.SetUser; -public void login(HttpServletRequest request) { - User user = authenticate(request); - - if (user == null) { - Track.track("user.login_failed"); - throw new UnauthorizedException(); - } - - SetUser.setUser(new SetUser.UserObject(user.getId(), user.getName())); - Track.track("user.login_succeeded"); +if (!authenticate(request)) { + Track.track("user.login_failed"); + throw new UnauthorizedException(); } ``` -After adding `Track.track(...)`, trigger the event at least once. It will then appear on the Playbooks page in the Aikido dashboard. From there, you can create a playbook and choose what should happen when the event occurs. Calling `Track.track(...)` by itself does not create a playbook or block anything. - -Call `Track.track(...)` while handling an HTTP request. Zen associates the event with the request's IP address. Playbook counts are per IP, not across your whole app. If you call [`setUser`](./user.md) before `Track.track(...)`, Zen also includes the current user. Calling `setUser` is optional — events without a user are still tracked. +After adding `Track.track()`, trigger the event at least once. It will then appear on the Playbooks page in the Aikido dashboard. From there, you can create a playbook and choose what should happen when the event occurs. Calling `Track.track()` by itself does not create a playbook or block anything. -> [!NOTE] -> `Track.track(...)` doesn't support Spring WebFlux yet. +Call `Track.track()` while handling an HTTP request. Zen associates the event with the request's IP address. Playbook counts are per IP, not across your whole app. If you call [`SetUser.setUser()`](./user.md) before `Track.track()`, Zen also includes the current user. `SetUser.setUser()` is optional. Events without a user are still tracked. Event names can use any format. We recommend lowercase, dot-separated names such as `user.login_failed`. From 6446b83b281f3965a03df9eef91b9c88a0d39576 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Tue, 20 Oct 2026 13:36:47 +0200 Subject: [PATCH 6/6] Reword queue cap comment --- .../src/main/java/dev/aikido/agent_api/storage/AttackQueue.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java b/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java index 86a0fd5f8..94dfbad04 100644 --- a/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java +++ b/agent_api/src/main/java/dev/aikido/agent_api/storage/AttackQueue.java @@ -14,7 +14,7 @@ public final class AttackQueue { private static final Logger logger = LogManager.getLogger(AttackQueue.class); - // Events pile up while Aikido's API is slow or down, so cap memory use. add() drops events when full. + // Keeps memory bounded when our API is slow or down. add() drops new events when the queue is full. private static final int MAX_QUEUE_SIZE = 100; private static final BlockingQueue queue = new LinkedBlockingQueue<>(MAX_QUEUE_SIZE);