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 +223 -0
- package/dist/index.cjs +119 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +115 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
- package/src/index.test.ts +128 -0
- package/src/index.ts +186 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|