Skip to content

Audit Logging

autotel-audit provides audit-focused helpers for structured compliance logging with automatic outcome tagging and sampling bypass for critical events.

Terminal window
npm install autotel-audit

Wrap sensitive operations with automatic audit metadata and outcome tracking:

import { trace } from 'autotel';
import { withAudit } from 'autotel-audit';
export const deleteUser = trace(async (userId: string) => {
return withAudit(
{
action: 'user.delete',
resource: 'user',
actorId: 'admin-42',
},
async (_ctx, log) => {
await db.users.delete(userId);
log.info('User deleted successfully');
return { ok: true };
},
{ emitNow: true },
);
});
import { trace } from 'autotel';
import { withAudit } from 'autotel-audit';
app.delete('/users/:id', trace(async (req, res) => {
await withAudit(
{
action: 'user.delete',
resource: 'user',
actorId: req.user.id,
userId: req.params.id,
},
async () => {
await deleteUser(req.params.id);
},
{ emitNow: true },
);
res.json({ ok: true });
}));
import { Controller, Delete, Param } from '@nestjs/common';
import { trace } from 'autotel';
import { withAudit } from 'autotel-audit';
@Controller('users')
export class UsersController {
@Delete(':id')
async deleteUser(@Param('id') id: string) {
return withAudit(
{
action: 'user.delete',
resource: 'user',
actorId: 'admin-123',
},
async (ctx, log) => {
await this.usersService.delete(id);
log.info('User deleted');
return { ok: true };
},
{ emitNow: true },
);
}
}
import { trace } from 'autotel';
import { withAudit } from 'autotel-audit';
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
return withAudit(
{
action: 'user.delete',
resource: 'user',
actorId: req.user?.id || 'anonymous',
},
async () => {
await db.users.delete(params.id);
},
{ emitNow: true },
);
}

Wraps an async operation with audit context and automatic outcome tagging.

Parameters:

| Name | Type | Description | |------|------|-------------| | metadata | AuditMetadata | Audit event metadata (action, resource, actor, etc.) | | fn | (ctx, logger) => Promise<T> | Async function receiving audit context and request logger | | options.emitNow | boolean | Immediately emit the audit event (default: false) | | options.forceKeep | boolean | Force event through tail-sampling (default: true) | | options.ctx | AuditContext | Custom audit context (auto-resolved if omitted) | | options.logger | RequestLogger | Override request logger instance |

Returns: Promise<T> — Result from the wrapped function.

Outcome Tagging:

  • Success: Automatically tagged with outcome: 'success' (unless explicitly set)
  • Failure: Automatically tagged with outcome: 'failure' and logged as error
// Explicit outcome override
await withAudit(
{ action: 'payment.process', outcome: 'partial' },
async () => {
// ...
},
);

Write audit metadata as normalized audit.* span attributes without wrapping an operation.

import { setAuditAttributes } from 'autotel-audit';
setAuditAttributes({
action: 'config.update',
resource: 'settings',
actorId: 'user-123',
});
// Sets: audit.action, audit.resource, audit.actorId, autotel.audit=true

Mark the active trace to bypass tail-drop sampling. Called automatically by withAudit unless disabled.

import { forceKeepAuditEvent } from 'autotel-audit';
if (request.isPrivileged()) {
forceKeepAuditEvent();
}
interface AuditMetadata {
action: string; // Required: e.g., 'user.delete', 'payment.process'
resource?: string; // What was modified: 'user', 'account', 'transaction'
actorId?: string; // Who performed the action
category?: string; // Optional grouping: 'admin', 'compliance', 'security'
outcome?: 'success' | 'failure' | string; // Auto-set if omitted
[key: string]: unknown; // Custom fields (auto-serialized)
}

Always keep sensitive operations in sampling:

await withAudit(
{
action: 'secret.access',
resource: 'api-key',
actorId: user.id,
environment: 'production',
},
async () => {
// Fetch secret
},
{ emitNow: true, forceKeep: true },
);

Emit only on errors or special conditions:

try {
await withAudit(
{ action: 'data.export', resource: 'report' },
async () => {
return await generateReport();
},
{ emitNow: false }, // Default sampling applies
);
} catch (error) {
// Error is auto-logged with outcome: failure
// forceKeep is enabled by default for errors
throw error;
}
await withAudit(
{
action: 'users.deactivate',
resource: 'user',
actorId: 'admin-123',
count: userIds.length,
reason: 'inactivity',
},
async (ctx, log) => {
for (const id of userIds) {
await deactivateUser(id);
}
log.info(`Deactivated ${userIds.length} users`);
},
{ emitNow: true },
);
export const processPayment = trace(async (payment) => {
return withAudit(
{
action: 'payment.process',
resource: 'transaction',
actorId: payment.merchant,
amount: payment.amount,
currency: payment.currency,
},
async (ctx, log) => {
const charge = await stripe.charges.create({
amount: payment.amount,
currency: payment.currency,
});
log.info('Payment processed', { chargeId: charge.id });
// Nested audit for refund capability
setAuditAttributes({
chargeId: charge.id,
stripeId: charge.id,
});
return charge;
},
{ emitNow: true },
);
});

Tail-sampling decisions happen after spans complete. Critical audit trails must bypass statistical sampling:

  • forceKeepAuditEvent() marks spans for keeper-worthy treatment
  • Ensures compliance events aren't dropped by sampling rules
  • Enabled by default in withAudit()
// Safe default: force-keep enabled for all audit events
await withAudit(metadata, fn);
// Disable for high-volume audit streams with separate retention
await withAudit(metadata, fn, { forceKeep: false });

Audit attributes integrate with any OTLP-compatible backend:

Attributes exported:

  • audit.action — The audited action
  • audit.resource — The affected resource
  • audit.actorId — The actor performing the action
  • audit.outcome'success' or 'failure'
  • audit.* — All custom metadata fields
  • autotel.audit — Always true (for filtering)

Query examples (Datadog):

@autotel.audit:true @audit.action:user.delete @audit.outcome:failure

Alerts:

// Create alert for all failed admin operations
@audit.category:admin @audit.outcome:failure

Define audit schemas per operation for IDE autocomplete:

import type { AuditMetadata } from 'autotel-audit';
type UserDeleteAudit = AuditMetadata & {
action: 'user.delete';
resource: 'user';
actorId: string;
reason?: string;
};
type PaymentProcessAudit = AuditMetadata & {
action: 'payment.process';
resource: 'transaction';
amount: number;
currency: string;
actorId: string;
};
// IDE will autocomplete these fields
await withAudit<UserDeleteAudit>(metadata, fn);

Errors inside withAudit are automatically tagged with outcome: 'failure':

await withAudit(
{ action: 'account.suspend' },
async () => {
await checkCompliance(); // May throw
await suspendAccount();
},
{ emitNow: true },
);
// On error:
// - outcome: 'failure' is set
// - Error is logged via logger.error()
// - Exception is re-thrown
// - Event is emitted (if emitNow: true)
  • withAudit adds minimal overhead (attribute serialization only)
  • Type conversion handles circular references gracefully
  • emitNow: true sends events immediately; omit for batch emission
  • Default forceKeep: true ensures compliance but may override sampling decisions