npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 in entities, one item per instance, at least one. A single key deletion and a 500-key deletion share the same event.type; only the length of entities differs. Publishers keep the message under the SQS 1 MiB limit.
  • No PII on the wire: team, project and initiator carry only uuid. No id, name, email, or fullname.
  • 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-lib

The 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; operationId only 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 operationId out 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
}
  • id is 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 for team/project/initiator does 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 by auditEventId + id, so consumers can ask "everything that happened to key 991". Two items with the same id in one event collapse into one row.
  • changes is 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; omit old when the field had no value before and new when it has none after. Encode anything richer (a list of tags, an object) as a string, e.g. its JSON. Leave changes out for created/deleted/accessed events.
  • context is 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.
  • details is 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 of details, changes and context). 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' added entityType/entities and dropped initiator.metadata.aiModel; the optional operationId came later within '2'.
  • payload.event.schemaVersion (int, set by you) — bump when a specific event type's details, changes or context shape 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 same auditEventId.
  • Ordering is not guaranteed and not needed — the read side orders by occurredAt/auditEventId.
  • publishAuditEvent rejects on validation failure and on send failure. Treat a rejection as "not confirmed published" and retry with the same auditEventId (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 — entities grows with the size of a bulk operation, so the publisher is responsible for keeping the message under the 1 MiB limit (trim context, or split the operation into several audit records with their own auditEventId). 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 (payloadRef with store fss).

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_command when the prefix is set. Point AWS_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:GetQueueUrl and sqs:GetQueueAttributes on arn:aws:sqs:<region>:<account>:audi-audit_log_command; the grant lives on your role, not on Audi's side. A service on the lok/k8s/main cluster whose role attaches the shared sns-sqs-all policy (_inventory/k8s/main/iam-roles/<service>.hcl in infra-terragrunt) already has sqs:* on audi*. Any other role (Expert's iam-policy-expert-sns-sqs is scoped to expert-*) gets the statement through an infra-terragrunt PR on its policy, applied by Platform.
  • Locally, the shared dev-localstack fauxqs seeds audi-audit_log_command; point AWS_ENDPOINT at it with no resource prefix.
  • The internal SQS options resolver is cached per process, so the first appEnv a 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_events row per entity and logs Stored audit log event with its auditEventId; 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 flag perf_audi_enabled on for the team (internal guide).
  • A PREnv of Audi runs its own fauxqs on port 4566 with static test credentials and no prefix: in a Kargo stack with Audi, point AWS_ENDPOINT at 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: