Skip to main content

Audit Log Backend Performance

Audience: Platform operators and contributors evaluating audit log storage options or RBAC performance.

Related UI-wide benchmark results are tracked in UI Performance Benchmark Results.

Three improvements shipped together in #1903 / PR #1917:

  1. Audit service — MongoDB writes replaced by a lightweight service that owns local/S3 storage
  2. OpenFGA N+1 fix — sequential permission checks on /api/rbac/admin-tab-gates parallelized with Promise.all
  3. OpenFGA BatchCheck — 18 parallel RPCs collapsed into a single HTTP call via /batch-check

How to run the benchmarks yourself

Prerequisites

pip install locust
# Node ≥18 required for mint-test-session.mjs
nvm use # or: node --version

1. Start the production image

# from repo root
docker compose -f docker-compose.dev.yaml --profile caipe-ui-prod up -d

The caipe-ui-prod service builds from the local branch source. Relevant env vars in .env:

AUDIT_LOG_BACKEND=service
AUDIT_SERVICE_URL=http://audit-service:8010
AUDIT_SERVICE_BACKEND=local # or: s3
AUDIT_SERVICE_LOCAL_RETENTION_DAYS=1
AUDIT_SERVICE_S3_BUCKET=<your-bucket>
AUDIT_SERVICE_S3_REGION=us-east-2
AUDIT_SERVICE_S3_PREFIX=audit

2. Mint a test session token

The locust file auto-mints a JWT session for the admin user defined in NEXTAUTH_SECRET. It calls ui/mint-test-session.mjs, which must run from the ui/ directory so it can resolve next-auth from ui/node_modules.

export NEXTAUTH_SECRET="$(grep NEXTAUTH_SECRET .env | cut -d= -f2)"

3. Run the load test

# 20 concurrent users — baseline comparison
locust -f scripts/locustfile.py --headless -u 20 -r 5 --run-time 60s \
--host http://localhost:3000 --csv reports/run-label

# 300 concurrent users — scaling stress test
locust -f scripts/locustfile.py --headless -u 300 -r 10 --run-time 60s \
--host http://localhost:3000 --csv reports/300u-label

The locustfile.py samples these endpoints per virtual user:

  • GET /api/auth/session (every task — session keepalive)
  • GET /api/rbac/admin-tab-gates (admin gate check — the critical path)
  • GET /api/admin/stats
  • GET /api/admin/platform-config
  • GET /api/dynamic-agents/available
  • GET /api/chat/conversations (list and pagination)

4. Compare results

CSV files land in reports/. The key column is 95% (p95) in *_stats.csv:

