Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
a67d527
Add track() API for custom event tracking
hansott Mar 30, 2026
4c173f9
Remove unused metadata field from TrackedEvent
hansott Mar 30, 2026
eb3912e
Send tracked events to the realtime endpoint
hansott Mar 30, 2026
cb5b883
Merge branch 'main' of github.com:AikidoSec/firewall-node into add-tr…
hansott Apr 5, 2026
629a1a8
Fix user events endpoint and drop userAgent
hansott Apr 5, 2026
994a802
Replace config polling with SSE streaming
hansott Apr 6, 2026
abbf9b6
Simplify SSE connection and remove unused cleanup
hansott Apr 6, 2026
3498454
Add login route with track events to express-mysql sample app
hansott Apr 6, 2026
6084a60
Add AIKIDO_DEBUG_SSE env var and remove polling fallback
hansott Apr 6, 2026
e91caf2
Clear existing timer before scheduling SSE reconnect
hansott Apr 6, 2026
dfafad2
Merge branch 'main' of github.com:AikidoSec/firewall-node into add-tr…
hansott Apr 21, 2026
5f76bbb
Add config updated polling again
timokoessler Apr 24, 2026
03c66ce
Merge branch 'main' of github.com:AikidoSec/firewall-node into add-tr…
hansott May 15, 2026
07fe7c4
Merge branch 'main' of github.com:AikidoSec/firewall-node into add-tr…
hansott May 15, 2026
713b86d
Use zen.aikido.dev as default realtime hostname
hansott May 15, 2026
e58b480
Use getRealtimeURL directly for SSE connections
hansott May 19, 2026
f279f5b
Add read timeout to SSE client
hansott May 19, 2026
7c62ffd
Inline ConfigUpdateOptions into each consumer
hansott May 19, 2026
c36921c
Inline pollingURL variable
hansott May 19, 2026
d0e97c6
Retry polling URL probe with backoff
hansott May 19, 2026
4d6aeb0
Simplify SSE reconnect backoff
hansott May 19, 2026
692609e
Add SSE stream endpoint to mock server and e2e test
hansott May 20, 2026
081846c
Add logDebug helper to SSE modules
hansott May 20, 2026
b4423cd
Skip SSE when realtime endpoint is unreachable
hansott May 20, 2026
4c50247
Fix mock SSE event data to match zen-realtime
hansott May 20, 2026
682918e
Add SSE reconnect e2e test
hansott May 20, 2026
1227246
Log and ignore invalid SSE config-updated payloads
hansott May 20, 2026
7703dea
Format code
hansott May 20, 2026
86e3e74
Remove token from sink tests to avoid probe hostname leak
hansott May 20, 2026
85cc33a
Drain probeRealtimeURL fetch timeout in Agent tests
hansott May 20, 2026
a88ae85
Allow localhost in outbound blocking e2e test
hansott May 20, 2026
abddb97
Account for SSE connection in heartbeat hostname hits
hansott May 20, 2026
55f9c2a
Add jitter and stable-connection backoff to SSE reconnect
hansott May 20, 2026
b88692c
Stop SSE reconnect on 401 and 403
hansott May 20, 2026
4397a76
Allow localhost in outbound blocking e2e test
hansott May 20, 2026
8924af4
Add token to Fetch test to fix attack event assertion
hansott May 20, 2026
d4d0c9a
Account for probe hostname in Fetch and Undici tests
hansott May 20, 2026
708666f
Sort hostnames in Undici test to fix ordering flake
hansott May 20, 2026
6e49962
Restore token in HTTPRequest test for attack event assertion
hansott May 20, 2026
ed006ef
Clear probe hostname before sink test assertions
hansott May 20, 2026
5ae3353
Wait for realtime probe before sink test assertions
hansott May 20, 2026
c6499bb
Merge branch 'main' into add-track-api
timokoessler May 21, 2026
71839c9
Fix double backoff in SSE reconnect
hansott May 21, 2026
905bc15
Add unit tests for connectToSSE
hansott May 21, 2026
1d05922
Consolidate SSE unit tests into fewer files
hansott May 21, 2026
ea5c50c
Add unit test for SSE read timeout
hansott May 21, 2026
29f937f
Reset backoff on SSE socket timeout
hansott May 21, 2026
e3656fa
Split connectToSSE into connect + reconnect loop
hansott May 21, 2026
18fd832
Log SSE loop errors instead of swallowing them
hansott May 21, 2026
768dfa8
Fix SSE test compat with Node 16 and Node 26
hansott May 21, 2026
8520477
Avoid t.match with regex inside array in 401 test
hansott May 21, 2026
16a288a
Use setTimeout from node:timers/promises
hansott May 22, 2026
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
48 changes: 48 additions & 0 deletions docs/track.md
Original file line number Diff line number Diff line change
@@ -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.

```js
const Zen = require("@aikidosec/firewall");

app.post("/login", async (req, res) => {
const user = await authenticate(req.body.username, req.body.password);

if (!user) {
Zen.track("user.login_failed");
return res.status(401).json({ error: "Invalid credentials" });
}

Zen.setUser({ id: user.id });
Zen.track("user.login_succeeded");
res.json({ token: createToken(user) });
});
```

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

