From 98f5060bc507904647a2d9ff25167bfb25cae57d 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] 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.