autotel 3.2.0 → 3.3.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.
Files changed (76) hide show
  1. package/dist/auto.cjs +2 -2
  2. package/dist/auto.js +1 -1
  3. package/dist/{chunk-ZKKJQS6R.js → chunk-32AXF4MA.js} +29 -7
  4. package/dist/chunk-32AXF4MA.js.map +1 -0
  5. package/dist/{chunk-RRTFFAG3.cjs → chunk-3MZJ7Y24.cjs} +5 -5
  6. package/dist/{chunk-RRTFFAG3.cjs.map → chunk-3MZJ7Y24.cjs.map} +1 -1
  7. package/dist/{chunk-5RZ3NZ2M.cjs → chunk-4RA6HIYF.cjs} +5 -5
  8. package/dist/{chunk-5RZ3NZ2M.cjs.map → chunk-4RA6HIYF.cjs.map} +1 -1
  9. package/dist/{chunk-NIDUQZIN.js → chunk-4TAQQZDU.js} +3 -3
  10. package/dist/{chunk-NIDUQZIN.js.map → chunk-4TAQQZDU.js.map} +1 -1
  11. package/dist/{chunk-UV64CWMA.cjs → chunk-DQSVSGK3.cjs} +13 -13
  12. package/dist/{chunk-UV64CWMA.cjs.map → chunk-DQSVSGK3.cjs.map} +1 -1
  13. package/dist/{chunk-IS2QJ44P.js → chunk-FZROHTZZ.js} +3 -3
  14. package/dist/{chunk-IS2QJ44P.js.map → chunk-FZROHTZZ.js.map} +1 -1
  15. package/dist/{chunk-4UUEGERM.cjs → chunk-MQH5OOZK.cjs} +17 -17
  16. package/dist/{chunk-4UUEGERM.cjs.map → chunk-MQH5OOZK.cjs.map} +1 -1
  17. package/dist/{chunk-QVLMGNQF.js → chunk-OACAWYLR.js} +4 -4
  18. package/dist/{chunk-QVLMGNQF.js.map → chunk-OACAWYLR.js.map} +1 -1
  19. package/dist/{chunk-RZI5XXAD.js → chunk-OPCTN527.js} +3 -3
  20. package/dist/{chunk-RZI5XXAD.js.map → chunk-OPCTN527.js.map} +1 -1
  21. package/dist/{chunk-NN2GODP4.cjs → chunk-QICFEFD6.cjs} +7 -7
  22. package/dist/{chunk-NN2GODP4.cjs.map → chunk-QICFEFD6.cjs.map} +1 -1
  23. package/dist/{chunk-KKIYPZOP.cjs → chunk-QJYWKAC5.cjs} +29 -7
  24. package/dist/chunk-QJYWKAC5.cjs.map +1 -0
  25. package/dist/{chunk-7EVW3Z37.js → chunk-TGV2XF57.js} +3 -3
  26. package/dist/{chunk-7EVW3Z37.js.map → chunk-TGV2XF57.js.map} +1 -1
  27. package/dist/{chunk-FVA2YDEQ.js → chunk-U4D5IBSB.js} +4 -4
  28. package/dist/{chunk-FVA2YDEQ.js.map → chunk-U4D5IBSB.js.map} +1 -1
  29. package/dist/{chunk-EEQHQKPP.cjs → chunk-U72TGONP.cjs} +32 -32
  30. package/dist/{chunk-EEQHQKPP.cjs.map → chunk-U72TGONP.cjs.map} +1 -1
  31. package/dist/correlation-id.cjs +10 -10
  32. package/dist/correlation-id.js +2 -2
  33. package/dist/decorators.cjs +4 -4
  34. package/dist/decorators.js +3 -3
  35. package/dist/event.cjs +6 -6
  36. package/dist/event.js +3 -3
  37. package/dist/functional.cjs +11 -11
  38. package/dist/functional.js +3 -3
  39. package/dist/http.cjs +3 -3
  40. package/dist/http.js +2 -2
  41. package/dist/index.cjs +215 -70
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/index.d.cts +185 -2
  44. package/dist/index.d.ts +185 -2
  45. package/dist/index.js +149 -12
  46. package/dist/index.js.map +1 -1
  47. package/dist/{init-BSyIyDs5.d.ts → init-DyE43paw.d.ts} +7 -2
  48. package/dist/{init-D9Bxx39e.d.cts → init-gyesUMwz.d.cts} +7 -2
  49. package/dist/instrumentation.cjs +8 -8
  50. package/dist/instrumentation.js +1 -1
  51. package/dist/messaging.cjs +7 -7
  52. package/dist/messaging.js +4 -4
  53. package/dist/semantic-helpers.cjs +8 -8
  54. package/dist/semantic-helpers.js +4 -4
  55. package/dist/webhook.cjs +5 -5
  56. package/dist/webhook.js +3 -3
  57. package/dist/workflow-distributed.cjs +5 -5
  58. package/dist/workflow-distributed.js +3 -3
  59. package/dist/workflow.cjs +8 -8
  60. package/dist/workflow.js +4 -4
  61. package/dist/yaml-config.d.cts +1 -1
  62. package/dist/yaml-config.d.ts +1 -1
  63. package/package.json +1 -1
  64. package/skills/build-audit-trails/SKILL.md +150 -5
  65. package/skills/build-audit-trails/references/audit-queries.md +73 -0
  66. package/skills/build-audit-trails/references/framework-wiring.md +187 -0
  67. package/skills/review-otel-patterns/SKILL.md +41 -0
  68. package/src/error-catalog.test.ts +128 -0
  69. package/src/error-catalog.ts +259 -0
  70. package/src/gen-ai-cost.test.ts +81 -0
  71. package/src/gen-ai-cost.ts +145 -0
  72. package/src/index.ts +29 -0
  73. package/src/init-auto-redactor.test.ts +53 -0
  74. package/src/init.ts +46 -7
  75. package/dist/chunk-KKIYPZOP.cjs.map +0 -1
  76. package/dist/chunk-ZKKJQS6R.js.map +0 -1
