Skip to main content

Querying Audit Records

All audit queries go through the Audit Service query layer, which automatically applies FINAL to every ClickHouse SELECT. This guarantees that anonymized records (GDPR) are never returned in their original form via the API.


Get Record by ID

const record = await kernel.audit().get('01j9paud1000000000000000');
GET https://api.septemcore.com/v1/audit/01j9paud1000000000000000
Authorization: Bearer <access_token>

Response 200 OK:

{
"auditId": "01j9paud1000000000000000",
"action": "user.login",
"entityType": "user",
"entityId": "01j9pa5mz700000000000000",
"userId": "01j9pa5mz700000000000000",
"ip": "192.0.2.10",
"userAgent": "Mozilla/5.0 ...",
"before": null,
"after": { "loginAt": "2026-04-15T10:30:00.000Z", "method": "password" },
"metadata": { "sessionId": "01j9psess00000000000000" },
"timestamp": "2026-04-15T10:30:00.000Z"
}

Search Records

GET https://api.septemcore.com/v1/audit?action=user.login&userId=01j9pa5mz...&from=2026-04-01T00:00:00Z&to=2026-04-30T23:59:59Z&limit=20
Authorization: Bearer <access_token>
{
"data": [
{
"auditId": "01j9paud1000000000000000",
"action": "user.login",
"entityType": "user",
"entityId": "01j9pa5mz700000000000000",
"userId": "01j9pa5mz700000000000000",
"ip": "192.0.2.10",
"timestamp": "2026-04-15T10:30:00.000Z"
}
],
"pagination": {
"nextCursor": "01j9paud4000000000000000",
"hasMore": true
}
}

Filter Parameters

ParameterDescription
actionFilter by exact action string (e.g. money.transaction.debited) or prefix (e.g. money.)
entityTypeFilter by entity type (user, wallet, role, module, etc.)
entityIdFilter by entity ID
userIdFilter by user who performed the action
fromStart of time range (ISO 8601, inclusive)
toEnd of time range (ISO 8601, inclusive)
limitPage size (default 20, max 100)
cursorCursor from previous page's nextCursor

Pagination Behavior

Audit records are sorted by timestamp DESC. Cursor-based pagination is used rather than offset-based to avoid scanning large result sets in ClickHouse. Cursor is an auditId (ULID) — the service uses it as a WHERE timestamp < cursor_timestamp predicate on the index.

SDK:

const result = await kernel.audit().query({
action: 'money.transaction.debited',
from: '2026-04-01T00:00:00Z',
to: '2026-04-30T23:59:59Z',
limit: 50,
});

// result.data, result.pagination.nextCursor, result.pagination.hasMore

Entity History

Retrieve all audit records for a specific entity — a complete chronological history of everything that happened to it.

const history = await kernel.audit().entityHistory('wallet', '01j9paw1t000000000000000');
GET https://api.septemcore.com/v1/audit/entity/wallet/01j9paw1t000000000000000?limit=50
Authorization: Bearer <access_token>
{
"entityType": "wallet",
"entityId": "01j9paw1t000000000000000",
"data": [
{
"auditId": "01j9paud9000000000000000",
"action": "money.transaction.credited",
"userId": "01j9pa5mz700000000000000",
"before": { "available": 0 },
"after": { "available": 5000 },
"timestamp": "2026-04-15T10:30:00.000Z"
},
{
"auditId": "01j9paud8000000000000000",
"action": "money.hold.created",
"userId": "01j9pa5mz700000000000000",
"before": { "available": 5000, "frozen": 0 },
"after": { "available": 2500, "frozen": 2500 },
"timestamp": "2026-04-15T10:31:00.000Z"
}
],
"pagination": { "nextCursor": null, "hasMore": false }
}

This endpoint is optimized for compliance investigations: "show me everything that happened to this wallet." It uses the ClickHouse index (tenant_id, action, timestamp) with an entity_id filter, which resolves in milliseconds for entities with up to millions of records.


Export

Export audit records to JSON or CSV for compliance reports, legal requests, and external audit tools.

GET https://api.septemcore.com/v1/audit/export?format=csv&action=money.&from=2026-01-01T00:00:00Z&to=2026-03-31T23:59:59Z
Authorization: Bearer <access_token>

The export endpoint accepts the same filter parameters as GET /audit. Additional parameters:

ParameterDescription
formatjson (default) or csv

Response for format=json: streaming application/json (newline- delimited JSON, one record per line).

Response for format=csv: streaming text/csv with header row.

GDPR Export Guarantee

Before returning any records, the export endpoint checks for pending GDPR anonymization requests for the queried tenant. If any are pending, the endpoint waits for all anonymizations to complete before streaming the export:

GET /audit/export received:
1. Check: any anonymizing:{userId} Valkey locks for this tenant?
┌─ No pending → stream export immediately
└─ Pending found → wait for lock release (poll 100ms, max 5s)
→ stream export (anonymized version)

This guarantees that even a freshly submitted GDPR request is reflected in any export initiated within the same time window.


ReplacingMergeTree FINAL

All queries to ClickHouse from the Audit Service query layer use the FINAL modifier:

SELECT * FROM audit_logs FINAL
WHERE tenant_id = $tenantId
AND action = $action
ORDER BY timestamp DESC
LIMIT 20;

Without FINAL: ClickHouse may return both the original record and the GDPR-anonymized record simultaneously. The ReplacingMergeTree engine merges duplicates in a background process (mergeTree), not at write time. Until the merge runs, both versions coexist.

With FINAL: ClickHouse performs the deduplication at query time, returning only the latest version — the anonymized one.

Without FINAL (race condition):
Query returns:
Row 1: { userId: "user-01j...", email: "[email protected]" } ← original
Row 2: { userId: "user-01j...", email: "[REDACTED]" } ← anonymized

With FINAL (correct):
Query returns:
Row 1: { userId: "user-01j...", email: "[REDACTED]" } ← latest only

Module developers must not query ClickHouse directly. Always use kernel.audit() — the SDK adds FINAL automatically.


Error Reference

ScenarioHTTPCode
Record not found404not-found
Record belongs to different tenant403forbidden
from / to not valid ISO 8601400validation-error
limit > 100400validation-error
format not json or csv400validation-error
Export pending anonymization timeout (> 5s)503ANONYMIZATION_PENDING — retry in 10s