autotel-audit 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.ts DELETED
@@ -1,145 +0,0 @@
1
- import { getTraceContext, otelTrace } from 'autotel';
2
-
3
- export interface AuditContext {
4
- traceId: string;
5
- spanId: string;
6
- correlationId: string;
7
- setAttribute(key: string, value: string | number | boolean): void;
8
- setAttributes(
9
- attrs: Record<string, string | number | boolean | string[] | number[] | boolean[]>,
10
- ): void;
11
- }
12
-
13
- const MISSING_CONTEXT_MESSAGE =
14
- '[autotel-audit] No active trace context. Wrap the call in trace()/instrument(), pass options.ctx, ' +
15
- 'or set options.onMissingContext to "warn"/"skip" to degrade gracefully instead of throwing.';
16
-
17
- /**
18
- * Resolve an audit context without throwing. Returns `null` when no trace context
19
- * is available, so callers can degrade gracefully (best-effort instrumentation).
20
- */
21
- const INVALID_TRACE_ID = '00000000000000000000000000000000';
22
-
23
- export function resolveContextSafe(ctx?: AuditContext): AuditContext | null {
24
- if (ctx) return ctx;
25
-
26
- const span = otelTrace.getActiveSpan();
27
- if (!span) return null;
28
-
29
- // Resolve trace ids from autotel's context when available, otherwise from the
30
- // active OTel span itself, so audit works in any OTel setup — not only inside
31
- // autotel's own `trace()`.
32
- const ids = getTraceContext();
33
- const sc = span.spanContext();
34
- const traceId = ids?.traceId ?? sc.traceId;
35
- if (!traceId || traceId === INVALID_TRACE_ID) return null;
36
-
37
- return {
38
- traceId,
39
- spanId: ids?.spanId ?? sc.spanId,
40
- correlationId: ids?.correlationId ?? traceId.slice(0, 16),
41
- setAttribute: (key, value) => span.setAttribute(key, value),
42
- setAttributes: (attrs) => span.setAttributes(attrs),
43
- };
44
- }
45
-
46
- export function resolveContext(ctx?: AuditContext): AuditContext {
47
- const resolved = resolveContextSafe(ctx);
48
- if (resolved) return resolved;
49
- throw new Error(MISSING_CONTEXT_MESSAGE);
50
- }
51
-
52
- export { MISSING_CONTEXT_MESSAGE };
53
-
54
- /**
55
- * How instrumentation should behave when no trace context is available.
56
- *
57
- * - `throw` — fail fast (original behaviour). Use when telemetry is mandatory.
58
- * - `warn` — run the wrapped handler un-audited and log one warning per action (default).
59
- * - `skip` — run the wrapped handler un-audited, silently.
60
- *
61
- * Telemetry is observability: a missing context should never crash the business
62
- * logic it wraps, so the default is best-effort (`warn`).
63
- */
64
- export type OnMissingContext = 'throw' | 'warn' | 'skip';
65
-
66
- /** A no-op {@link AuditContext} whose attribute setters do nothing. */
67
- export function noopAuditContext(): AuditContext {
68
- return {
69
- traceId: '',
70
- spanId: '',
71
- correlationId: '',
72
- setAttribute() {},
73
- setAttributes() {},
74
- };
75
- }
76
-
77
- const warnedMissingContext = new Set<string>();
78
- const warnedMissingLogger = new Set<string>();
79
-
80
- /** Warn (once per action) that instrumentation is running without a trace context. */
81
- export function warnMissingContextOnce(action: string): void {
82
- if (warnedMissingContext.has(action)) return;
83
- warnedMissingContext.add(action);
84
- console.warn(
85
- `[autotel-audit] No active trace context for "${action}" — running un-audited. ` +
86
- 'Wrap the call in trace()/instrument() or pass options.ctx to capture telemetry. ' +
87
- '(set options.onMissingContext: "throw" to fail fast, or "skip" to silence this warning)',
88
- );
89
- }
90
-
91
- /** Warn (once per action) that attributes were recorded but no canonical log line emitted. */
92
- export function warnMissingLoggerOnce(action: string): void {
93
- if (warnedMissingLogger.has(action)) return;
94
- warnedMissingLogger.add(action);
95
- console.warn(
96
- `[autotel-audit] No request logger for "${action}" — attributes were recorded on the span, ` +
97
- 'but no canonical log line was emitted. Pass options.logger or run inside runWithRequestContext().',
98
- );
99
- }
100
-
101
- export function toAttributeValue(
102
- value: unknown,
103
- ): string | number | boolean | string[] | number[] | boolean[] | undefined {
104
- if (
105
- typeof value === 'string' ||
106
- typeof value === 'number' ||
107
- typeof value === 'boolean'
108
- ) {
109
- return value;
110
- }
111
-
112
- if (Array.isArray(value)) {
113
- if (value.every((entry) => typeof entry === 'string')) {
114
- return value;
115
- }
116
-
117
- if (value.every((entry) => typeof entry === 'number')) {
118
- return value;
119
- }
120
-
121
- if (value.every((entry) => typeof entry === 'boolean')) {
122
- return value;
123
- }
124
-
125
- try {
126
- return JSON.stringify(value);
127
- } catch {
128
- return '<serialization-failed>';
129
- }
130
- }
131
-
132
- if (value instanceof Date) {
133
- return value.toISOString();
134
- }
135
-
136
- if (value === null || value === undefined) {
137
- return undefined;
138
- }
139
-
140
- try {
141
- return JSON.stringify(value);
142
- } catch {
143
- return '<serialization-failed>';
144
- }
145
- }
package/src/index.test.ts DELETED
@@ -1,183 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { otelTrace } from 'autotel';
3
- import {
4
- forceKeepAuditEvent,
5
- setAuditAttributes,
6
- withAudit,
7
- type AuditMetadata,
8
- } from './index';
9
-
10
- const setAttribute = vi.fn();
11
- const setAttributes = vi.fn();
12
- const mockCtx = {
13
- traceId: 'trace-1',
14
- spanId: 'span-1',
15
- correlationId: 'corr-1',
16
- setAttribute,
17
- setAttributes,
18
- setStatus: vi.fn(),
19
- addLink: vi.fn(),
20
- addLinks: vi.fn(),
21
- updateName: vi.fn(),
22
- isRecording: vi.fn(() => true),
23
- recordError: vi.fn(),
24
- track: vi.fn(),
25
- getBaggage: vi.fn(),
26
- setBaggage: vi.fn(),
27
- deleteBaggage: vi.fn(),
28
- getAllBaggage: vi.fn(),
29
- getTypedBaggage: vi.fn(),
30
- setTypedBaggage: vi.fn(),
31
- withBaggage: vi.fn(),
32
- };
33
-
34
- const logger = {
35
- set: vi.fn(),
36
- info: vi.fn(),
37
- warn: vi.fn(),
38
- error: vi.fn(),
39
- getContext: vi.fn(() => ({})),
40
- emitNow: vi.fn(() => ({
41
- timestamp: new Date().toISOString(),
42
- traceId: 'trace-1',
43
- spanId: 'span-1',
44
- correlationId: 'corr-1',
45
- context: {},
46
- })),
47
- fork: vi.fn(),
48
- };
49
-
50
- vi.mock('autotel', () => ({
51
- AUTOTEL_SAMPLING_TAIL_EVALUATED: 'autotel.sampling.tail.evaluated',
52
- AUTOTEL_SAMPLING_TAIL_KEEP: 'autotel.sampling.tail.keep',
53
- createCounter: vi.fn(() => ({ add: vi.fn() })),
54
- REDACTOR_PATTERNS: {
55
- sensitiveKey:
56
- /^(password|passwd|pwd|secret|token|api[_-]?key|auth|credential|private[_-]?key|authorization)$/i,
57
- },
58
- getTraceContext: vi.fn(() => mockCtx),
59
- getRequestLogger: vi.fn(() => logger),
60
- getRequestLoggerSafe: vi.fn(() => logger),
61
- createNoopRequestLogger: vi.fn(() => logger),
62
- otelTrace: {
63
- getActiveSpan: vi.fn(() => ({
64
- setAttribute,
65
- setAttributes,
66
- spanContext: () => ({ traceId: 'trace-1', spanId: 'span-1' }),
67
- })),
68
- },
69
- }));
70
-
71
- describe('autotel-audit', () => {
72
- beforeEach(() => {
73
- vi.clearAllMocks();
74
- });
75
-
76
- it('forceKeepAuditEvent sets tail keep attributes', () => {
77
- forceKeepAuditEvent(mockCtx as never);
78
-
79
- expect(setAttribute).toHaveBeenCalledWith(
80
- 'autotel.sampling.tail.evaluated',
81
- true,
82
- );
83
- expect(setAttribute).toHaveBeenCalledWith('autotel.sampling.tail.keep', true);
84
- expect(setAttribute).toHaveBeenCalledWith('autotel.audit.force_keep', true);
85
- });
86
-
87
- it('setAuditAttributes writes audit.* attributes', () => {
88
- const metadata: AuditMetadata = {
89
- action: 'user.delete',
90
- resource: 'account',
91
- actorId: 'admin-1',
92
- };
93
-
94
- setAuditAttributes(metadata, mockCtx as never);
95
-
96
- expect(setAttributes).toHaveBeenCalledWith(
97
- expect.objectContaining({
98
- 'autotel.audit': true,
99
- 'audit.action': 'user.delete',
100
- 'audit.resource': 'account',
101
- 'audit.actorId': 'admin-1',
102
- }),
103
- );
104
- });
105
-
106
- it('withAudit marks success and optionally emits', async () => {
107
- const result = await withAudit(
108
- { action: 'permission.update', resource: 'role' },
109
- async () => 'ok',
110
- { emitNow: true },
111
- );
112
-
113
- expect(result).toBe('ok');
114
- expect(logger.set).toHaveBeenCalled();
115
- expect(setAttributes).toHaveBeenCalledWith(
116
- expect.objectContaining({
117
- 'audit.outcome': 'success',
118
- }),
119
- );
120
- expect(logger.emitNow).toHaveBeenCalledTimes(1);
121
- });
122
-
123
- it('withAudit marks failure and rethrows', async () => {
124
- await expect(
125
- withAudit({ action: 'secrets.read' }, async () => {
126
- throw new Error('denied');
127
- }),
128
- ).rejects.toThrow('denied');
129
-
130
- expect(setAttributes).toHaveBeenCalledWith(
131
- expect.objectContaining({
132
- 'audit.outcome': 'failure',
133
- }),
134
- );
135
- expect(logger.error).toHaveBeenCalledTimes(1);
136
- });
137
- });
138
-
139
- describe('autotel-audit best-effort (onMissingContext)', () => {
140
- beforeEach(() => {
141
- vi.clearAllMocks();
142
- });
143
-
144
- it('runs the handler un-audited and warns once by default when no context', async () => {
145
- vi.mocked(otelTrace.getActiveSpan).mockReturnValueOnce(undefined as never);
146
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
147
-
148
- const result = await withAudit(
149
- { action: 'missing.default' },
150
- async () => 'ran',
151
- );
152
-
153
- expect(result).toBe('ran');
154
- expect(warn).toHaveBeenCalledTimes(1);
155
- expect(setAttributes).not.toHaveBeenCalled();
156
- warn.mockRestore();
157
- });
158
-
159
- it('throws when onMissingContext is "throw"', async () => {
160
- vi.mocked(otelTrace.getActiveSpan).mockReturnValueOnce(undefined as never);
161
-
162
- await expect(
163
- withAudit({ action: 'missing.throw' }, async () => 'x', {
164
- onMissingContext: 'throw',
165
- }),
166
- ).rejects.toThrow('No active trace context');
167
- });
168
-
169
- it('runs silently when onMissingContext is "skip"', async () => {
170
- vi.mocked(otelTrace.getActiveSpan).mockReturnValueOnce(undefined as never);
171
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
172
-
173
- const result = await withAudit(
174
- { action: 'missing.skip' },
175
- async () => 'ran',
176
- { onMissingContext: 'skip' },
177
- );
178
-
179
- expect(result).toBe('ran');
180
- expect(warn).not.toHaveBeenCalled();
181
- warn.mockRestore();
182
- });
183
- });
package/src/index.ts DELETED
@@ -1,153 +0,0 @@
1
- import {
2
- AUTOTEL_SAMPLING_TAIL_EVALUATED,
3
- AUTOTEL_SAMPLING_TAIL_KEEP,
4
- createNoopRequestLogger,
5
- getRequestLoggerSafe,
6
- } from 'autotel';
7
- import type { RequestLogger } from 'autotel';
8
- import {
9
- MISSING_CONTEXT_MESSAGE,
10
- noopAuditContext,
11
- resolveContextSafe,
12
- toAttributeValue,
13
- warnMissingContextOnce,
14
- warnMissingLoggerOnce,
15
- type AuditContext,
16
- type OnMissingContext,
17
- } from './context';
18
-
19
- export type { AuditContext, OnMissingContext } from './context';
20
- export * from './security';
21
- export * from './security-signals';
22
- export * from './security-heartbeat';
23
-
24
- export interface AuditMetadata {
25
- action: string;
26
- resource?: string;
27
- actorId?: string;
28
- category?: string;
29
- outcome?: 'success' | 'failure' | (string & {});
30
- [key: string]: unknown;
31
- }
32
-
33
- export interface WithAuditOptions {
34
- ctx?: AuditContext;
35
- emitNow?: boolean;
36
- forceKeep?: boolean;
37
- logger?: RequestLogger;
38
- /**
39
- * Behaviour when no trace context can be resolved. Defaults to `warn`
40
- * (best-effort: run un-audited, warn once). See {@link OnMissingContext}.
41
- */
42
- onMissingContext?: OnMissingContext;
43
- }
44
-
45
- function flattenAuditAttributes(
46
- metadata: AuditMetadata,
47
- ): Record<string, string | number | boolean | string[] | number[] | boolean[]> {
48
- const attributes: Record<
49
- string,
50
- string | number | boolean | string[] | number[] | boolean[]
51
- > = {
52
- 'autotel.audit': true,
53
- };
54
-
55
- for (const [key, value] of Object.entries(metadata)) {
56
- const attr = toAttributeValue(value);
57
- if (attr !== undefined) {
58
- attributes[`audit.${key}`] = attr;
59
- }
60
- }
61
-
62
- return attributes;
63
- }
64
-
65
- export function forceKeepAuditEvent(ctx?: AuditContext): void {
66
- const traceCtx = resolveContextSafe(ctx);
67
- if (!traceCtx) return;
68
- traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
69
- traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
70
- traceCtx.setAttribute('autotel.audit.force_keep', true);
71
- }
72
-
73
- export function setAuditAttributes(
74
- metadata: AuditMetadata,
75
- ctx?: AuditContext,
76
- ): void {
77
- const traceCtx = resolveContextSafe(ctx);
78
- if (!traceCtx) return;
79
- traceCtx.setAttributes(flattenAuditAttributes(metadata));
80
- }
81
-
82
- export async function withAudit<T>(
83
- metadata: AuditMetadata,
84
- fn: (ctx: AuditContext, logger: RequestLogger) => T | Promise<T>,
85
- options: WithAuditOptions = {},
86
- ): Promise<T> {
87
- const traceCtx = resolveContextSafe(options.ctx);
88
-
89
- // No trace context: degrade per onMissingContext instead of throwing into
90
- // business logic. Audit is observability — it must never crash the caller.
91
- if (!traceCtx) {
92
- const mode = options.onMissingContext ?? 'warn';
93
- if (mode === 'throw') {
94
- throw new Error(MISSING_CONTEXT_MESSAGE);
95
- }
96
- if (mode === 'warn') {
97
- warnMissingContextOnce(metadata.action);
98
- }
99
- return fn(noopAuditContext(), options.logger ?? createNoopRequestLogger());
100
- }
101
-
102
- if (options.forceKeep !== false) {
103
- forceKeepAuditEvent(traceCtx);
104
- }
105
-
106
- setAuditAttributes(metadata, traceCtx);
107
-
108
- // A trace context may exist (e.g. caller-supplied options.ctx) without a
109
- // resolvable request logger. Record span attributes regardless and only skip
110
- // the canonical log line — never throw.
111
- let logger = options.logger ?? getRequestLoggerSafe() ?? undefined;
112
- if (!logger) {
113
- if ((options.onMissingContext ?? 'warn') === 'warn') {
114
- warnMissingLoggerOnce(metadata.action);
115
- }
116
- logger = createNoopRequestLogger();
117
- }
118
- logger.set({
119
- audit: {
120
- ...metadata,
121
- forceKeep: options.forceKeep !== false,
122
- },
123
- });
124
-
125
- try {
126
- const result = await fn(traceCtx, logger);
127
-
128
- if (!metadata.outcome) {
129
- setAuditAttributes({ ...metadata, outcome: 'success' }, traceCtx);
130
- }
131
-
132
- if (options.emitNow) {
133
- logger.emitNow();
134
- }
135
-
136
- return result;
137
- } catch (error) {
138
- const asError = error instanceof Error ? error : new Error(String(error));
139
- setAuditAttributes({ ...metadata, outcome: 'failure' }, traceCtx);
140
- logger.error(asError, {
141
- audit: {
142
- action: metadata.action,
143
- resource: metadata.resource,
144
- },
145
- });
146
-
147
- if (options.emitNow) {
148
- logger.emitNow();
149
- }
150
-
151
- throw asError;
152
- }
153
- }
@@ -1,24 +0,0 @@
1
- import { createCounter } from 'autotel';
2
-
3
- export interface LazyCounter {
4
- add(value: number, attributes?: Record<string, string | number | boolean>): void;
5
- }
6
-
7
- /**
8
- * Counter that is created on first use (the meter may not be configured
9
- * until `init()` completes) and whose failures are swallowed — metrics
10
- * must never break event emission or the span pipeline.
11
- */
12
- export function lazyCounter(name: string, description: string): LazyCounter {
13
- let counter: ReturnType<typeof createCounter> | undefined;
14
- return {
15
- add(value, attributes) {
16
- try {
17
- counter ??= createCounter(name, { description });
18
- counter.add(value, attributes);
19
- } catch {
20
- // Swallow — observability must never take the process down.
21
- }
22
- },
23
- };
24
- }
@@ -1,65 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { startSecurityHeartbeat } from './security-heartbeat';
3
-
4
- const counterAdd = vi.fn();
5
-
6
- vi.mock('autotel', () => ({
7
- createCounter: vi.fn(() => ({ add: counterAdd })),
8
- }));
9
-
10
- describe('startSecurityHeartbeat', () => {
11
- beforeEach(() => {
12
- vi.useFakeTimers();
13
- vi.clearAllMocks();
14
- });
15
-
16
- afterEach(() => {
17
- vi.useRealTimers();
18
- });
19
-
20
- it('beats immediately and then on every interval', () => {
21
- const heartbeat = startSecurityHeartbeat({ intervalMs: 10_000 });
22
-
23
- expect(counterAdd).toHaveBeenCalledTimes(1);
24
-
25
- vi.advanceTimersByTime(30_000);
26
- expect(counterAdd).toHaveBeenCalledTimes(4);
27
-
28
- heartbeat.stop();
29
- });
30
-
31
- it('stops beating after stop()', () => {
32
- const heartbeat = startSecurityHeartbeat({ intervalMs: 10_000 });
33
- heartbeat.stop();
34
-
35
- vi.advanceTimersByTime(60_000);
36
- expect(counterAdd).toHaveBeenCalledTimes(1); // only the initial beat
37
-
38
- heartbeat.stop(); // idempotent
39
- });
40
-
41
- it('attaches custom attributes to every beat', () => {
42
- const heartbeat = startSecurityHeartbeat({
43
- intervalMs: 10_000,
44
- attributes: { component: 'payments' },
45
- });
46
-
47
- vi.advanceTimersByTime(10_000);
48
- expect(counterAdd).toHaveBeenLastCalledWith(1, { component: 'payments' });
49
-
50
- heartbeat.stop();
51
- });
52
-
53
- it('survives a broken meter', async () => {
54
- const { createCounter } = vi.mocked(await import('autotel'));
55
- createCounter.mockImplementation(() => {
56
- throw new Error('meter not configured');
57
- });
58
-
59
- expect(() => {
60
- const heartbeat = startSecurityHeartbeat({ intervalMs: 10_000 });
61
- vi.advanceTimersByTime(20_000);
62
- heartbeat.stop();
63
- }).not.toThrow();
64
- });
65
- });
@@ -1,63 +0,0 @@
1
- import { SECURITY_METRICS } from 'autotel/security-schema';
2
- import { lazyCounter } from './lazy-counter';
3
-
4
- /**
5
- * Security-telemetry heartbeat.
6
- *
7
- * A silently-dead telemetry pipeline is itself a security failure (NIST
8
- * SP 800-92: systems must not keep operating without visibility into
9
- * security events). `startSecurityHeartbeat()` emits the
10
- * `autotel.security.heartbeat` counter on a fixed interval so security
11
- * teams can alert on the ABSENCE of telemetry from a service:
12
- *
13
- * ```promql
14
- * absent(rate(autotel_security_heartbeat_total{service_name="api"}[5m]))
15
- * ```
16
- *
17
- * ```typescript
18
- * const heartbeat = startSecurityHeartbeat();
19
- * // on shutdown:
20
- * heartbeat.stop();
21
- * ```
22
- */
23
-
24
- export interface SecurityHeartbeatOptions {
25
- /** Beat interval in milliseconds. Default 60_000. */
26
- intervalMs?: number;
27
- /** Extra counter attributes (keep cardinality low — labels, not data). */
28
- attributes?: Record<string, string | number | boolean>;
29
- }
30
-
31
- export interface SecurityHeartbeat {
32
- stop(): void;
33
- }
34
-
35
- export function startSecurityHeartbeat(
36
- options: SecurityHeartbeatOptions = {},
37
- ): SecurityHeartbeat {
38
- const intervalMs = options.intervalMs ?? 60_000;
39
- const attributes = options.attributes ?? {};
40
-
41
- const counter = lazyCounter(
42
- SECURITY_METRICS.heartbeat,
43
- 'Security-telemetry liveness signal — alert on its absence',
44
- );
45
-
46
- function beat(): void {
47
- counter.add(1, attributes);
48
- }
49
-
50
- beat(); // establish the series immediately, not one interval later
51
- const timer = setInterval(beat, intervalMs);
52
- // Never hold the process open just to beat.
53
- timer.unref?.();
54
-
55
- let stopped = false;
56
- return {
57
- stop() {
58
- if (stopped) return;
59
- stopped = true;
60
- clearInterval(timer);
61
- },
62
- };
63
- }