@lokalise/audi-audit-events-lib
v0.7.0
Published
Queue name, message schema, and a ready-made publisher for Audi's audit-events SQS command queue, for services that publish audit log events.
Maintainers
Keywords
Readme
@lokalise/audi-audit-events-lib
Publish audit log events to Audi (the audit log service).
The package owns the wire contract for Audi's audi-audit_log_command SQS command queue: the queue
name, the message schemas, and a ready-made AuditLogEventPublisher. Audi is the queue's only
consumer; publishing services (e.g. Expert, Vantage, CTEng) only need this package.
- Granularity: one message per persisted audit document. If one domain event fans out into N audit records (e.g. a user event recorded once per team membership), publish N messages — Audi stores what it is told and does not re-derive fan-out.
- Every event is a bulk event: the payload names the kind of entity it touched (
entityType) and lists every affected instance inentities, one item per instance, at least one. A single key deletion and a 500-key deletion share the sameevent.type; only the length ofentitiesdiffers. Publishers keep the message under the SQS 1 MiB limit. - No PII on the wire:
team,projectandinitiatorcarry onlyuuid. Noid,name,email, orfullname. - This queue means exactly one thing: "persist this audit event". Any other action (reprocess, purge, backfill) belongs on its own command queue.
Install
pnpm add @lokalise/audi-audit-events-libThe library declares its AWS and messaging integration as peer dependencies, so it reuses the
copies your service already has instead of pulling a second tree into node_modules. The host
service must have these installed:
| Peer | Range |
|---------------------------------|-------------|
| @lokalise/aws-config | >=8.1.0 |
| @message-queue-toolkit/sqs | >=26.1.1 |
| @message-queue-toolkit/schemas| >=7.2.0 |
| zod | ^4.3.6 |
Any service built from node-service-template already has all of them.
Quick start
import { AuditLogEventPublisher } from '@lokalise/audi-audit-events-lib'
import { SQSClient } from '@aws-sdk/client-sqs'
const publisher = new AuditLogEventPublisher(
{
sqsClient: new SQSClient({ region: config.aws.region, credentials: config.aws.credentials }),
logger, // CommonLogger (pino)
errorReporter, // ErrorReporter
},
{
aws: config.aws, // AwsConfig of *your* service
appEnv: config.appEnv, // 'production' | 'staging' | 'development'
producedBy: 'expert', // your service name
},
)
await publisher.publishAuditEvent({
auditEventId: '01a088b5-a200-7c3f-9e1d-4b6a8f2c7d51', // UUID v7
event: { group: 'project', type: 'keys.deleted', schemaVersion: 2 },
occurredAt: '2026-09-10T09:30:00.000Z', // ISO 8601, UTC
team: { uuid: teamUuid },
project: { uuid: projectUuid, branch: null },
initiator: {
type: 'USER',
uuid: userUuid,
metadata: {
web: { remoteAddress: req.ip, userAgent: req.headers['user-agent'], url: req.url },
},
},
entityType: 'key',
entities: [
{ id: '991', context: { keyName: 'welcome.title' } },
{ id: '992', context: { keyName: 'welcome.subtitle' } },
],
details: { projectName: 'Mobile App', source: 'api' },
})That is the whole API surface you need: you provide the payload, the library provides everything
else. The envelope (id, timestamp, type) and the metadata shell are filled in for you.
Publisher config
| Field | Required | Notes |
|--------------|----------|-------------------------------------------------------------------------------------------|
| aws | yes | AwsConfig of the publishing service (region, credentials, resourcePrefix, …). |
| appEnv | yes | 'production' \| 'staging' \| 'development'. Enables queue-name validation off-production. |
| producedBy | yes | Your service name, e.g. 'expert' or 'autopilot-backend-import': the service, not the product. Stamped onto metadata.producedBy/originatedFrom. |
| isTest | no | Test mode: disables startup queue polling and enables handlerSpy. See Testing. |
Lifecycle
publishAuditEvent() lazily initialises on first call (it resolves the queue URL by name), so a
plain singleton works. If your DI container supports it, register the publisher with init as the
async initialiser and close as the async disposer to pay the queue lookup at boot instead of on
the first audit event:
auditLogEventPublisher: asSingletonClass(AuditLogEventPublisher, {
asyncInit: 'init',
asyncDispose: 'close',
})Audi owns and creates the queue; the publisher only locates it. publish rejects if the queue
cannot be resolved — it never creates it.
The message
{
"id": "018f1e2a-9c3b-7000-8b7a-2b1e2f6c9a11", // auto: uuid v7, per publish attempt
"timestamp": "2026-07-15T09:32:04.512Z", // auto: publish time (ISO-8601 UTC)
"type": "audit_log.event", // auto: constant for this queue
"payload": { /* what you pass to publishAuditEvent */ },
"metadata": {
"schemaVersion": "2", // auto: envelope + payload shell version
"producedBy": "expert", // auto: from config.producedBy
"originatedFrom": "expert", // auto, overridable
"correlationId": "018f1e2a-…" // auto: new uuid v7, overridable
}
}Envelope type is always the constant audit_log.event; event identity lives in payload.event.
Propagating correlation
Pass the second argument only to continue an existing message chain:
await publisher.publishAuditEvent(payload, {
correlationId: requestContext.reqId,
originatedFrom: 'vantage', // the service that started the workflow, if not you
})Nothing else in metadata is overridable. A third argument (SQSMessageOptions) exists for
MessageGroupId/MessageDeduplicationId; the queue is a standard (non-FIFO) queue, so leave it out.
Payload reference
| Field | Type | Notes |
|----------------------|------------------------------------------|------------------------------------------------------------------------------|
| auditEventId | UUID v7 | Idempotency key Audi upserts on. Not the envelope id, not a Mongo _id. |
| operationId | UUID v7, optional | Shared by every event of one operation. See Operations. |
| event.group | 'project' \| 'team' \| 'user' | Coarse grouping; drives the nullability rules below. |
| event.type | string | Dotted name, globally unique across groups (e.g. keys.deleted). |
| event.schemaVersion| int | Version of details for this event.type (today's AUDIT_DATA_SCHEMA_VERSION). |
| occurredAt | ISO-8601 UTC string | Domain event time — distinct from the envelope's publish timestamp. |
| team | { uuid } \| null | See group rules. |
| project | object | null | { uuid, branch }; branch is { uuid, name } \| null (null on master). |
| initiator | discriminated union on type | See below. SYSTEM/AI carry the well-known actor uuids. |
| entityType | snake_case slug, ≤ 64 | Singular entity slug from the entity registry naming the kind of entity the event is about (key, segment, team, user). Carried on its own; consumers filter by it instead of parsing event.type. |
| entities | AuditLogEntity[], min 1 | One item per affected instance of entityType. See Entities. |
| details | Record<string, unknown>, optional | Event-level context that is true for the whole event and belongs to no single entity (source, an export format, …). What belongs to one instance goes in entities[].context. Opaque per (group, type, schemaVersion); consumers key off event. |
auditEventId must be UUID v7 and is rejected otherwise (v4 included). Generate it once, when
the audit record is created, and reuse the same value for every retry — that is what makes
at-least-once delivery safe.
Operations
An action applied later by a job (a bulk action, a background process) publishes several events:
for example key.bulk_delete.requested when the request is accepted, the regular outcome events
as the job works through it (key.deleted, possibly in chunks), and key.bulk_delete.completed
or .failed at the end. Give all of them the same operationId:
- Generate it once, a UUID v7, when the operation is accepted, and carry it to the job with the work item. Every event of the operation, from any service, sends that value.
- Each event still has its own
auditEventId;operationIdonly links them. - Facts about the operation itself (who asked, what was targeted) belong in the event that starts it. Do not repeat them on every event.
- Leave
operationIdout for a standalone action.
The event names above are only an example: Audi treats none of them specially. Readers filter by
operation_id to get every event of the operation; the OCSF export carries it as
metadata.correlation_uid.
Do not use metadata.correlationId for this: it is a per-message tracing id and is not stored.
Group rules (enforced by the schema)
| event.group | team | project |
|---------------|----------|-----------|
| project | required | required |
| team | required | null |
| user | null | null |
Violations throw at publish time, before anything reaches SQS.
Entities
{
id: string, // required, non-empty, ≤ 36 (uuid length)
changes?: { field: string, old?: Scalar, new?: Scalar }[], // field-level diff; Scalar = string | number | boolean
context?: Record<string, unknown>, // what belongs to this one instance beyond the diff
}idis your stable identifier for the instance: its uuid where the entity has one, otherwise the stringified numeric id. Entity ids are not PII, so the uuid-only rule forteam/project/initiatordoes not apply. At most 36 characters, the length of a uuid. Bulk messages exist to keep SQS traffic down; Audi fans them out on write and stores one row per entity, keyed byauditEventId+id, so consumers can ask "everything that happened to key 991". Two items with the sameidin one event collapse into one row.changesis the field-level diff of an update: one item per changed field with the value before (old) and after (new). Both values are scalars: a string, a number or a boolean; omitoldwhen the field had no value before andnewwhen it has none after. Encode anything richer (a list of tags, an object) as a string, e.g. its JSON. Leavechangesout for created/deleted/accessed events.contextis what belongs to this one instance beyond the diff: what a reader needs to recognise it (a key name, a language code, a file name) or to understand the change (the reason, the source row). Keep it small; it is repeated per item.detailsis optional and keeps what is true for the whole event and none of the entities in particular. Do not duplicate per-entity data there.
An event that touches exactly one instance still carries entities with one item: a
user.created event lists the user, a team.updated event lists the team with its changes.
Initiator
{ type: 'USER', uuid, metadata: { web: { remoteAddress, userAgent, url } | null } }
{ type: 'SYSTEM', uuid, metadata: {} }
{ type: 'AI', uuid, metadata: {} }web is null when there is no HTTP request context (CLI, background jobs, consumers). Resolve the
USER/SYSTEM/AI classification at publish time — Audi must not need a read-time lookup for it.
System and AI initiators
Actions with no person behind them (background jobs, service-to-service calls, AI engines) are attributed to two well-known actors, fixed by this package and identical in every environment:
| Actor | initiator.uuid | Built by |
|--------|----------------------------------------|---------------------|
| System | 11111111-1111-4111-8111-111111111111 | systemInitiator() |
| AI | 22222222-2222-4222-8222-222222222222 | aiInitiator() |
The schema enforces them per type, at publish time and in Audi's consumer: a SYSTEM initiator
with any other uuid (the nil uuid, a service sub, the uuid of a "system user" row in your own
database) is rejected. They are plain labels, not users - nothing resolves them against a user
table, and the values are shaped as valid RFC 4122 uuids so strict validators accept them.
The values are not exported on purpose: the helpers are the only way to build these initiators, and a test that needs the uuid reads it off the result. Publishers on other stacks mirror the values from the table above.
import { aiInitiator, systemInitiator } from '@lokalise/audi-audit-events-lib'
initiator: systemInitiator() // { type: 'SYSTEM', uuid: '11111111-…', metadata: {} }
initiator: aiInitiator() // { type: 'AI', uuid: '22222222-…', metadata: {} }Neither carries more than the uuid. If the engine or job behind the action matters to a reader,
name it in details (e.g. details.source: 'deepl').
Two version axes
metadata.schemaVersion(string, set by this library) — bump when the payload shell changes in a way consumers must branch on: a new required field, a removed field, a field whose meaning changes (any field above except the content ofdetails,changesandcontext). A new optional field is no bump: a message without it is still valid. Consumers can branch on it without knowing any event type.'2'addedentityType/entitiesand droppedinitiator.metadata.aiModel; the optionaloperationIdcame later within'2'.payload.event.schemaVersion(int, set by you) — bump when a specific event type'sdetails,changesorcontextshape changes. One event type's bump does not affect any other.
More examples
Team event — no project:
await publisher.publishAuditEvent({
auditEventId: '01a088b5-a200-7c3f-9e1d-4b6a8f2c7d51',
event: { group: 'team', type: 'team.member.invited', schemaVersion: 1 },
occurredAt: '2026-09-10T09:30:00.000Z',
team: { uuid: teamUuid },
project: null,
initiator: { type: 'USER', uuid: userUuid, metadata: { web: null } },
entityType: 'team_member',
entities: [{ id: invitedUserUuid, context: { role: 'translator' } }],
details: {},
})User event — no team, no project:
await publisher.publishAuditEvent({
auditEventId: '01a088b5-a200-7c3f-9e1d-4b6a8f2c7d51',
event: { group: 'user', type: 'user.created', schemaVersion: 1 },
occurredAt: '2026-09-10T09:30:00.000Z',
team: null,
project: null,
initiator: systemInitiator(),
entityType: 'user',
entities: [{ id: userUuid }],
details: { source: 'sso' },
})Update with a field-level diff — one entity, its changes:
await publisher.publishAuditEvent({
auditEventId: '01a088b5-a200-7c3f-9e1d-4b6a8f2c7d51',
event: { group: 'team', type: 'team.updated', schemaVersion: 1 },
occurredAt: '2026-09-10T09:30:00.000Z',
team: { uuid: teamUuid },
project: null,
initiator: { type: 'USER', uuid: userUuid, metadata: { web: null } },
entityType: 'team',
entities: [
{
id: teamUuid,
changes: [
{ field: 'name', old: 'Backend', new: 'Platform' },
{ field: 'ssoEnforced', old: false, new: true },
{ field: 'description', new: 'Platform team' },
],
},
],
details: {},
})AI initiator on a branch project:
await publisher.publishAuditEvent({
auditEventId: '01a088b5-a200-7c3f-9e1d-4b6a8f2c7d51',
event: { group: 'project', type: 'translations.updated', schemaVersion: 3 },
occurredAt: '2026-09-10T09:30:00.000Z',
team: { uuid: teamUuid },
project: {
uuid: projectUuid,
branch: { uuid: branchUuid, name: 'release-2.4' },
},
initiator: aiInitiator(),
entityType: 'translation',
entities: [
{ id: '1', changes: [{ field: 'value', old: 'Hallo', new: 'Hallo Welt' }] },
{ id: '2', changes: [{ field: 'value', new: 'Weiter' }] },
],
details: { source: 'deepl' },
})Delivery semantics
- SQS is at-least-once; Audi upserts on
auditEventId, so duplicates are harmless as long as the same logical record always carries the sameauditEventId. - Ordering is not guaranteed and not needed — the read side orders by
occurredAt/auditEventId. publishAuditEventrejects on validation failure and on send failure. Treat a rejection as "not confirmed published" and retry with the sameauditEventId(a background job or outbox is the usual answer; do not let a failed audit publish silently vanish).- The whole event rides inside a single SQS message —
entitiesgrows with the size of a bulk operation, so the publisher is responsible for keeping the message under the 1 MiB limit (trimcontext, or split the operation into several audit records with their ownauditEventId). The library does not check the size and does not offload. Audi does resolve payloads that a publisher offloaded to the File Storage Service itself (payloadRefwith storefss).
Environments, naming and IAM
- Queue name:
AUDIT_LOG_COMMAND_QUEUE_NAME=audi-audit_log_command. Never hardcode it. - Environment separation comes from
aws.resourcePrefix: the publisher resolves<resourcePrefix>_audi-audit_log_commandwhen the prefix is set. PointAWS_RESOURCE_PREFIX(and region/credentials) at the environment you mean. - Audi creates the queue in each AWS account. The publishing service's IAM role needs
sqs:SendMessage,sqs:GetQueueUrlandsqs:GetQueueAttributesonarn:aws:sqs:<region>:<account>:audi-audit_log_command; the grant lives on your role, not on Audi's side. A service on thelok/k8s/maincluster whose role attaches the sharedsns-sqs-allpolicy (_inventory/k8s/main/iam-roles/<service>.hclininfra-terragrunt) already hassqs:*onaudi*. Any other role (Expert'siam-policy-expert-sns-sqsis scoped toexpert-*) gets the statement through aninfra-terragruntPR on its policy, applied by Platform. - Locally, the shared
dev-localstackfauxqs seedsaudi-audit_log_command; pointAWS_ENDPOINTat it with no resource prefix. - The internal SQS options resolver is cached per process, so the first
appEnva publisher is constructed with wins. This only matters if you build publishers for several environments in one process (typically tests).
Testing
Construct the publisher with isTest: true against LocalStack or a stubbed SQSClient. Test mode
skips startup queue polling and enables publisher.handlerSpy.
const publisher = new AuditLogEventPublisher(dependencies, {
aws: { region: 'eu-central-1', kmsKeyId: '', credentials: { accessKeyId: 'test', secretAccessKey: 'test' } },
appEnv: 'development',
producedBy: 'expert',
isTest: true,
})To assert against the contract without SQS at all, validate your payload directly:
import { AuditEvents } from '@lokalise/audi-audit-events-lib'
AuditEvents['audit_log.event'].consumerSchema.parse(message)Verifying delivery
- Nothing is stored before Audi's storage is live on the target environment (Audi announces
the date per environment). The consumer stores every valid message as one
audit_eventsrow per entity and logsStored audit log eventwith itsauditEventId; a message it cannot store is quarantined or retried, never dropped. Before the storage date, ask Platform Enablement to confirm the log line. - Once storage is live,
GET /v1/audit-logs?team_id=<id>&event_type=<type>returns the event through Expert's proxy, with the team's API token and the flagperf_audi_enabledon for the team (internal guide). - A PREnv of Audi runs its own fauxqs on port
4566with statictestcredentials and no prefix: in a Kargo stack with Audi, pointAWS_ENDPOINTat it and read the Audi container log.
Exports
| Export | What it is |
|-----------------------------------------|------------------------------------------------------------------|
| AuditLogEventPublisher | The publisher. publishAuditEvent(payload, metadataOverrides?, options?). |
| AuditLogEventPublisherConfig | Config type for the constructor. |
| AuditLogEventInput | The payload type — use it to type your mappers. |
| AuditLogEntity, AuditLogEntityChange | Item types of entities and entities[].changes. |
| AuditLogEventMetadataOverrides | { correlationId?, originatedFrom? }. |
| AUDIT_LOG_COMMAND_QUEUE_NAME | 'audi-audit_log_command'. |
| AuditEvents | audit_log.event consumer/publisher schemas + schemaVersion. |
| AUDIT_EVENT_PAYLOAD_SCHEMA | The payload zod schema, including the group rules. |
| AuditEventsAnyVersion, AUDIT_EVENT_PAYLOAD_SCHEMA_ANY_VERSION, upgradeAuditEventPayload(), isLegacyAuditEventPayload() | Consumer-side, temporary: accept '1' messages during the rollout. See Rollout. |
| AUDIT_LOG_EVENT_GROUPS | The enum values, for exhaustive mapping on your side. |
| systemInitiator(), aiInitiator() | Build a SYSTEM / AI initiator with its well-known actor uuid; types SystemInitiator, AiInitiator. |
| AuditLogEventConsumerMessage, AuditLogEventPublisherMessage | Full message types. |
Rollout of metadata.schemaVersion '2' (temporary)
Publishers that are still on 0.4.x send '1' messages: no entityType/entities, and an AI
initiator that may carry aiModel. Until every publisher is on 0.5.0, Audi's consumer registers
AuditEventsAnyVersion instead of AuditEvents, which accepts both shapes, and normalises with
upgradeAuditEventPayload(): a '1' payload is stored against the entity the event is scoped
to (entityType = event.group, one entity: the project, the team, or the initiating user).
isLegacyAuditEventPayload() tells the two apart. Nothing here is for publishers: publish v2
through AuditLogEventPublisher, and expect these three exports to be removed in a later minor
once the rollout is over.
Contract changes
The contract lives in this package inside the audi repo —
publisher and consumer share it, so changes must land here first, be released, and then be adopted
by publishers. A payload-shell change consumers must branch on (see
Two version axes) means bumping metadata.schemaVersion and updating Audi's
consumer to accept both versions during the rollout. A new optional field needs no bump, but Audi
must release and deploy the new lib before any publisher sends it: an older schema drops a field it
does not know without an error, on either side.
Checklist for new event publishing
Before a new publisher or a new event type goes live:
- [ ] The event name and its entity follow the handbook's event naming conventions and entity registry; a new entity or event is registered in the same PR.
- [ ] The payload follows the payload reference; actions with no person behind them use the well-known actors.
- [ ]
auditEventIdand retries follow delivery semantics. - [ ] The publishing role can reach the queue in every environment (Environments, naming and IAM).
- [ ] The event is unit tested (Testing).
- [ ] A message was seen to land on stage (Verifying delivery).