@@ -2,11 +2,11 @@
2
2
 
3
3
  var chunk4P6ZOARG_cjs = require('./chunk-4P6ZOARG.cjs');
4
4
  var chunkINJD3G4K_cjs = require('./chunk-INJD3G4K.cjs');
5
- var chunkEEQHQKPP_cjs = require('./chunk-EEQHQKPP.cjs');
5
+ var chunkU72TGONP_cjs = require('./chunk-U72TGONP.cjs');
6
6
  require('./chunk-2GIBANLB.cjs');
7
7
  require('./chunk-VQTCQKHQ.cjs');
8
- require('./chunk-UV64CWMA.cjs');
9
- require('./chunk-KKIYPZOP.cjs');
8
+ require('./chunk-DQSVSGK3.cjs');
9
+ require('./chunk-QJYWKAC5.cjs');
10
10
  require('./chunk-FEEVB2GV.cjs');
11
11
  require('./chunk-CEAQK2QY.cjs');
12
12
  require('./chunk-ZNMBW67B.cjs');
@@ -58,7 +58,7 @@ var WorkflowBaggage = chunkINJD3G4K_cjs.createSafeBaggageSchema(workflowBaggageF
58
58
  function traceDistributedWorkflow(config) {
59
59
  const spanName = `workflow.${config.name}`;
60
60
  return (fnFactory) => {
61
- return chunkEEQHQKPP_cjs.trace(
61
+ return chunkU72TGONP_cjs.trace(
62
62
  { name: spanName, spanKind: api.SpanKind.INTERNAL },
63
63
  (baseCtx) => {
64
64
  return async (...args) => {
@@ -157,7 +157,7 @@ function traceDistributedWorkflow(config) {
157
157
  function traceDistributedStep(config) {
158
158
  const spanName = `workflow.step.${config.name}`;
159
159
  return (fnFactory) => {
160
- return chunkEEQHQKPP_cjs.trace(
160
+ return chunkU72TGONP_cjs.trace(
161
161
  { name: spanName, spanKind: api.SpanKind.INTERNAL },
162
162
  (baseCtx) => {
163
163
  return async (...args) => {
@@ -1,10 +1,10 @@
1
1
  import { emitCorrelatedEvent } from './chunk-KIL5CUN6.js';
2
2
  import { createSafeBaggageSchema } from './chunk-4IFSYQVX.js';
3
- import { trace } from './chunk-FVA2YDEQ.js';
3
+ import { trace } from './chunk-U4D5IBSB.js';
4
4
  import './chunk-HLZ7H3VZ.js';
5
5
  import './chunk-SEO6NAQT.js';
6
- import './chunk-7EVW3Z37.js';
7
- import './chunk-ZKKJQS6R.js';
6
+ import './chunk-TGV2XF57.js';
7
+ import './chunk-32AXF4MA.js';
8
8
  import './chunk-643PQG3Y.js';
9
9
  import './chunk-A4E5AQFK.js';
10
10
  import './chunk-WGWSHJ2N.js';
package/dist/workflow.cjs CHANGED
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
2
 
3
- var chunk5RZ3NZ2M_cjs = require('./chunk-5RZ3NZ2M.cjs');
3
+ var chunk4RA6HIYF_cjs = require('./chunk-4RA6HIYF.cjs');
4
4
  require('./chunk-4P6ZOARG.cjs');
5
- require('./chunk-EEQHQKPP.cjs');
5
+ require('./chunk-U72TGONP.cjs');
6
6
  require('./chunk-2GIBANLB.cjs');
7
7
  require('./chunk-VQTCQKHQ.cjs');
8
- require('./chunk-UV64CWMA.cjs');
9
- require('./chunk-KKIYPZOP.cjs');
8
+ require('./chunk-DQSVSGK3.cjs');
9
+ require('./chunk-QJYWKAC5.cjs');
10
10
  require('./chunk-FEEVB2GV.cjs');
11
11
  require('./chunk-CEAQK2QY.cjs');
12
12
  require('./chunk-ZNMBW67B.cjs');
@@ -24,19 +24,19 @@ require('./chunk-YREV3LGG.cjs');
24
24
 
25
25
  Object.defineProperty(exports, "getCurrentWorkflowContext", {
26
26
  enumerable: true,
27
- get: function () { return chunk5RZ3NZ2M_cjs.getCurrentWorkflowContext; }
27
+ get: function () { return chunk4RA6HIYF_cjs.getCurrentWorkflowContext; }
28
28
  });
29
29
  Object.defineProperty(exports, "isInWorkflow", {
30
30
  enumerable: true,
31
- get: function () { return chunk5RZ3NZ2M_cjs.isInWorkflow; }
31
+ get: function () { return chunk4RA6HIYF_cjs.isInWorkflow; }
32
32
  });
33
33
  Object.defineProperty(exports, "traceStep", {
34
34
  enumerable: true,
35
- get: function () { return chunk5RZ3NZ2M_cjs.traceStep; }
35
+ get: function () { return chunk4RA6HIYF_cjs.traceStep; }
36
36
  });
37
37
  Object.defineProperty(exports, "traceWorkflow", {
38
38
  enumerable: true,
39
- get: function () { return chunk5RZ3NZ2M_cjs.traceWorkflow; }
39
+ get: function () { return chunk4RA6HIYF_cjs.traceWorkflow; }
40
40
  });
41
41
  //# sourceMappingURL=workflow.cjs.map
42
42
  //# sourceMappingURL=workflow.cjs.map
package/dist/workflow.js CHANGED
@@ -1,10 +1,10 @@
1
- export { getCurrentWorkflowContext, isInWorkflow, traceStep, traceWorkflow } from './chunk-IS2QJ44P.js';
1
+ export { getCurrentWorkflowContext, isInWorkflow, traceStep, traceWorkflow } from './chunk-FZROHTZZ.js';
2
2
  import './chunk-KIL5CUN6.js';
3
- import './chunk-FVA2YDEQ.js';
3
+ import './chunk-U4D5IBSB.js';
4
4
  import './chunk-HLZ7H3VZ.js';
5
5
  import './chunk-SEO6NAQT.js';
6
- import './chunk-7EVW3Z37.js';
7
- import './chunk-ZKKJQS6R.js';
6
+ import './chunk-TGV2XF57.js';
7
+ import './chunk-32AXF4MA.js';
8
8
  import './chunk-643PQG3Y.js';
9
9
  import './chunk-A4E5AQFK.js';
10
10
  import './chunk-WGWSHJ2N.js';
@@ -1,4 +1,4 @@
1
- import { A as AutotelConfig } from './init-D9Bxx39e.cjs';
1
+ import { A as AutotelConfig } from './init-gyesUMwz.cjs';
2
2
  import { SamplingPreset } from './sampling.cjs';
3
3
  import '@opentelemetry/sdk-trace-base';
4
4
  import '@opentelemetry/sdk-node';
@@ -1,4 +1,4 @@
1
- import { A as AutotelConfig } from './init-BSyIyDs5.js';
1
+ import { A as AutotelConfig } from './init-DyE43paw.js';
2
2
  import { SamplingPreset } from './sampling.js';
3
3
  import '@opentelemetry/sdk-trace-base';
4
4
  import '@opentelemetry/sdk-node';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autotel",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Write Once, Observe Anywhere",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1,11 +1,17 @@
1
1
  ---
2
2
  name: build-audit-trails
3
3
  description: >
4
- Design tamper-aware audit trails on top of OpenTelemetry spans using
5
- autotel. Covers what counts as auditable, the audit-only span discipline,
6
- signing and tamper-detection, denial logging, redaction, retention,
7
- separation of concerns from operational telemetry, and framework wiring
8
- (Next.js, Nuxt, Hono, Express, Cloudflare Workers).
4
+ Build or review tamper-aware audit trails on top of OpenTelemetry spans
5
+ using autotel and the autotel-audit package (`withAudit`,
6
+ `setAuditAttributes`, `forceKeepAuditEvent`). Covers what counts as
7
+ auditable, the audit-only span discipline, sampling bypass, HMAC and
8
+ hash-chain signing with tamper detection, denial logging, redaction,
9
+ retention, GDPR right-to-erasure via crypto-shredding, separation from
10
+ operational telemetry, testing audit spans, backend-agnostic audit queries,
11
+ a production-readiness checklist, and framework wiring (Next.js, Nuxt,
12
+ Nitro, NestJS, Express, Fastify, Hono, Cloudflare Workers, AWS Lambda,
13
+ standalone). Use it to design new audit trails or review existing ones for
14
+ GDPR, HIPAA, SOC 2, PCI-DSS, ISO 27001, SOX, and GxP compliance.
9
15
  license: MIT
10
16
  ---
11
17
 
@@ -21,6 +27,46 @@ autotel lets you express both with the same primitive — a span — but you sho
21
27
  - Adding "who did what" trails for admin actions, access reviews, payments
22
28
  - Recording authorization decisions (allow + deny)
23
29
  - Building immutable evidence for incident response
30
+ - Reviewing an existing audit trail for compliance gaps (see "Review an existing audit trail")
31
+
32
+ ## Quick reference
33
+
34
+ | Situation | Use |
35
+ | -------------------------------------------------------- | ---------------------------------------------------------------- |
36
+ | Wrap an action so success/failure is audited + kept | `withAudit(metadata, fn)` from `autotel-audit` |
37
+ | Tag the active span with `audit.*` attributes only | `setAuditAttributes(metadata)` |
38
+ | Make sure an audit span survives tail sampling | `forceKeepAuditEvent()` |
39
+ | Record an authorization denial | `audit({ …, outcome: 'deny', reason })` (Step 1 helper) |
40
+ | Full control / framework-agnostic span helper | hand-rolled `audit()` (Step 1) |
41
+ | Keep audit data off ops dashboards | `FilteringSpanProcessor` split (Step 3) |
42
+ | Prove a record was not altered | HMAC or hash-chain signature (Step 4) |
43
+ | Honor a GDPR erasure request on an append-only log | crypto-shredding (Step 6.5) |
44
+ | Assert the trail in tests | `createTraceCollector()` from `autotel/testing` (Step 8) |
45
+
46
+ ## The shortest path: the `autotel-audit` package
47
+
48
+ `autotel-audit` ships this discipline as helpers. Reach for these before hand-rolling:
49
+
50
+ ```typescript
51
+ import {
52
+ withAudit,
53
+ setAuditAttributes,
54
+ forceKeepAuditEvent,
55
+ } from 'autotel-audit';
56
+
57
+ // Wrap the action: sets audit.* attributes, force-keeps past tail sampling,
58
+ // and tags outcome 'success' / 'failure' automatically.
59
+ await withAudit(
60
+ { action: 'user.delete', resource: 'user', actorId: 'usr_42', category: 'admin' },
61
+ async () => db.user.delete({ where: { id } }),
62
+ );
63
+
64
+ // Or tag the current span yourself and opt out of sampling:
65
+ setAuditAttributes({ action: 'secret.read', resource: 'sec_abc', actorId: 'usr_42' });
66
+ forceKeepAuditEvent();
67
+ ```
68
+
69
+ `withAudit` marks the span with `autotel.audit = true` and sets the tail-sampling keep flags via `forceKeepAuditEvent`, so audit events are never dropped. The rest of this guide builds the same model from raw spans when you need full control over the schema, signing, and the pipeline split. If you use the package, filter on `autotel.audit` (not `audit`) in Step 3.
24
70
 
25
71
  ## The audit span discipline
26
72
 
@@ -122,6 +168,17 @@ Critical: route audit spans to a **different processor and backend** so:
122
168
  - They are not subject to development-mode debug exporters.
123
169
  - They go to a write-once / append-only store (S3 Object Lock, immutable bucket, dedicated audit DB).
124
170
 
171
+ How a span flows once you split the pipeline:
172
+
173
+ ```text
174
+ audit(payload)
175
+ └─ span.attributes['audit'] = true (or autotel.audit via the package)
176
+
177
+ ├─► FilteringSpanProcessor include: audit === true ─► auditExporter ─► append-only store
178
+
179
+ └─► FilteringSpanProcessor exclude: audit === true ─► opsExporter (PII-redacted dashboards)
180
+ ```
181
+
125
182
  ```typescript
126
183
  import {
127
184
  composeSpanProcessors,
@@ -221,8 +278,31 @@ Audit retention is set by regulation, not engineering taste. Common minimums:
221
278
 
222
279
  Express retention as a backend lifecycle policy (S3 Object Lock COMPLIANCE mode, BigQuery `--time_partitioning_expiration`), not application code.
223
280
 
281
+ ## Step 6.5: GDPR and the right to erasure
282
+
283
+ An append-only audit log and GDPR Article 17 ("right to be forgotten") look like a contradiction: you must keep the audit record, but the subject can demand their personal data be erased. Resolve it with **crypto-shredding** rather than deleting rows.
284
+
285
+ - Store the immutable facts in the clear: `audit.action`, `audit.outcome`, timestamps, `audit.resource.id`.
286
+ - Encrypt any personal data (names, emails, free-form context) with a **per-subject key**, and store only the ciphertext on the span (or a reference to it).
287
+ - Keep per-subject keys in a separate key store. To honor an erasure request, delete that subject's key. The audit record stays intact and tamper-evident; the personal fields become permanently unrecoverable.
288
+
289
+ ```typescript
290
+ // Never put raw PII on the span. Store a reference and shred the key on request.
291
+ setAuditAttributes({
292
+ action: 'profile.update',
293
+ resource: 'user',
294
+ actorId: 'usr_42',
295
+ 'subject.id': 'usr_42', // internal id, safe to retain
296
+ 'pii.ref': 'kms://subjects/usr_42/v3', // ciphertext lives off-span, keyed per subject
297
+ });
298
+ ```
299
+
300
+ This keeps the chain of custody and signatures valid (you never mutate a signed record) while making erasure a key-management operation. Confirm the approach with your DPO; some regulators accept crypto-shredding as erasure, others want documented justification.
301
+
224
302
  ## Step 7: Framework wiring
225
303
 
304
+ The handlers below are the common cases. For Nuxt, Nitro, NestJS, Fastify, AWS Lambda, and standalone jobs, see [references/framework-wiring.md](references/framework-wiring.md).
305
+
226
306
  ### Next.js
227
307
 
228
308
  ```typescript
@@ -286,6 +366,62 @@ export default defineWorkerFetch(
286
366
  );
287
367
  ```
288
368
 
369
+ ## Step 8: Test the trail
370
+
371
+ An audit trail you have not tested is a compliance risk. The two tests that matter most: the denial path is recorded, and the audit attributes are present and correct. Use `createTraceCollector()` from `autotel/testing` to assert on emitted spans.
372
+
373
+ ```typescript
374
+ import { describe, it, expect, beforeEach } from 'vitest';
375
+ import { createTraceCollector, type TraceCollector } from 'autotel/testing';
376
+
377
+ describe('audit trail', () => {
378
+ let collector: TraceCollector;
379
+ beforeEach(() => {
380
+ collector = createTraceCollector();
381
+ });
382
+
383
+ it('records a denial with reason and actor', async () => {
384
+ await expect(deleteUser(forbiddenReq)).rejects.toMatchObject({ status: 403 });
385
+
386
+ const [span] = collector.getSpansByAttributes({ 'audit.outcome': 'deny' });
387
+ expect(span).toBeDefined();
388
+ expect(span.attributes['audit.action']).toBe('user.delete');
389
+ expect(span.attributes['audit.reason']).toBeTruthy();
390
+ expect(span.attributes['enduser.id']).toBe('usr_42');
391
+ });
392
+
393
+ it('records the success path on allow', async () => {
394
+ await deleteUser(allowedReq);
395
+ expect(collector.getSpansByAttributes({ 'audit.outcome': 'allow' })).toHaveLength(1);
396
+ });
397
+ });
398
+ ```
399
+
400
+ Test the signature too: build an audit span, recompute the HMAC over the sorted attributes, and assert it matches; then mutate one attribute and assert it no longer does.
401
+
402
+ ## Review an existing audit trail
403
+
404
+ When the task is to audit existing code rather than build new, work these four passes in order and report findings as you go.
405
+
406
+ 1. **Pipeline.** Grep for where audit spans are produced (`audit(`, `withAudit`, `setAuditAttributes`). Confirm a `FilteringSpanProcessor` routes them to a separate, append-only backend, and that ops never receives them. Flag any audit span subject to sampling.
407
+ 2. **Coverage.** List every `audit(`/`withAudit` call site. Are denials logged, not just successes? Are mutating admin actions, access reviews, payments, and data exports all covered? Flag audit-on-every-read noise.
408
+ 3. **Integrity + redaction.** Is there a signature (HMAC or hash-chain) where storage is shared with the producer? Are raw payloads, secrets, or emails on the span instead of references and internal ids? Is the ops branch redacted?
409
+ 4. **Tests + retention.** Is there a denial-path test and a signature-verification test? Is retention enforced at the storage layer (lifecycle policy), not in code?
410
+
411
+ ## Production checklist
412
+
413
+ - [ ] Audit spans marked (`audit` / `autotel.audit`) and force-kept past sampling
414
+ - [ ] Separate processor and backend from ops; ops branch redacted
415
+ - [ ] Both allow and deny outcomes recorded
416
+ - [ ] No secrets, tokens, raw payloads, or emails on spans (references and internal ids only)
417
+ - [ ] Signature (HMAC or hash-chain) where storage is shared with the producer; keys rotated
418
+ - [ ] Retention set as a storage lifecycle policy per regulation
419
+ - [ ] GDPR erasure plan (crypto-shredding) for personal fields
420
+ - [ ] Multi-tenant isolation on the audit store
421
+ - [ ] Denial-path and signature-verification tests in CI
422
+
423
+ To query the trail (denials, per-actor history, tamper detection) across Honeycomb, Grafana Tempo, or Datadog, see [references/audit-queries.md](references/audit-queries.md).
424
+
289
425
  ## Anti-patterns
290
426
 
291
427
  | Anti-pattern | Fix |
@@ -300,3 +436,12 @@ export default defineWorkerFetch(
300
436
  | Audit on every read of harmless data | Audit _meaningful_ events; not every list call |
301
437
  | Audit row tied to a specific framework | The `audit()` function is framework-agnostic |
302
438
  | `enduser.id` = email | Use the internal id; emails go in a separate identity table |
439
+
440
+ ## Glossary
441
+
442
+ - **Audit span** — an OpenTelemetry span that records who did what to which resource, marked `audit` / `autotel.audit` so it is routed and retained separately from ops telemetry.
443
+ - **Force-keep** — opting a span out of tail sampling so it is always exported; `forceKeepAuditEvent()` sets the autotel tail-keep flags.
444
+ - **Denial** — an authorization decision that blocked an action; recorded with `audit.outcome = 'deny'` and a reason. Logging denials is as important as logging successes.
445
+ - **Hash-chain** — linking each audit record to the hash of the previous one so removing or reordering records is detectable.
446
+ - **Crypto-shredding** — satisfying an erasure request by destroying the per-subject encryption key rather than deleting the immutable record.
447
+ - **Append-only store** — a backend that rejects updates and deletes (S3 Object Lock, immutable bucket, WORM storage), the destination for audit spans.
@@ -0,0 +1,73 @@
1
+ # Querying the audit trail
2
+
3
+ Because audit events are OpenTelemetry spans, you query them with the same tools as the rest of your telemetry, no separate audit UI required. The attribute names below match the skill: `audit.action`, `audit.outcome`, `audit.resource.id`, `enduser.id`, `autotel.audit`, and `audit.signature.value`.
4
+
5
+ Run these against the **audit backend** (the append-only one), not your ops backend.
6
+
7
+ ## Find all denials in a window
8
+
9
+ A spike in denials is a security signal (credential stuffing, privilege probing).
10
+
11
+ - **Honeycomb** — filter `autotel.audit = true` AND `audit.outcome = deny`, group by `audit.action` and `enduser.id`, visualize `COUNT`.
12
+ - **Grafana Tempo (TraceQL)**:
13
+ ```
14
+ { span.autotel.audit = true && span.audit.outcome = "deny" }
15
+ ```
16
+ - **Datadog (spans search)**:
17
+ ```
18
+ @autotel.audit:true @audit.outcome:deny
19
+ ```
20
+
21
+ ## Trace one actor across every resource
22
+
23
+ Answer "everything user X did" for an access review or incident.
24
+
25
+ - **Honeycomb** — filter `enduser.id = "usr_42"`, group by `audit.action`, `audit.resource.type`, order by timestamp.
26
+ - **TraceQL**:
27
+ ```
28
+ { span.enduser.id = "usr_42" && span.autotel.audit = true }
29
+ ```
30
+ - **Datadog**:
31
+ ```
32
+ @enduser.id:usr_42 @autotel.audit:true
33
+ ```
34
+
35
+ ## Who touched one resource
36
+
37
+ Answer "everyone who accessed secret `sec_abc`".
38
+
39
+ - **TraceQL**:
40
+ ```
41
+ { span.audit.resource.id = "sec_abc" && span.autotel.audit = true }
42
+ ```
43
+ - **Datadog**:
44
+ ```
45
+ @audit.resource.id:sec_abc @autotel.audit:true
46
+ ```
47
+
48
+ ## Spot coverage gaps
49
+
50
+ Confirm sensitive actions are actually being recorded. Group audit spans by `audit.action` and compare against your list of auditable actions. An action that never appears is either never exercised or never audited; both deserve a look.
51
+
52
+ - **Honeycomb** — group by `audit.action`, `COUNT`, over 30 days.
53
+
54
+ ## Detect tampering or missing signatures
55
+
56
+ In a shared-storage setup every audit span should carry `audit.signature.value`. Spans without one, or whose recomputed HMAC does not match, are suspect.
57
+
58
+ - **Find unsigned audit spans (TraceQL)**:
59
+ ```
60
+ { span.autotel.audit = true && span.audit.signature.value = nil }
61
+ ```
62
+ - **Datadog**:
63
+ ```
64
+ @autotel.audit:true -@audit.signature.value:*
65
+ ```
66
+
67
+ Verification itself happens in code: export the spans, recompute the HMAC over the sorted attribute set (excluding `audit.signature.value`), and flag mismatches. A scheduled job that does this and writes its own audit span (`action: 'audit.integrity.check'`) gives you meta-auditing.
68
+
69
+ ## Tips
70
+
71
+ - Pin the time range explicitly; audit queries often span months, well past hot-storage windows.
72
+ - Export results to CSV/NDJSON for auditors who need evidence outside the observability tool.
73
+ - Keep these queries in version control next to the audit code so reviewers can reproduce them.
@@ -0,0 +1,187 @@
1
+ # Framework wiring for audit trails
2
+
3
+ Each handler below records an authorization decision (allow and deny) and emits one audit span. They assume the `audit()` / `withAuthz()` helpers from Step 1–2 of the skill, or the `withAudit` helper from `autotel-audit`. Keep the audit call inside the traced request so it inherits the trace context.
4
+
5
+ The rule across every framework is the same: wrap the authorization decision so both branches reach the audit pipeline, and never put raw payloads on the span.
6
+
7
+ ## Next.js (App Router)
8
+
9
+ ```typescript
10
+ // app/admin/users/[id]/route.ts
11
+ import { withAuthz } from '@/lib/audit';
12
+
13
+ export async function DELETE(req: Request, { params }: { params: { id: string } }) {
14
+ return withAuthz(
15
+ {
16
+ action: 'user.delete',
17
+ resource: { type: 'user', id: params.id },
18
+ actor: { id: req.headers.get('x-user-id')!, role: 'admin' },
19
+ },
20
+ async () => ({ allow: await canDelete(req, params.id) }),
21
+ async () => {
22
+ await db.user.delete({ where: { id: params.id } });
23
+ return Response.json({ ok: true });
24
+ },
25
+ );
26
+ }
27
+ ```
28
+
29
+ ## Nuxt / Nitro
30
+
31
+ ```typescript
32
+ // server/api/secrets/[id].delete.ts
33
+ import { withAuthz } from '~/server/utils/audit';
34
+
35
+ export default defineEventHandler((event) =>
36
+ withAuthz(
37
+ {
38
+ action: 'secret.delete',
39
+ resource: { type: 'secret', id: getRouterParam(event, 'id')! },
40
+ actor: { id: event.context.user.id, role: event.context.user.role },
41
+ },
42
+ async () => ({ allow: await canManageSecret(event) }),
43
+ async () => {
44
+ await secrets.delete(getRouterParam(event, 'id')!);
45
+ return { ok: true };
46
+ },
47
+ ),
48
+ );
49
+ ```
50
+
51
+ ## NestJS
52
+
53
+ Wrap the audited work inside the service or an interceptor. The decision lives next to the business logic:
54
+
55
+ ```typescript
56
+ @Injectable()
57
+ export class UsersService {
58
+ async remove(id: string, actor: Actor) {
59
+ return withAuthz(
60
+ { action: 'user.delete', resource: { type: 'user', id }, actor },
61
+ async () => ({ allow: actor.role === 'admin' }),
62
+ async () => this.repo.delete(id),
63
+ );
64
+ }
65
+ }
66
+ ```
67
+
68
+ ## Express
69
+
70
+ ```typescript
71
+ import { withAuthz } from './audit';
72
+
73
+ app.delete('/admin/users/:id', async (req, res, next) => {
74
+ try {
75
+ await withAuthz(
76
+ {
77
+ action: 'user.delete',
78
+ resource: { type: 'user', id: req.params.id },
79
+ actor: { id: req.user.id, role: req.user.role },
80
+ },
81
+ async () => ({ allow: req.user.role === 'admin' }),
82
+ async () => db.user.delete({ where: { id: req.params.id } }),
83
+ );
84
+ res.json({ ok: true });
85
+ } catch (err) {
86
+ next(err);
87
+ }
88
+ });
89
+ ```
90
+
91
+ ## Fastify
92
+
93
+ ```typescript
94
+ import { withAuthz } from './audit';
95
+
96
+ fastify.delete('/admin/users/:id', async (request, reply) => {
97
+ await withAuthz(
98
+ {
99
+ action: 'user.delete',
100
+ resource: { type: 'user', id: (request.params as { id: string }).id },
101
+ actor: { id: request.user.id, role: request.user.role },
102
+ },
103
+ async () => ({ allow: request.user.role === 'admin' }),
104
+ async () => db.user.delete({ where: { id: (request.params as { id: string }).id } }),
105
+ );
106
+ return { ok: true };
107
+ });
108
+ ```
109
+
110
+ ## Hono
111
+
112
+ ```typescript
113
+ import { withAuthz } from './audit';
114
+
115
+ app.post('/secrets/:id/read', (c) =>
116
+ withAuthz(
117
+ {
118
+ action: 'secret.read',
119
+ resource: { type: 'secret', id: c.req.param('id') },
120
+ actor: { id: c.var.user.id, role: c.var.user.role },
121
+ },
122
+ () => requireScope(c, 'secrets:read'),
123
+ async () => c.json({ value: await secrets.read(c.req.param('id')) }),
124
+ ),
125
+ );
126
+ ```
127
+
128
+ ## Cloudflare Workers
129
+
130
+ Audit from inside `defineWorkerFetch` so `ctx.waitUntil` exports the audit span before the response returns:
131
+
132
+ ```typescript
133
+ import { defineWorkerFetch } from 'autotel-cloudflare';
134
+ import { withAuthz } from './audit';
135
+
136
+ export default defineWorkerFetch(
137
+ { service: { name: 'admin-api' } },
138
+ async (request, env, ctx, log) =>
139
+ withAuthz(
140
+ {
141
+ action: 'data.export',
142
+ resource: { type: 'project', id: 'p_123' },
143
+ actor: { id: 'usr_42' },
144
+ },
145
+ async () => ({ allow: true }),
146
+ async () => Response.json({ ok: true }),
147
+ ),
148
+ );
149
+ ```
150
+
151
+ ## AWS Lambda
152
+
153
+ Wrap the traced handler; the audit span is flushed when the handler settles:
154
+
155
+ ```typescript
156
+ import { withAuthz } from './audit';
157
+
158
+ export const handler = async (event: APIGatewayProxyEventV2) =>
159
+ withAuthz(
160
+ {
161
+ action: 'invoice.void',
162
+ resource: { type: 'invoice', id: event.pathParameters!.id! },
163
+ actor: { id: event.requestContext.authorizer!.userId },
164
+ },
165
+ async () => ({ allow: await canVoid(event) }),
166
+ async () => {
167
+ await invoices.void(event.pathParameters!.id!);
168
+ return { statusCode: 200, body: JSON.stringify({ ok: true }) };
169
+ },
170
+ );
171
+ ```
172
+
173
+ ## Standalone scripts and cron jobs
174
+
175
+ Outside a request there is no ambient trace context. Wrap the job in `trace()` first so the audit span has a parent, and set the actor to the system principal:
176
+
177
+ ```typescript
178
+ import { trace } from 'autotel';
179
+ import { withAudit } from 'autotel-audit';
180
+
181
+ export const nightlyPurge = trace(async function nightlyPurge() {
182
+ await withAudit(
183
+ { action: 'data.purge', resource: 'expired-sessions', actorId: 'system:cron', category: 'maintenance' },
184
+ async () => sessions.purgeExpired(),
185
+ );
186
+ });
187
+ ```
@@ -7,6 +7,7 @@ description: >
7
7
  missing span attributes, manual exporter setup, broken context propagation, exposed PII, and ad-hoc
8
8
  error handling. Covers spans, metrics, logs, structured errors, the autotel processor pipeline
9
9
  (tail-sampling, attribute redaction, span-name normalisation, filtering, baggage),
10
+ built-in enrichers (user agent, geo, request size) and custom `defineEnricher`,
10
11
  `defineWorkerFetch` for Cloudflare async drains, multi-vendor OTLP backends (Honeycomb, Datadog,
11
12
  Grafana Cloud, Sentry, Axiom, HyperDX), `composeSpanProcessors` / `composeSubscribers` /
12
13
  `composePostProcessors` for pipelines, AI SDK observability with gen-ai semantic conventions, and
@@ -232,6 +233,46 @@ All options work with `init()`, framework adapters, and `wrapModule` / `defineWo
232
233
 
233
234
  ---
234
235
 
236
+ ## Built-in enrichers
237
+
238
+ Enrichers turn raw request data into standard, low-cardinality span attributes. Import the helpers from `autotel/enrichers` and spread their output onto the active span or request logger. Each returns `undefined` when there is nothing to add, so spreading is safe.
239
+
240
+ | Helper | Returns attributes | Source |
241
+ | -------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------- |
242
+ | `userAgent(headers)` | `user_agent.raw`, `user_agent.browser`, `user_agent.os`, `user_agent.device` | `user-agent` request header |
243
+ | `geo(headers)` | `geo.country`, `geo.region`, `geo.city`, `geo.latitude`, `geo.longitude` | Vercel / Cloudflare geo headers |
244
+ | `requestSize(reqHeaders, resHeaders?)` | `http.request.body.size`, `http.response.body.size` | `content-length` headers |
245
+
246
+ ```typescript
247
+ import { userAgent, geo, requestSize } from 'autotel/enrichers';
248
+
249
+ export const handler = trace((ctx) => async (request: Request) => {
250
+ ctx.setAttributes({
251
+ ...userAgent(request.headers),
252
+ ...geo(request.headers),
253
+ ...requestSize(request.headers),
254
+ });
255
+ // ... handle request
256
+ });
257
+ ```
258
+
259
+ For your own derived fields on a request's wide event, build a reusable enricher with `defineEnricher` (from `autotel`) instead of scattering ad-hoc field writes. `compute` returns an object that is merged into the named `field`; return `undefined` to skip. Keep the output low-cardinality (bucket or hash high-cardinality values):
260
+
261
+ ```typescript
262
+ import { defineEnricher } from 'autotel';
263
+
264
+ // Merge a derived, low-cardinality object into event.user on each request.
265
+ const enrichTier = defineEnricher<{ user?: { plan?: string } }, { tier: string }>({
266
+ name: 'user-tier',
267
+ field: 'user',
268
+ compute: ({ event }) => ({ tier: event.user?.plan ?? 'anonymous' }),
269
+ });
270
+ ```
271
+
272
+ **Review checks:** raw `User-Agent` strings stored verbatim (use `userAgent()` to parse), geo data hand-parsed per framework (use `geo()`), and high-cardinality values (full URLs, emails, ids) set directly as attributes instead of being bucketed or hashed.
273
+
274
+ ---
275
+
235
276
  ## Backends (multi-vendor OTLP)
236
277
 
237
278
  Switch backends with **no code changes** — autotel speaks OTLP HTTP/JSON and HTTP/protobuf out of the box.