autotel-audit
v0.4.5
Published
Audit-focused helpers for Autotel (force-keep + structured audit instrumentation)
Maintainers
Readme
autotel-audit
Audit and security-event helpers for autotel. Provides structured audit logging with automatic tail-sampling bypass and OpenTelemetry attribute normalization.
What it provides
withAudit(...)— Wraps an operation with audit metadata, automatic outcome tagging (success/failure), and optional immediate emitforceKeepAuditEvent(...)— Marks the current trace to bypass tail-drop sampling for compliance/audit trailssetAuditAttributes(...)— Writes normalizedaudit.*attributes on the active span with automatic type conversionsecurityEvent(...)/withSecurity(...)— Typed security events (OWASP A09-aligned) with a stablesecurity.*schema, force-keep by default, a credential-key guard, and automatic counter metricscreateSecuritySignalProcessor(...)— Zero-code security signals derived from the HTTP spans you already have: suspicious-path flagging, denied-response metrics, and per-client auth-failure burst detectionhashIdentifier(...)— Stable one-way digest for correlating PII-bearing identifiers (emails, IPs) without logging raw values
Features
- Structured Metadata — Enforce consistent audit schemas with
AuditMetadatainterface - Automatic Outcome Tagging — Operations auto-tagged as
successorfailure(override with explicitoutcomefield) - Sampling Bypass — Force critical audit events through tail-sampling with
forceKeepAuditEvent()oroptions.forceKeep - Type-Safe Attributes — Automatic serialization of complex types (Objects, Dates, Arrays) to OpenTelemetry-compatible values
- Request Context Integration — Propagates actor ID, resource, and action across structured logs
- Compliance Ready — Emit audit events immediately (
emitNow: true) for real-time compliance systems
Quick Start
import { trace } from 'autotel';
import { withAudit } from 'autotel-audit';
export const deleteUser = trace(async () => {
return withAudit(
{ action: 'user.delete', resource: 'user', actorId: 'admin-42' },
async (_ctx, log) => {
// business logic
log.info('User deleted successfully');
return { ok: true };
},
{ emitNow: true },
);
});API Reference
withAudit<T>(metadata, fn, options?)
Wraps an async operation with audit metadata and handles success/failure outcomes.
Parameters:
metadata: AuditMetadata— Audit event metadata (action, resource, actor, etc.)fn: (ctx, logger) => Promise<T>— Async function receiving audit context and request loggeroptions?: WithAuditOptions— Optional configuration:emitNow?: boolean— Immediately emit the audit event (default: false)forceKeep?: boolean— Force event through tail-sampling (default: true)ctx?: AuditContext— Provide custom audit context (auto-resolved from trace if omitted)logger?: RequestLogger— Override the request logger instance
Example with custom context:
const ctx = {
traceId: 'abc-123',
spanId: 'def-456',
correlationId: 'xyz-789',
setAttribute: (k, v) => span.setAttribute(k, v),
setAttributes: (attrs) => span.setAttributes(attrs),
};
await withAudit({ action: 'data.export' }, fn, { ctx, emitNow: true });setAuditAttributes(metadata, ctx?)
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',
category: 'admin',
});
// Sets: audit.action, audit.resource, audit.actorId, audit.category, autotel.audit=trueforceKeepAuditEvent(ctx?)
Mark the active trace to bypass tail-drop sampling. Called automatically by withAudit unless forceKeep: false.
import { trace } from 'autotel';
import { forceKeepAuditEvent } from 'autotel-audit';
export const readSecrets = trace(async (req) => {
if (req.user.role !== 'admin') {
forceKeepAuditEvent(); // Keep sensitive access attempts
throw new Error('Unauthorized');
}
// ...
});Type-Safe Metadata
Define audit schemas for different operations:
import type { AuditMetadata } from 'autotel-audit';
interface DeleteUserAudit extends AuditMetadata {
action: 'user.delete';
resource: 'user';
actorId: string;
reason?: string;
}
interface PermissionUpdate extends AuditMetadata {
action: 'permission.update';
resource: 'role';
oldValue?: Record<string, boolean>;
newValue?: Record<string, boolean>;
actorId: string;
}Common Patterns
Emit audit events only on errors
await withAudit(
{ action: 'account.suspend', resource: 'account', actorId: 'admin-1' },
async (ctx, log) => {
try {
await suspendAccount();
} catch (err) {
log.error(err); // Auto-tagged with outcome: failure
throw;
}
},
{ emitNow: true },
);Track sensitive operations with context
await withAudit(
{
action: 'secret.access',
resource: 'api-key',
actorId: user.id,
secretType: 'api-key',
env: 'prod',
},
async () => {
// Fetch secret...
},
{ emitNow: true, forceKeep: true },
);Nested audit context in complex flows
export const transferFunds = trace(async (transfer) => {
return withAudit(
{
action: 'transfer.execute',
resource: 'transaction',
actorId: transfer.initiator,
amount: transfer.amount,
fromAccount: transfer.from,
toAccount: transfer.to,
},
async (ctx, log) => {
const debitResult = await debitAccount(transfer.from, transfer.amount);
const creditResult = await creditAccount(transfer.to, transfer.amount);
log.info('Transfer completed', {
transactionId: debitResult.txId,
debitStatus: debitResult.status,
creditStatus: creditResult.status,
});
return { success: true, txId: debitResult.txId };
},
{ emitNow: true },
);
});Compliance & Sampling
Why force-keep audit events?
Tail-sampling decisions are made after spans complete. Critical audit trails need guaranteed export regardless of sampling rate. forceKeepAuditEvent() marks spans as keeper-worthy, ensuring they bypass statistical sampling.
// Default: force-keep is enabled (critical for audit)
await withAudit(metadata, fn);
// Disable if audit backend has separate retention
await withAudit(metadata, fn, { forceKeep: false });
// Manual control for hybrid scenarios
if (isPrivilegedOperation) {
forceKeepAuditEvent();
}Security Events (OWASP A09)
OWASP A09:2025 (Security Logging & Alerting Failures) calls out missing logs for important events, unclear messages, and ineffective alerting. securityEvent() gives security-relevant behaviours a stable, queryable schema so backends can build detection rules and dashboards instead of grepping free text.
import { securityEvent, hashIdentifier } from 'autotel-audit';
// Inside a trace()-wrapped handler:
securityEvent({
name: 'auth.login.failed', // autocomplete for well-known names, free-form allowed
category: 'authentication',
outcome: 'failure',
severity: 'warning',
actorId: hashIdentifier(email), // correlate without logging PII
reason: 'invalid_password',
});
// Sets: security.event, security.category, security.outcome, security.severity,
// security.actor_id, security.reason, autotel.security=trueDesign guarantees
- Force-keep by default — an attack you sampled away is an attack you cannot investigate. Opt out per-event with
forceKeep: falsefor high-volume info events. - Credential-key guard — values under credential-shaped keys (
token,apiKey,password, …, reusing autotel core'sREDACTOR_PATTERNS.sensitiveKey) are never emitted, even by accident. Dropped key names are recorded insecurity.dropped_keys. - Stable schema —
security.event/security.category/security.outcome/security.severityare always present; everything else flattens undersecurity.*. - Hash, don't log —
hashIdentifier(value, { salt })produces a stable sha256 digest for emails/IPs. Never log secrets, hashed or not.
Wrapping security-sensitive operations
import { withSecurity } from 'autotel-audit';
await withSecurity(
{
name: 'api_key.created',
category: 'secrets',
outcome: 'success',
actorId: user.id,
keyId: newKey.id, // safe: identifier, not the key material
},
async () => createApiKey(user.id),
{ emitNow: true },
);
// Success → outcome as given; thrown error → outcome: 'error',
// severity escalated to at least 'error', logged, rethrownCategories
authentication · authorization · data_access · admin_action · configuration · secrets · rate_limit · validation · supply_chain · llm
securityEvent vs withAudit
withAudit— compliance trail for business operations ("who did what to which resource").securityEvent— detection signal for security-relevant behaviour ("is the system being abused"). Categories, outcomes, and severities are closed unions so alerting rules don't drift.
They compose: a sensitive admin operation can carry both an audit.* trail and a security.* event.
Metrics for alerting
Every securityEvent() also increments the autotel.security.events counter (attributes: event, category, outcome, severity) so security teams can alert on rates — failed-login spikes, denied-access bursts — without log-based alerting. Disable per-event with metrics: false.
Cardinality: the event name is a counter attribute. Keep names to a stable catalogue; never interpolate user input into them.
Library vs backend responsibility
This package's job ends at emitting structured, correlated, redaction-safe, sampling-exempt events. Detection rules, alert thresholds, dashboards, and SIEM routing belong in your observability backend.
Zero-Code Security Signals
Most security-relevant traffic never reaches your handlers — scanners probing /.env, traversal attempts, credential stuffing producing 401 storms. createSecuritySignalProcessor() derives security signals from the HTTP spans your instrumentation already produces, with no per-route code:
import { init } from 'autotel';
import { createSecuritySignalProcessor } from 'autotel-audit';
init({
service: 'api',
spanProcessors: [
createSecuritySignalProcessor({
onSignal: (signal) => {
// optional: forward to Slack/SIEM/pager
},
}),
],
});What it does:
| Signal | When | Output |
|---|---|---|
| Suspicious request | Request path matches a probe pattern (path traversal, /.env / /.git / /etc/passwd probes, SQLi/XSS probes, null bytes) | Span flagged security.suspicious_request=true + security.signal=<pattern>, force-kept through tail sampling, autotel.security.http.suspicious counter, onSignal callback |
| Denied response | Response status is 401/403/429 (configurable) | autotel.security.http.denied{status} counter |
| Auth-failure burst | One client crosses N denied responses (default 10) inside a sliding window (default 60s), keyed by client.address | autotel.security.anomaly counter + onSignal callback — fired once per crossing, so alert volume stays bounded under attack |
Why this pairing matters: a credential-stuffing run at 10% baseline sampling is invisible in traces and a /.env probe is one boring 404 in your logs — but flagged spans bypass tail sampling, and the counters give security teams something to alert on. The interesting traffic is guaranteed to exist in your backend.
Design notes:
- Patterns are conservative (scanner/probe traffic, not a WAF) —
union+station+selectin a search query does not flag. Extend withextraPatterns. - Burst tracking is per-process with bounded memory (
maxKeys, default 10k clients, oldest evicted) — random-IP floods can't grow the map forever. For fleet-wide correlation, alert on the metrics in your backend instead. - Both metric emission and your
onSignalcallback are guarded — they can never break the span pipeline.
Integration with Observability Backends
Audit attributes are standard OpenTelemetry span attributes and work with any OTLP-compatible backend (Datadog, New Relic, Jaeger, etc.).
- Attributes are stored as
audit.action,audit.resource,audit.actorId, etc. - Root span contains
autotel.audit: truefor filtering - Use backend span filters to create audit dashboards and alerts
See Also
- Advanced Features — Trace helpers, metadata flattening, isolated tracer providers
- Request Logging — Structured request context and event emission
- Autotel Core —
trace(),span(), and request context patterns