```js
Zen.track("user.signed_up");
Zen.track("user.password_reset_requested");
Zen.track("plan.invite_sent");
Zen.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 a script, 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 ID attached.
5 changes: 5 additions & 0 deletions end2end/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { updateConfig } from "./src/handlers/updateConfig.ts";
import { lists } from "./src/handlers/lists.ts";
import { updateIPLists } from "./src/handlers/updateLists.ts";
import { realtimeConfig } from "./src/handlers/realtimeConfig.ts";
import { stream, disconnectStreams } from "./src/handlers/stream.ts";
import { deleteApp } from "./src/handlers/deleteApp.ts";

const app = express();
app.set("trust proxy", false);
Expand All @@ -24,6 +26,8 @@ app.post("/api/runtime/config", checkToken, updateConfig);

// Realtime polling endpoint
app.get("/config", checkToken, realtimeConfig);
app.get("/api/runtime/stream", checkToken, stream);
app.post("/api/runtime/stream/disconnect", checkToken, disconnectStreams);

app.get("/api/runtime/events", checkToken, listEvents);
app.post("/api/runtime/events", checkToken, captureEvent);
Expand All @@ -32,6 +36,7 @@ app.get("/api/runtime/firewall/lists", checkToken, lists);
app.post("/api/runtime/firewall/lists", checkToken, updateIPLists);

app.post("/api/runtime/apps", createApp);
app.delete("/api/runtime/apps", checkToken, deleteApp);

app.listen(port, () => {
console.log(`Server is running on port ${port}`);
Expand Down
14 changes: 14 additions & 0 deletions end2end/server/src/handlers/deleteApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Response } from "express";
import { removeApp } from "../zen/apps.ts";
import { closeStreams } from "./stream.ts";
import type { ZenRequest } from "../types.ts";

export function deleteApp(req: ZenRequest, res: Response) {
if (!req.zenApp) {
throw new Error("App is missing");
}

removeApp(req.zenApp);
closeStreams(req.zenApp.id);
res.json({ ok: true });
}
64 changes: 64 additions & 0 deletions end2end/server/src/handlers/stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Response } from "express";
import { getAppConfig, configEvents } from "../zen/config.ts";
import type { ZenRequest } from "../types.ts";

const connections = new Map<number, Set<Response>>();

export function stream(req: ZenRequest, res: Response) {
if (!req.zenApp) {
throw new Error("App is missing");
}

const app = req.zenApp;

if (!connections.has(app.id)) {
connections.set(app.id, new Set());
}
connections.get(app.id)!.add(res);

res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});

function sendConfig() {
const config = getAppConfig(app);
const data = { serviceId: app.id, configUpdatedAt: config.configUpdatedAt };
res.write(`event: config-updated\ndata: ${JSON.stringify(data)}\n\n`);
}

sendConfig();

const eventName = `config-updated:${app.id}`;
configEvents.on(eventName, sendConfig);

const ping = setInterval(() => {
res.write(": ping\n\n");
}, 30_000);

req.on("close", () => {
connections.get(app.id)?.delete(res);
configEvents.off(eventName, sendConfig);
clearInterval(ping);
});
}

export function closeStreams(appId: number) {
const appConnections = connections.get(appId);
if (appConnections) {
for (const conn of appConnections) {
conn.end();
}
appConnections.clear();
}
}

export function disconnectStreams(req: ZenRequest, res: Response) {
if (!req.zenApp) {
throw new Error("App is missing");
}

closeStreams(req.zenApp.id);
res.json({ ok: true });
}
6 changes: 5 additions & 1 deletion end2end/server/src/zen/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type App = {
configUpdatedAt: number;
};

const apps: App[] = [];
let apps: App[] = [];

let id = 1;
export function createApp(): string {
Expand All @@ -20,6 +20,10 @@ export function createApp(): string {
return token;
}

export function removeApp(app: App): void {
apps = apps.filter((a) => a.id !== app.id);
}

export function getByToken(token: string): App | undefined {
return apps.find((app) => {
if (app.token.length !== token.length) {
Expand Down
4 changes: 4 additions & 0 deletions end2end/server/src/zen/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { EventEmitter } from "node:events";
import type { App } from "./apps.ts";

export const configEvents = new EventEmitter();

type AppConfig = {
success: boolean;
serviceId: number;
Expand Down Expand Up @@ -53,6 +56,7 @@ export function updateAppConfig(app: App, newConfig: Partial<AppConfig>) {
...newConfig,
configUpdatedAt: Date.now(),
};
configEvents.emit(`config-updated:${app.id}`);
return true;
}

Expand Down
2 changes: 1 addition & 1 deletion end2end/tests-new/heartbeat.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ test("It reports own http requests in heartbeat events", async () => {
{
hostname: "localhost",
port: 5874,
hits: 3,
hits: 4,
},
],
agent: {
Expand Down
2 changes: 2 additions & 0 deletions end2end/tests-new/hono-pg-esm-outbound.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ test("blockNewOutgoingRequests is true", async () => {
domains: [
{ hostname: "ssrf-redirects.testssandbox.com", mode: "block" },
{ hostname: "aikido.dev", mode: "allow" },
// Otherwise we cannot communicate with the mock server
{ hostname: "localhost", mode: "allow" },
],
}),
});
Expand Down
Loading