autotel-audit 0.1.0

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/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # autotel-audit
2
+
3
+ Audit-focused helpers for `autotel`. Provides structured audit logging with automatic tail-sampling bypass and OpenTelemetry attribute normalization.
4
+
5
+ ## What it provides
6
+
7
+ - **`withAudit(...)`** — Wraps an operation with audit metadata, automatic outcome tagging (success/failure), and optional immediate emit
8
+ - **`forceKeepAuditEvent(...)`** — Marks the current trace to bypass tail-drop sampling for compliance/audit trails
9
+ - **`setAuditAttributes(...)`** — Writes normalized `audit.*` attributes on the active span with automatic type conversion
10
+
11
+ ## Features
12
+
13
+ - **Structured Metadata** — Enforce consistent audit schemas with `AuditMetadata` interface
14
+ - **Automatic Outcome Tagging** — Operations auto-tagged as `success` or `failure` (override with explicit `outcome` field)
15
+ - **Sampling Bypass** — Force critical audit events through tail-sampling with `forceKeepAuditEvent()` or `options.forceKeep`
16
+ - **Type-Safe Attributes** — Automatic serialization of complex types (Objects, Dates, Arrays) to OpenTelemetry-compatible values
17
+ - **Request Context Integration** — Propagates actor ID, resource, and action across structured logs
18
+ - **Compliance Ready** — Emit audit events immediately (`emitNow: true`) for real-time compliance systems
19
+
20
+ ## Quick Start
21
+
22
+ ```ts
23
+ import { trace } from 'autotel';
24
+ import { withAudit } from 'autotel-audit';
25
+
26
+ export const deleteUser = trace(async () => {
27
+ return withAudit(
28
+ { action: 'user.delete', resource: 'user', actorId: 'admin-42' },
29
+ async (_ctx, log) => {
30
+ // business logic
31
+ log.info('User deleted successfully');
32
+ return { ok: true };
33
+ },
34
+ { emitNow: true },
35
+ );
36
+ });
37
+ ```
38
+
39
+ ## API Reference
40
+
41
+ ### `withAudit<T>(metadata, fn, options?)`
42
+
43
+ Wraps an async operation with audit metadata and handles success/failure outcomes.
44
+
45
+ **Parameters:**
46
+
47
+ - `metadata: AuditMetadata` — Audit event metadata (action, resource, actor, etc.)
48
+ - `fn: (ctx, logger) => Promise<T>` — Async function receiving audit context and request logger
49
+ - `options?: WithAuditOptions` — Optional configuration:
50
+ - `emitNow?: boolean` — Immediately emit the audit event (default: false)
51
+ - `forceKeep?: boolean` — Force event through tail-sampling (default: true)
52
+ - `ctx?: AuditContext` — Provide custom audit context (auto-resolved from trace if omitted)
53
+ - `logger?: RequestLogger` — Override the request logger instance
54
+
55
+ **Example with custom context:**
56
+
57
+ ```ts
58
+ const ctx = {
59
+ traceId: 'abc-123',
60
+ spanId: 'def-456',
61
+ correlationId: 'xyz-789',
62
+ setAttribute: (k, v) => span.setAttribute(k, v),
63
+ setAttributes: (attrs) => span.setAttributes(attrs),
64
+ };
65
+
66
+ await withAudit({ action: 'data.export' }, fn, { ctx, emitNow: true });
67
+ ```
68
+
69
+ ### `setAuditAttributes(metadata, ctx?)`
70
+
71
+ Write audit metadata as normalized `audit.*` span attributes without wrapping an operation.
72
+
73
+ ```ts
74
+ import { setAuditAttributes } from 'autotel-audit';
75
+
76
+ setAuditAttributes({
77
+ action: 'config.update',
78
+ resource: 'settings',
79
+ actorId: 'user-123',
80
+ category: 'admin',
81
+ });
82
+ // Sets: audit.action, audit.resource, audit.actorId, audit.category, autotel.audit=true
83
+ ```
84
+
85
+ ### `forceKeepAuditEvent(ctx?)`
86
+
87
+ Mark the active trace to bypass tail-drop sampling. Called automatically by `withAudit` unless `forceKeep: false`.
88
+
89
+ ```ts
90
+ import { trace } from 'autotel';
91
+ import { forceKeepAuditEvent } from 'autotel-audit';
92
+
93
+ export const readSecrets = trace(async (req) => {
94
+ if (req.user.role !== 'admin') {
95
+ forceKeepAuditEvent(); // Keep sensitive access attempts
96
+ throw new Error('Unauthorized');
97
+ }
98
+ // ...
99
+ });
100
+ ```
101
+
102
+ ## Type-Safe Metadata
103
+
104
+ Define audit schemas for different operations:
105
+
106
+ ```ts
107
+ import type { AuditMetadata } from 'autotel-audit';
108
+
109
+ interface DeleteUserAudit extends AuditMetadata {
110
+ action: 'user.delete';
111
+ resource: 'user';
112
+ actorId: string;
113
+ reason?: string;
114
+ }
115
+
116
+ interface PermissionUpdate extends AuditMetadata {
117
+ action: 'permission.update';
118
+ resource: 'role';
119
+ oldValue?: Record<string, boolean>;
120
+ newValue?: Record<string, boolean>;
121
+ actorId: string;
122
+ }
123
+ ```
124
+
125
+ ## Common Patterns
126
+
127
+ ### Emit audit events only on errors
128
+
129
+ ```ts
130
+ await withAudit(
131
+ { action: 'account.suspend', resource: 'account', actorId: 'admin-1' },
132
+ async (ctx, log) => {
133
+ try {
134
+ await suspendAccount();
135
+ } catch (err) {
136
+ log.error(err); // Auto-tagged with outcome: failure
137
+ throw;
138
+ }
139
+ },
140
+ { emitNow: true },
141
+ );
142
+ ```
143
+
144
+ ### Track sensitive operations with context
145
+
146
+ ```ts
147
+ await withAudit(
148
+ {
149
+ action: 'secret.access',
150
+ resource: 'api-key',
151
+ actorId: user.id,
152
+ secretType: 'api-key',
153
+ env: 'prod',
154
+ },
155
+ async () => {
156
+ // Fetch secret...
157
+ },
158
+ { emitNow: true, forceKeep: true },
159
+ );
160
+ ```
161
+
162
+ ### Nested audit context in complex flows
163
+
164
+ ```ts
165
+ export const transferFunds = trace(async (transfer) => {
166
+ return withAudit(
167
+ {
168
+ action: 'transfer.execute',
169
+ resource: 'transaction',
170
+ actorId: transfer.initiator,
171
+ amount: transfer.amount,
172
+ fromAccount: transfer.from,
173
+ toAccount: transfer.to,
174
+ },
175
+ async (ctx, log) => {
176
+ const debitResult = await debitAccount(transfer.from, transfer.amount);
177
+ const creditResult = await creditAccount(transfer.to, transfer.amount);
178
+
179
+ log.info('Transfer completed', {
180
+ transactionId: debitResult.txId,
181
+ debitStatus: debitResult.status,
182
+ creditStatus: creditResult.status,
183
+ });
184
+
185
+ return { success: true, txId: debitResult.txId };
186
+ },
187
+ { emitNow: true },
188
+ );
189
+ });
190
+ ```
191
+
192
+ ## Compliance & Sampling
193
+
194
+ ### Why force-keep audit events?
195
+
196
+ 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.
197
+
198
+ ```ts
199
+ // Default: force-keep is enabled (critical for audit)
200
+ await withAudit(metadata, fn);
201
+
202
+ // Disable if audit backend has separate retention
203
+ await withAudit(metadata, fn, { forceKeep: false });
204
+
205
+ // Manual control for hybrid scenarios
206
+ if (isPrivilegedOperation) {
207
+ forceKeepAuditEvent();
208
+ }
209
+ ```
210
+
211
+ ## Integration with Observability Backends
212
+
213
+ Audit attributes are standard OpenTelemetry span attributes and work with any OTLP-compatible backend (Datadog, New Relic, Jaeger, etc.).
214
+
215
+ - Attributes are stored as `audit.action`, `audit.resource`, `audit.actorId`, etc.
216
+ - Root span contains `autotel.audit: true` for filtering
217
+ - Use backend span filters to create audit dashboards and alerts
218
+
219
+ ## See Also
220
+
221
+ - **[Advanced Features](/advanced)** — Trace helpers, metadata flattening, isolated tracer providers
222
+ - **[Request Logging](/integrations/logging)** — Structured request context and event emission
223
+ - **[Autotel Core](/)** — `trace()`, `span()`, and request context patterns
package/dist/index.cjs ADDED
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ var autotel = require('autotel');
4
+
5
+ // src/index.ts
6
+ function resolveContext(ctx) {
7
+ if (ctx) return ctx;
8
+ const ids = autotel.getTraceContext();
9
+ const span = autotel.otelTrace.getActiveSpan();
10
+ if (ids && span) {
11
+ return {
12
+ traceId: ids.traceId,
13
+ spanId: ids.spanId,
14
+ correlationId: ids.correlationId,
15
+ setAttribute: (key, value) => span.setAttribute(key, value),
16
+ setAttributes: (attrs) => span.setAttributes(attrs)
17
+ };
18
+ }
19
+ throw new Error(
20
+ "[autotel-audit] No active trace context. Wrap your handler with trace() or pass options.ctx."
21
+ );
22
+ }
23
+ function toAttributeValue(value) {
24
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
25
+ return value;
26
+ }
27
+ if (Array.isArray(value)) {
28
+ if (value.every((entry) => typeof entry === "string")) {
29
+ return value;
30
+ }
31
+ if (value.every((entry) => typeof entry === "number")) {
32
+ return value;
33
+ }
34
+ if (value.every((entry) => typeof entry === "boolean")) {
35
+ return value;
36
+ }
37
+ try {
38
+ return JSON.stringify(value);
39
+ } catch {
40
+ return "<serialization-failed>";
41
+ }
42
+ }
43
+ if (value instanceof Date) {
44
+ return value.toISOString();
45
+ }
46
+ if (value === null || value === void 0) {
47
+ return void 0;
48
+ }
49
+ try {
50
+ return JSON.stringify(value);
51
+ } catch {
52
+ return "<serialization-failed>";
53
+ }
54
+ }
55
+ function flattenAuditAttributes(metadata) {
56
+ const attributes = {
57
+ "autotel.audit": true
58
+ };
59
+ for (const [key, value] of Object.entries(metadata)) {
60
+ const attr = toAttributeValue(value);
61
+ if (attr !== void 0) {
62
+ attributes[`audit.${key}`] = attr;
63
+ }
64
+ }
65
+ return attributes;
66
+ }
67
+ function forceKeepAuditEvent(ctx) {
68
+ const traceCtx = resolveContext(ctx);
69
+ traceCtx.setAttribute(autotel.AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
70
+ traceCtx.setAttribute(autotel.AUTOTEL_SAMPLING_TAIL_KEEP, true);
71
+ traceCtx.setAttribute("autotel.audit.force_keep", true);
72
+ }
73
+ function setAuditAttributes(metadata, ctx) {
74
+ const traceCtx = resolveContext(ctx);
75
+ traceCtx.setAttributes(flattenAuditAttributes(metadata));
76
+ }
77
+ async function withAudit(metadata, fn, options = {}) {
78
+ const traceCtx = resolveContext(options.ctx);
79
+ if (options.forceKeep !== false) {
80
+ forceKeepAuditEvent(traceCtx);
81
+ }
82
+ setAuditAttributes(metadata, traceCtx);
83
+ const logger = options.logger ?? autotel.getRequestLogger();
84
+ logger.set({
85
+ audit: {
86
+ ...metadata,
87
+ forceKeep: options.forceKeep !== false
88
+ }
89
+ });
90
+ try {
91
+ const result = await fn(traceCtx, logger);
92
+ if (!metadata.outcome) {
93
+ setAuditAttributes({ ...metadata, outcome: "success" }, traceCtx);
94
+ }
95
+ if (options.emitNow) {
96
+ logger.emitNow();
97
+ }
98
+ return result;
99
+ } catch (error) {
100
+ const asError = error instanceof Error ? error : new Error(String(error));
101
+ setAuditAttributes({ ...metadata, outcome: "failure" }, traceCtx);
102
+ logger.error(asError, {
103
+ audit: {
104
+ action: metadata.action,
105
+ resource: metadata.resource
106
+ }
107
+ });
108
+ if (options.emitNow) {
109
+ logger.emitNow();
110
+ }
111
+ throw asError;
112
+ }
113
+ }
114
+
115
+ exports.forceKeepAuditEvent = forceKeepAuditEvent;
116
+ exports.setAuditAttributes = setAuditAttributes;
117
+ exports.withAudit = withAudit;
118
+ //# sourceMappingURL=index.cjs.map
119
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":["getTraceContext","otelTrace","AUTOTEL_SAMPLING_TAIL_EVALUATED","AUTOTEL_SAMPLING_TAIL_KEEP","getRequestLogger"],"mappings":"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","file":"index.cjs","sourcesContent":["import {\n AUTOTEL_SAMPLING_TAIL_EVALUATED,\n AUTOTEL_SAMPLING_TAIL_KEEP,\n getRequestLogger,\n getTraceContext,\n otelTrace,\n} from 'autotel';\nimport type { RequestLogger } from 'autotel';\n\nexport interface AuditMetadata {\n action: string;\n resource?: string;\n actorId?: string;\n category?: string;\n outcome?: 'success' | 'failure' | (string & {});\n [key: string]: unknown;\n}\n\nexport interface WithAuditOptions {\n ctx?: AuditContext;\n emitNow?: boolean;\n forceKeep?: boolean;\n logger?: RequestLogger;\n}\n\nexport interface AuditContext {\n traceId: string;\n spanId: string;\n correlationId: string;\n setAttribute(key: string, value: string | number | boolean): void;\n setAttributes(\n attrs: Record<string, string | number | boolean | string[] | number[] | boolean[]>,\n ): void;\n}\n\nfunction resolveContext(ctx?: AuditContext): AuditContext {\n if (ctx) return ctx;\n\n const ids = getTraceContext();\n const span = otelTrace.getActiveSpan();\n if (ids && span) {\n return {\n traceId: ids.traceId,\n spanId: ids.spanId,\n correlationId: ids.correlationId,\n setAttribute: (key, value) => span.setAttribute(key, value),\n setAttributes: (attrs) => span.setAttributes(attrs),\n };\n }\n\n throw new Error(\n '[autotel-audit] No active trace context. Wrap your handler with trace() or pass options.ctx.',\n );\n}\n\nfunction toAttributeValue(\n value: unknown,\n): string | number | boolean | string[] | number[] | boolean[] | undefined {\n if (\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n return value;\n }\n\n if (Array.isArray(value)) {\n if (value.every((entry) => typeof entry === 'string')) {\n return value;\n }\n\n if (value.every((entry) => typeof entry === 'number')) {\n return value;\n }\n\n if (value.every((entry) => typeof entry === 'boolean')) {\n return value;\n }\n\n try {\n return JSON.stringify(value);\n } catch {\n return '<serialization-failed>';\n }\n }\n\n if (value instanceof Date) {\n return value.toISOString();\n }\n\n if (value === null || value === undefined) {\n return undefined;\n }\n\n try {\n return JSON.stringify(value);\n } catch {\n return '<serialization-failed>';\n }\n}\n\nfunction flattenAuditAttributes(\n metadata: AuditMetadata,\n): Record<string, string | number | boolean | string[] | number[] | boolean[]> {\n const attributes: Record<\n string,\n string | number | boolean | string[] | number[] | boolean[]\n > = {\n 'autotel.audit': true,\n };\n\n for (const [key, value] of Object.entries(metadata)) {\n const attr = toAttributeValue(value);\n if (attr !== undefined) {\n attributes[`audit.${key}`] = attr;\n }\n }\n\n return attributes;\n}\n\nexport function forceKeepAuditEvent(ctx?: AuditContext): void {\n const traceCtx = resolveContext(ctx);\n traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);\n traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);\n traceCtx.setAttribute('autotel.audit.force_keep', true);\n}\n\nexport function setAuditAttributes(\n metadata: AuditMetadata,\n ctx?: AuditContext,\n): void {\n const traceCtx = resolveContext(ctx);\n traceCtx.setAttributes(flattenAuditAttributes(metadata));\n}\n\nexport async function withAudit<T>(\n metadata: AuditMetadata,\n fn: (ctx: AuditContext, logger: RequestLogger) => T | Promise<T>,\n options: WithAuditOptions = {},\n): Promise<T> {\n const traceCtx = resolveContext(options.ctx);\n\n if (options.forceKeep !== false) {\n forceKeepAuditEvent(traceCtx);\n }\n\n setAuditAttributes(metadata, traceCtx);\n\n const logger = options.logger ?? getRequestLogger();\n logger.set({\n audit: {\n ...metadata,\n forceKeep: options.forceKeep !== false,\n },\n });\n\n try {\n const result = await fn(traceCtx, logger);\n\n if (!metadata.outcome) {\n setAuditAttributes({ ...metadata, outcome: 'success' }, traceCtx);\n }\n\n if (options.emitNow) {\n logger.emitNow();\n }\n\n return result;\n } catch (error) {\n const asError = error instanceof Error ? error : new Error(String(error));\n setAuditAttributes({ ...metadata, outcome: 'failure' }, traceCtx);\n logger.error(asError, {\n audit: {\n action: metadata.action,\n resource: metadata.resource,\n },\n });\n\n if (options.emitNow) {\n logger.emitNow();\n }\n\n throw asError;\n }\n}\n"]}
@@ -0,0 +1,28 @@
1
+ import { RequestLogger } from 'autotel';
2
+
3
+ interface AuditMetadata {
4
+ action: string;
5
+ resource?: string;
6
+ actorId?: string;
7
+ category?: string;
8
+ outcome?: 'success' | 'failure' | (string & {});
9
+ [key: string]: unknown;
10
+ }
11
+ interface WithAuditOptions {
12
+ ctx?: AuditContext;
13
+ emitNow?: boolean;
14
+ forceKeep?: boolean;
15
+ logger?: RequestLogger;
16
+ }
17
+ interface AuditContext {
18
+ traceId: string;
19
+ spanId: string;
20
+ correlationId: string;
21
+ setAttribute(key: string, value: string | number | boolean): void;
22
+ setAttributes(attrs: Record<string, string | number | boolean | string[] | number[] | boolean[]>): void;
23
+ }
24
+ declare function forceKeepAuditEvent(ctx?: AuditContext): void;
25
+ declare function setAuditAttributes(metadata: AuditMetadata, ctx?: AuditContext): void;
26
+ declare function withAudit<T>(metadata: AuditMetadata, fn: (ctx: AuditContext, logger: RequestLogger) => T | Promise<T>, options?: WithAuditOptions): Promise<T>;
27
+
28
+ export { type AuditContext, type AuditMetadata, type WithAuditOptions, forceKeepAuditEvent, setAuditAttributes, withAudit };
@@ -0,0 +1,28 @@
1
+ import { RequestLogger } from 'autotel';
2
+
3
+ interface AuditMetadata {
4
+ action: string;
5
+ resource?: string;
6
+ actorId?: string;
7
+ category?: string;
8
+ outcome?: 'success' | 'failure' | (string & {});
9
+ [key: string]: unknown;
10
+ }
11
+ interface WithAuditOptions {
12
+ ctx?: AuditContext;
13
+ emitNow?: boolean;
14
+ forceKeep?: boolean;
15
+ logger?: RequestLogger;
16
+ }
17
+ interface AuditContext {
18
+ traceId: string;
19
+ spanId: string;
20
+ correlationId: string;
21
+ setAttribute(key: string, value: string | number | boolean): void;
22
+ setAttributes(attrs: Record<string, string | number | boolean | string[] | number[] | boolean[]>): void;
23
+ }
24
+ declare function forceKeepAuditEvent(ctx?: AuditContext): void;
25
+ declare function setAuditAttributes(metadata: AuditMetadata, ctx?: AuditContext): void;
26
+ declare function withAudit<T>(metadata: AuditMetadata, fn: (ctx: AuditContext, logger: RequestLogger) => T | Promise<T>, options?: WithAuditOptions): Promise<T>;
27
+
28
+ export { type AuditContext, type AuditMetadata, type WithAuditOptions, forceKeepAuditEvent, setAuditAttributes, withAudit };
package/dist/index.js ADDED
@@ -0,0 +1,115 @@
1
+ import { AUTOTEL_SAMPLING_TAIL_EVALUATED, AUTOTEL_SAMPLING_TAIL_KEEP, getRequestLogger, getTraceContext, otelTrace } from 'autotel';
2
+
3
+ // src/index.ts
4
+ function resolveContext(ctx) {
5
+ if (ctx) return ctx;
6
+ const ids = getTraceContext();
7
+ const span = otelTrace.getActiveSpan();
8
+ if (ids && span) {
9
+ return {
10
+ traceId: ids.traceId,
11
+ spanId: ids.spanId,
12
+ correlationId: ids.correlationId,
13
+ setAttribute: (key, value) => span.setAttribute(key, value),
14
+ setAttributes: (attrs) => span.setAttributes(attrs)
15
+ };
16
+ }
17
+ throw new Error(
18
+ "[autotel-audit] No active trace context. Wrap your handler with trace() or pass options.ctx."
19
+ );
20
+ }
21
+ function toAttributeValue(value) {
22
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
23
+ return value;
24
+ }
25
+ if (Array.isArray(value)) {
26
+ if (value.every((entry) => typeof entry === "string")) {
27
+ return value;
28
+ }
29
+ if (value.every((entry) => typeof entry === "number")) {
30
+ return value;
31
+ }
32
+ if (value.every((entry) => typeof entry === "boolean")) {
33
+ return value;
34
+ }
35
+ try {
36
+ return JSON.stringify(value);
37
+ } catch {
38
+ return "<serialization-failed>";
39
+ }
40
+ }
41
+ if (value instanceof Date) {
42
+ return value.toISOString();
43
+ }
44
+ if (value === null || value === void 0) {
45
+ return void 0;
46
+ }
47
+ try {
48
+ return JSON.stringify(value);
49
+ } catch {
50
+ return "<serialization-failed>";
51
+ }
52
+ }
53
+ function flattenAuditAttributes(metadata) {
54
+ const attributes = {
55
+ "autotel.audit": true
56
+ };
57
+ for (const [key, value] of Object.entries(metadata)) {
58
+ const attr = toAttributeValue(value);
59
+ if (attr !== void 0) {
60
+ attributes[`audit.${key}`] = attr;
61
+ }
62
+ }
63
+ return attributes;
64
+ }
65
+ function forceKeepAuditEvent(ctx) {
66
+ const traceCtx = resolveContext(ctx);
67
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
68
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
69
+ traceCtx.setAttribute("autotel.audit.force_keep", true);
70
+ }
71
+ function setAuditAttributes(metadata, ctx) {
72
+ const traceCtx = resolveContext(ctx);
73
+ traceCtx.setAttributes(flattenAuditAttributes(metadata));
74
+ }
75
+ async function withAudit(metadata, fn, options = {}) {
76
+ const traceCtx = resolveContext(options.ctx);
77
+ if (options.forceKeep !== false) {
78
+ forceKeepAuditEvent(traceCtx);
79
+ }
80
+ setAuditAttributes(metadata, traceCtx);
81
+ const logger = options.logger ?? getRequestLogger();
82
+ logger.set({
83
+ audit: {
84
+ ...metadata,
85
+ forceKeep: options.forceKeep !== false
86
+ }
87
+ });
88
+ try {
89
+ const result = await fn(traceCtx, logger);
90
+ if (!metadata.outcome) {
91
+ setAuditAttributes({ ...metadata, outcome: "success" }, traceCtx);
92
+ }
93
+ if (options.emitNow) {
94
+ logger.emitNow();
95
+ }
96
+ return result;
97
+ } catch (error) {
98
+ const asError = error instanceof Error ? error : new Error(String(error));
99
+ setAuditAttributes({ ...metadata, outcome: "failure" }, traceCtx);
100
+ logger.error(asError, {
101
+ audit: {
102
+ action: metadata.action,
103
+ resource: metadata.resource
104
+ }
105
+ });
106
+ if (options.emitNow) {
107
+ logger.emitNow();
108
+ }
109
+ throw asError;
110
+ }
111
+ }
112
+
113
+ export { forceKeepAuditEvent, setAuditAttributes, withAudit };
114
+ //# sourceMappingURL=index.js.map
115
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import {\n AUTOTEL_SAMPLING_TAIL_EVALUATED,\n AUTOTEL_SAMPLING_TAIL_KEEP,\n getRequestLogger,\n getTraceContext,\n otelTrace,\n} from 'autotel';\nimport type { RequestLogger } from 'autotel';\n\nexport interface AuditMetadata {\n action: string;\n resource?: string;\n actorId?: string;\n category?: string;\n outcome?: 'success' | 'failure' | (string & {});\n [key: string]: unknown;\n}\n\nexport interface WithAuditOptions {\n ctx?: AuditContext;\n emitNow?: boolean;\n forceKeep?: boolean;\n logger?: RequestLogger;\n}\n\nexport interface AuditContext {\n traceId: string;\n spanId: string;\n correlationId: string;\n setAttribute(key: string, value: string | number | boolean): void;\n setAttributes(\n attrs: Record<string, string | number | boolean | string[] | number[] | boolean[]>,\n ): void;\n}\n\nfunction resolveContext(ctx?: AuditContext): AuditContext {\n if (ctx) return ctx;\n\n const ids = getTraceContext();\n const span = otelTrace.getActiveSpan();\n if (ids && span) {\n return {\n traceId: ids.traceId,\n spanId: ids.spanId,\n correlationId: ids.correlationId,\n setAttribute: (key, value) => span.setAttribute(key, value),\n setAttributes: (attrs) => span.setAttributes(attrs),\n };\n }\n\n throw new Error(\n '[autotel-audit] No active trace context. Wrap your handler with trace() or pass options.ctx.',\n );\n}\n\nfunction toAttributeValue(\n value: unknown,\n): string | number | boolean | string[] | number[] | boolean[] | undefined {\n if (\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n return value;\n }\n\n if (Array.isArray(value)) {\n if (value.every((entry) => typeof entry === 'string')) {\n return value;\n }\n\n if (value.every((entry) => typeof entry === 'number')) {\n return value;\n }\n\n if (value.every((entry) => typeof entry === 'boolean')) {\n return value;\n }\n\n try {\n return JSON.stringify(value);\n } catch {\n return '<serialization-failed>';\n }\n }\n\n if (value instanceof Date) {\n return value.toISOString();\n }\n\n if (value === null || value === undefined) {\n return undefined;\n }\n\n try {\n return JSON.stringify(value);\n } catch {\n return '<serialization-failed>';\n }\n}\n\nfunction flattenAuditAttributes(\n metadata: AuditMetadata,\n): Record<string, string | number | boolean | string[] | number[] | boolean[]> {\n const attributes: Record<\n string,\n string | number | boolean | string[] | number[] | boolean[]\n > = {\n 'autotel.audit': true,\n };\n\n for (const [key, value] of Object.entries(metadata)) {\n const attr = toAttributeValue(value);\n if (attr !== undefined) {\n attributes[`audit.${key}`] = attr;\n }\n }\n\n return attributes;\n}\n\nexport function forceKeepAuditEvent(ctx?: AuditContext): void {\n const traceCtx = resolveContext(ctx);\n traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);\n traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);\n traceCtx.setAttribute('autotel.audit.force_keep', true);\n}\n\nexport function setAuditAttributes(\n metadata: AuditMetadata,\n ctx?: AuditContext,\n): void {\n const traceCtx = resolveContext(ctx);\n traceCtx.setAttributes(flattenAuditAttributes(metadata));\n}\n\nexport async function withAudit<T>(\n metadata: AuditMetadata,\n fn: (ctx: AuditContext, logger: RequestLogger) => T | Promise<T>,\n options: WithAuditOptions = {},\n): Promise<T> {\n const traceCtx = resolveContext(options.ctx);\n\n if (options.forceKeep !== false) {\n forceKeepAuditEvent(traceCtx);\n }\n\n setAuditAttributes(metadata, traceCtx);\n\n const logger = options.logger ?? getRequestLogger();\n logger.set({\n audit: {\n ...metadata,\n forceKeep: options.forceKeep !== false,\n },\n });\n\n try {\n const result = await fn(traceCtx, logger);\n\n if (!metadata.outcome) {\n setAuditAttributes({ ...metadata, outcome: 'success' }, traceCtx);\n }\n\n if (options.emitNow) {\n logger.emitNow();\n }\n\n return result;\n } catch (error) {\n const asError = error instanceof Error ? error : new Error(String(error));\n setAuditAttributes({ ...metadata, outcome: 'failure' }, traceCtx);\n logger.error(asError, {\n audit: {\n action: metadata.action,\n resource: metadata.resource,\n },\n });\n\n if (options.emitNow) {\n logger.emitNow();\n }\n\n throw asError;\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "autotel-audit",
3
+ "version": "0.1.0",
4
+ "description": "Audit-focused helpers for Autotel (force-keep + structured audit instrumentation)",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "dev": "tsup --watch",
24
+ "lint": "eslint src/**/*.ts",
25
+ "type-check": "tsc --noEmit",
26
+ "test": "vitest run"
27
+ },
28
+ "keywords": [
29
+ "autotel",
30
+ "audit",
31
+ "compliance",
32
+ "opentelemetry",
33
+ "tail-sampling"
34
+ ],
35
+ "author": "Jag Reehal <jag@jagreehal.com> (https://jagreehal.com)",
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "autotel": "workspace:*"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^25.6.0",
42
+ "@typescript-eslint/eslint-plugin": "^8.59.1",
43
+ "@typescript-eslint/parser": "^8.59.1",
44
+ "eslint-config-prettier": "^10.1.8",
45
+ "eslint-plugin-unicorn": "^64.0.0",
46
+ "tsup": "^8.5.1",
47
+ "typescript": "^6.0.3",
48
+ "typescript-eslint": "^8.59.1",
49
+ "vitest": "^4.1.5"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/jagreehal/autotel",
54
+ "directory": "packages/autotel-audit"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/jagreehal/autotel/issues"
58
+ },
59
+ "homepage": "https://github.com/jagreehal/autotel#readme"
60
+ }
@@ -0,0 +1,128 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ forceKeepAuditEvent,
4
+ setAuditAttributes,
5
+ withAudit,
6
+ type AuditMetadata,
7
+ } from './index';
8
+
9
+ const setAttribute = vi.fn();
10
+ const setAttributes = vi.fn();
11
+ const mockCtx = {
12
+ traceId: 'trace-1',
13
+ spanId: 'span-1',
14
+ correlationId: 'corr-1',
15
+ setAttribute,
16
+ setAttributes,
17
+ setStatus: vi.fn(),
18
+ addLink: vi.fn(),
19
+ addLinks: vi.fn(),
20
+ updateName: vi.fn(),
21
+ isRecording: vi.fn(() => true),
22
+ recordError: vi.fn(),
23
+ track: vi.fn(),
24
+ getBaggage: vi.fn(),
25
+ setBaggage: vi.fn(),
26
+ deleteBaggage: vi.fn(),
27
+ getAllBaggage: vi.fn(),
28
+ getTypedBaggage: vi.fn(),
29
+ setTypedBaggage: vi.fn(),
30
+ withBaggage: vi.fn(),
31
+ };
32
+
33
+ const logger = {
34
+ set: vi.fn(),
35
+ info: vi.fn(),
36
+ warn: vi.fn(),
37
+ error: vi.fn(),
38
+ getContext: vi.fn(() => ({})),
39
+ emitNow: vi.fn(() => ({
40
+ timestamp: new Date().toISOString(),
41
+ traceId: 'trace-1',
42
+ spanId: 'span-1',
43
+ correlationId: 'corr-1',
44
+ context: {},
45
+ })),
46
+ fork: vi.fn(),
47
+ };
48
+
49
+ vi.mock('autotel', () => ({
50
+ AUTOTEL_SAMPLING_TAIL_EVALUATED: 'autotel.sampling.tail.evaluated',
51
+ AUTOTEL_SAMPLING_TAIL_KEEP: 'autotel.sampling.tail.keep',
52
+ getTraceContext: vi.fn(() => mockCtx),
53
+ getRequestLogger: vi.fn(() => logger),
54
+ otelTrace: {
55
+ getActiveSpan: vi.fn(() => ({
56
+ setAttribute,
57
+ setAttributes,
58
+ })),
59
+ },
60
+ }));
61
+
62
+ describe('autotel-audit', () => {
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ });
66
+
67
+ it('forceKeepAuditEvent sets tail keep attributes', () => {
68
+ forceKeepAuditEvent(mockCtx as never);
69
+
70
+ expect(setAttribute).toHaveBeenCalledWith(
71
+ 'autotel.sampling.tail.evaluated',
72
+ true,
73
+ );
74
+ expect(setAttribute).toHaveBeenCalledWith('autotel.sampling.tail.keep', true);
75
+ expect(setAttribute).toHaveBeenCalledWith('autotel.audit.force_keep', true);
76
+ });
77
+
78
+ it('setAuditAttributes writes audit.* attributes', () => {
79
+ const metadata: AuditMetadata = {
80
+ action: 'user.delete',
81
+ resource: 'account',
82
+ actorId: 'admin-1',
83
+ };
84
+
85
+ setAuditAttributes(metadata, mockCtx as never);
86
+
87
+ expect(setAttributes).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ 'autotel.audit': true,
90
+ 'audit.action': 'user.delete',
91
+ 'audit.resource': 'account',
92
+ 'audit.actorId': 'admin-1',
93
+ }),
94
+ );
95
+ });
96
+
97
+ it('withAudit marks success and optionally emits', async () => {
98
+ const result = await withAudit(
99
+ { action: 'permission.update', resource: 'role' },
100
+ async () => 'ok',
101
+ { emitNow: true },
102
+ );
103
+
104
+ expect(result).toBe('ok');
105
+ expect(logger.set).toHaveBeenCalled();
106
+ expect(setAttributes).toHaveBeenCalledWith(
107
+ expect.objectContaining({
108
+ 'audit.outcome': 'success',
109
+ }),
110
+ );
111
+ expect(logger.emitNow).toHaveBeenCalledTimes(1);
112
+ });
113
+
114
+ it('withAudit marks failure and rethrows', async () => {
115
+ await expect(
116
+ withAudit({ action: 'secrets.read' }, async () => {
117
+ throw new Error('denied');
118
+ }),
119
+ ).rejects.toThrow('denied');
120
+
121
+ expect(setAttributes).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ 'audit.outcome': 'failure',
124
+ }),
125
+ );
126
+ expect(logger.error).toHaveBeenCalledTimes(1);
127
+ });
128
+ });
package/src/index.ts ADDED
@@ -0,0 +1,186 @@
1
+ import {
2
+ AUTOTEL_SAMPLING_TAIL_EVALUATED,
3
+ AUTOTEL_SAMPLING_TAIL_KEEP,
4
+ getRequestLogger,
5
+ getTraceContext,
6
+ otelTrace,
7
+ } from 'autotel';
8
+ import type { RequestLogger } from 'autotel';
9
+
10
+ export interface AuditMetadata {
11
+ action: string;
12
+ resource?: string;
13
+ actorId?: string;
14
+ category?: string;
15
+ outcome?: 'success' | 'failure' | (string & {});
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ export interface WithAuditOptions {
20
+ ctx?: AuditContext;
21
+ emitNow?: boolean;
22
+ forceKeep?: boolean;
23
+ logger?: RequestLogger;
24
+ }
25
+
26
+ export interface AuditContext {
27
+ traceId: string;
28
+ spanId: string;
29
+ correlationId: string;
30
+ setAttribute(key: string, value: string | number | boolean): void;
31
+ setAttributes(
32
+ attrs: Record<string, string | number | boolean | string[] | number[] | boolean[]>,
33
+ ): void;
34
+ }
35
+
36
+ function resolveContext(ctx?: AuditContext): AuditContext {
37
+ if (ctx) return ctx;
38
+
39
+ const ids = getTraceContext();
40
+ const span = otelTrace.getActiveSpan();
41
+ if (ids && span) {
42
+ return {
43
+ traceId: ids.traceId,
44
+ spanId: ids.spanId,
45
+ correlationId: ids.correlationId,
46
+ setAttribute: (key, value) => span.setAttribute(key, value),
47
+ setAttributes: (attrs) => span.setAttributes(attrs),
48
+ };
49
+ }
50
+
51
+ throw new Error(
52
+ '[autotel-audit] No active trace context. Wrap your handler with trace() or pass options.ctx.',
53
+ );
54
+ }
55
+
56
+ function toAttributeValue(
57
+ value: unknown,
58
+ ): string | number | boolean | string[] | number[] | boolean[] | undefined {
59
+ if (
60
+ typeof value === 'string' ||
61
+ typeof value === 'number' ||
62
+ typeof value === 'boolean'
63
+ ) {
64
+ return value;
65
+ }
66
+
67
+ if (Array.isArray(value)) {
68
+ if (value.every((entry) => typeof entry === 'string')) {
69
+ return value;
70
+ }
71
+
72
+ if (value.every((entry) => typeof entry === 'number')) {
73
+ return value;
74
+ }
75
+
76
+ if (value.every((entry) => typeof entry === 'boolean')) {
77
+ return value;
78
+ }
79
+
80
+ try {
81
+ return JSON.stringify(value);
82
+ } catch {
83
+ return '<serialization-failed>';
84
+ }
85
+ }
86
+
87
+ if (value instanceof Date) {
88
+ return value.toISOString();
89
+ }
90
+
91
+ if (value === null || value === undefined) {
92
+ return undefined;
93
+ }
94
+
95
+ try {
96
+ return JSON.stringify(value);
97
+ } catch {
98
+ return '<serialization-failed>';
99
+ }
100
+ }
101
+
102
+ function flattenAuditAttributes(
103
+ metadata: AuditMetadata,
104
+ ): Record<string, string | number | boolean | string[] | number[] | boolean[]> {
105
+ const attributes: Record<
106
+ string,
107
+ string | number | boolean | string[] | number[] | boolean[]
108
+ > = {
109
+ 'autotel.audit': true,
110
+ };
111
+
112
+ for (const [key, value] of Object.entries(metadata)) {
113
+ const attr = toAttributeValue(value);
114
+ if (attr !== undefined) {
115
+ attributes[`audit.${key}`] = attr;
116
+ }
117
+ }
118
+
119
+ return attributes;
120
+ }
121
+
122
+ export function forceKeepAuditEvent(ctx?: AuditContext): void {
123
+ const traceCtx = resolveContext(ctx);
124
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
125
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
126
+ traceCtx.setAttribute('autotel.audit.force_keep', true);
127
+ }
128
+
129
+ export function setAuditAttributes(
130
+ metadata: AuditMetadata,
131
+ ctx?: AuditContext,
132
+ ): void {
133
+ const traceCtx = resolveContext(ctx);
134
+ traceCtx.setAttributes(flattenAuditAttributes(metadata));
135
+ }
136
+
137
+ export async function withAudit<T>(
138
+ metadata: AuditMetadata,
139
+ fn: (ctx: AuditContext, logger: RequestLogger) => T | Promise<T>,
140
+ options: WithAuditOptions = {},
141
+ ): Promise<T> {
142
+ const traceCtx = resolveContext(options.ctx);
143
+
144
+ if (options.forceKeep !== false) {
145
+ forceKeepAuditEvent(traceCtx);
146
+ }
147
+
148
+ setAuditAttributes(metadata, traceCtx);
149
+
150
+ const logger = options.logger ?? getRequestLogger();
151
+ logger.set({
152
+ audit: {
153
+ ...metadata,
154
+ forceKeep: options.forceKeep !== false,
155
+ },
156
+ });
157
+
158
+ try {
159
+ const result = await fn(traceCtx, logger);
160
+
161
+ if (!metadata.outcome) {
162
+ setAuditAttributes({ ...metadata, outcome: 'success' }, traceCtx);
163
+ }
164
+
165
+ if (options.emitNow) {
166
+ logger.emitNow();
167
+ }
168
+
169
+ return result;
170
+ } catch (error) {
171
+ const asError = error instanceof Error ? error : new Error(String(error));
172
+ setAuditAttributes({ ...metadata, outcome: 'failure' }, traceCtx);
173
+ logger.error(asError, {
174
+ audit: {
175
+ action: metadata.action,
176
+ resource: metadata.resource,
177
+ },
178
+ });
179
+
180
+ if (options.emitNow) {
181
+ logger.emitNow();
182
+ }
183
+
184
+ throw asError;
185
+ }
186
+ }