column -t -s, reports/*.csv | grep admin-tab-gates

Benchmark runs

All runs: 60 s, production Docker image, MacBook (Apple Silicon), OpenFGA on the same host.

Run matrix

Run labelUsersAudit backendOpenFGA strategy
before-mongodb20MongoDB (legacy)Sequential for loop
after-local20Local NDJSONPromise.all parallel
after-s320S3 gzip-NDJSONPromise.all parallel
300u-local300Local NDJSONPromise.all parallel
300u-s3300S3 gzip-NDJSONPromise.all parallel
300u-batchcheck300S3 gzip-NDJSONBatchCheck single RPC

Results: /api/rbac/admin-tab-gates

This endpoint gates every Admin tab open — it's on the critical path for every admin page load.

20 concurrent users

Strategyp50p75p95p99
MongoDB + sequential checks (before)170 ms190 ms390 ms760 ms
Local + Promise.all130 ms150 ms230 ms350 ms
S3 + Promise.all130 ms140 ms170 ms220 ms

p50: −24% (170 → 130 ms). p99: −71% (760 → 220 ms).

300 concurrent users

Strategyp50p75p95p99
Promise.all (18 parallel RPCs)200 ms310 ms800 ms960 ms
BatchCheck (1 RPC)46 ms93 ms450 ms780 ms

p50: −77% (200 → 46 ms). p95: −44% (800 → 450 ms) at 300 users.


What drove the improvements

1. Audit service: fire-and-forget writes

The legacy path called db.collection("audit_logs").insertOne(event) inline and await-ed the result before returning. Under load, MongoDB connection-pool contention added 30–50 ms to the median and caused the long tail (760 ms p99).

Services now write fire-and-forget to audit-service — the handler returns immediately:

  • local — audit-service appends date-partitioned NDJSON under AUDIT_SERVICE_LOCAL_PATH and purges files older than AUDIT_SERVICE_LOCAL_RETENTION_DAYS (default: 1).
  • s3 — audit-service flushes date-partitioned Parquet objects to AUDIT_SERVICE_S3_BUCKET.

2. OpenFGA N+1: parallelized with Promise.all

Before the fix, each of 18 tab checks was await-ed sequentially:

// before — 18 × RTT ≈ 18 × 7 ms = 126 ms
for (const tab of ALL_TABS) {
gates[tab] = await checkOpenFgaTuple({ user, relation, object });
}

After the fix, all checks race concurrently:

// after — 1 × RTT (the slowest single check)
const [tabResults] = await Promise.all([
Promise.all(ALL_TABS.map(evaluateTab)),
repairCurrentUserBaseline(currentSubject, isAdmin),
]);

Wall-clock drops from N × RTT to 1 × RTT. At 20 users: 170 ms → 130 ms.

3. OpenFGA BatchCheck: 18 RPCs → 1 RPC

At 300 users, Promise.all fires 18 × 300 = 5,400 concurrent OpenFGA RPCs per second — OpenFGA's gRPC pool saturates and p95 climbs back to 800 ms.

The /batch-check API accepts all checks in a single HTTP request:

// one round-trip regardless of how many tabs
const batchResults = await batchCheckOpenFgaTuples(
batchEntries.map((e) => e.tuple)
);

Each check is identified by a correlation_id; OpenFGA resolves all of them concurrently server-side and returns a single result map. This reduces outbound RPC count from 18 to 1 per request. At 300 users: p50 200 ms → 46 ms.


Remaining latency at scale

The residual 450 ms p95 at 300 users is primarily:

  • hasAccessibleSlackChannel / hasAccessibleWebexSpace — each does a MongoDB query for active channel/space rows, then a per-row OpenFGA check in a sequential loop. These are secondary checks run only for users whose primary check failed; they are excluded from the batch.
  • /api/admin/stats — aggregates several MongoDB collections; p95 climbs to 650 ms at 300 users.

Next levers: add an OpenFGA server-side check-query cache (OPENFGA_CHECK_QUERY_CACHE_ENABLED=true, ~40% QPS reduction) or scale OpenFGA to 3 replicas.


Backend selection guide

EnvironmentRecommended backendReason
Local developmentlocalZero config, immediately readable NDJSON files
Single-node staginglocalNo external dependencies
Multi-replica Kubernetess3All pods write to the same bucket; survives pod restarts
Air-gapped / on-premlocalS3 not available

Set AUDIT_LOG_BACKEND=service on producers and point AUDIT_SERVICE_URL at audit-service. Then set AUDIT_SERVICE_BACKEND=local or AUDIT_SERVICE_BACKEND=s3 on audit-service.


Full benchmark data

20 users — all endpoints

Baseline — MongoDB + sequential OpenFGA

Endpointp50p95p99
/api/auth/session4 ms18 ms62 ms
/api/chat/conversations (list)8 ms27 ms110 ms
/api/admin/stats26 ms190 ms410 ms
/api/dynamic-agents/available39 ms230 ms310 ms
/api/rbac/admin-tab-gates170 ms390 ms760 ms

Local file backend + Promise.all

Endpointp50p95p99
/api/auth/session5 ms15 ms23 ms
/api/chat/conversations (list)8 ms32 ms87 ms
/api/admin/stats21 ms61 ms170 ms
/api/dynamic-agents/available34 ms73 ms260 ms
/api/rbac/admin-tab-gates130 ms230 ms350 ms

S3 backend + Promise.all

Endpointp50p95p99
/api/auth/session5 ms14 ms52 ms
/api/chat/conversations (list)8 ms27 ms100 ms
/api/admin/stats20 ms51 ms130 ms
/api/dynamic-agents/available27 ms70 ms88 ms
/api/rbac/admin-tab-gates130 ms170 ms220 ms

Multi-user scaling — admin-tab-gates (BatchCheck)

Test environment note: All runs below are on a single developer laptop (Apple Silicon, 16 GB RAM) with OpenFGA, PostgreSQL, MongoDB, and the Next.js server all running as local Docker containers. Back-to-back runs without cooldown inflate numbers at 50–100 users because the JIT is cold on the first run and the CPU saturates on subsequent runs. The 25-user and 300-user numbers are the most reliable (25 = warm JIT, low contention; 300 = isolated clean run after container rebuild). In a real Kubernetes deployment with dedicated nodes the crossover point would be significantly higher.

Usersp50p95p99Condition
1049 ms250 ms600 msCold JIT (first run after restart)
2531 ms48 ms88 msWarm JIT, isolated run
50220 ms2100 ms2600 msBack-to-back, CPU contention
100680 ms2600 ms2800 msBack-to-back, CPU-bound
30046 ms450 ms780 msIsolated clean run after rebuild

The 50/100 degradation is a single-node Docker resource limit, not an inherent BatchCheck ceiling. On the warm 25-user and clean 300-user runs the endpoint stays well under 50 ms p50.

300 users — all endpoints

S3 backend + Promise.all (18 parallel RPCs)

Endpointp50p95p99
/api/auth/session8 ms48 ms130 ms
/api/chat/conversations (list)16 ms190 ms360 ms
/api/admin/stats37 ms400 ms700 ms
/api/dynamic-agents/available45 ms400 ms700 ms
/api/rbac/admin-tab-gates200 ms800 ms960 ms

S3 backend + BatchCheck (1 RPC)

Endpointp50p95p99
/api/auth/session9 ms53 ms130 ms
/api/chat/conversations (list)15 ms400 ms760 ms
/api/admin/stats33 ms650 ms1200 ms
/api/dynamic-agents/available38 ms640 ms940 ms
/api/rbac/admin-tab-gates46 ms450 ms780 ms