autotel 2.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/LICENSE +21 -0
- package/README.md +1946 -0
- package/dist/chunk-2LNRY4QK.js +273 -0
- package/dist/chunk-2LNRY4QK.js.map +1 -0
- package/dist/chunk-3HENGDW2.js +587 -0
- package/dist/chunk-3HENGDW2.js.map +1 -0
- package/dist/chunk-4OAT42CA.cjs +73 -0
- package/dist/chunk-4OAT42CA.cjs.map +1 -0
- package/dist/chunk-5GWX5LFW.js +70 -0
- package/dist/chunk-5GWX5LFW.js.map +1 -0
- package/dist/chunk-5R2M36QB.js +195 -0
- package/dist/chunk-5R2M36QB.js.map +1 -0
- package/dist/chunk-5ZN622AO.js +73 -0
- package/dist/chunk-5ZN622AO.js.map +1 -0
- package/dist/chunk-77MSMAUQ.cjs +498 -0
- package/dist/chunk-77MSMAUQ.cjs.map +1 -0
- package/dist/chunk-ABPEQ6RK.cjs +596 -0
- package/dist/chunk-ABPEQ6RK.cjs.map +1 -0
- package/dist/chunk-BWYGJKRB.js +95 -0
- package/dist/chunk-BWYGJKRB.js.map +1 -0
- package/dist/chunk-BZHG5IZ4.js +73 -0
- package/dist/chunk-BZHG5IZ4.js.map +1 -0
- package/dist/chunk-G7VZBCD6.cjs +35 -0
- package/dist/chunk-G7VZBCD6.cjs.map +1 -0
- package/dist/chunk-GVLK7YUU.cjs +30 -0
- package/dist/chunk-GVLK7YUU.cjs.map +1 -0
- package/dist/chunk-HCCXC7XG.js +205 -0
- package/dist/chunk-HCCXC7XG.js.map +1 -0
- package/dist/chunk-HE6T6FIX.cjs +203 -0
- package/dist/chunk-HE6T6FIX.cjs.map +1 -0
- package/dist/chunk-KIXWPOCO.cjs +100 -0
- package/dist/chunk-KIXWPOCO.cjs.map +1 -0
- package/dist/chunk-KVGNW3FC.js +87 -0
- package/dist/chunk-KVGNW3FC.js.map +1 -0
- package/dist/chunk-LITNXTTT.js +3 -0
- package/dist/chunk-LITNXTTT.js.map +1 -0
- package/dist/chunk-M4ANN7RL.js +114 -0
- package/dist/chunk-M4ANN7RL.js.map +1 -0
- package/dist/chunk-NC52UBR2.cjs +32 -0
- package/dist/chunk-NC52UBR2.cjs.map +1 -0
- package/dist/chunk-NHCNRQD3.cjs +212 -0
- package/dist/chunk-NHCNRQD3.cjs.map +1 -0
- package/dist/chunk-NZ72VDNY.cjs +4 -0
- package/dist/chunk-NZ72VDNY.cjs.map +1 -0
- package/dist/chunk-P6JUDYNO.js +57 -0
- package/dist/chunk-P6JUDYNO.js.map +1 -0
- package/dist/chunk-RJYY7BWX.js +1349 -0
- package/dist/chunk-RJYY7BWX.js.map +1 -0
- package/dist/chunk-TRI4V5BF.cjs +126 -0
- package/dist/chunk-TRI4V5BF.cjs.map +1 -0
- package/dist/chunk-UL33I6IS.js +139 -0
- package/dist/chunk-UL33I6IS.js.map +1 -0
- package/dist/chunk-URRW6M2C.cjs +61 -0
- package/dist/chunk-URRW6M2C.cjs.map +1 -0
- package/dist/chunk-UY3UYPBZ.cjs +77 -0
- package/dist/chunk-UY3UYPBZ.cjs.map +1 -0
- package/dist/chunk-W3253FGB.cjs +277 -0
- package/dist/chunk-W3253FGB.cjs.map +1 -0
- package/dist/chunk-W7LHZVQF.js +26 -0
- package/dist/chunk-W7LHZVQF.js.map +1 -0
- package/dist/chunk-WBWNM6LB.cjs +1360 -0
- package/dist/chunk-WBWNM6LB.cjs.map +1 -0
- package/dist/chunk-WFJ7L2RV.js +494 -0
- package/dist/chunk-WFJ7L2RV.js.map +1 -0
- package/dist/chunk-X4RMFFMR.js +28 -0
- package/dist/chunk-X4RMFFMR.js.map +1 -0
- package/dist/chunk-Y4Y2S7BM.cjs +92 -0
- package/dist/chunk-Y4Y2S7BM.cjs.map +1 -0
- package/dist/chunk-YLPNXZFI.cjs +143 -0
- package/dist/chunk-YLPNXZFI.cjs.map +1 -0
- package/dist/chunk-YTXEZ4SD.cjs +77 -0
- package/dist/chunk-YTXEZ4SD.cjs.map +1 -0
- package/dist/chunk-Z6ZWNWWR.js +30 -0
- package/dist/chunk-Z6ZWNWWR.js.map +1 -0
- package/dist/config.cjs +26 -0
- package/dist/config.cjs.map +1 -0
- package/dist/config.d.cts +75 -0
- package/dist/config.d.ts +75 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -0
- package/dist/db.cjs +233 -0
- package/dist/db.cjs.map +1 -0
- package/dist/db.d.cts +123 -0
- package/dist/db.d.ts +123 -0
- package/dist/db.js +228 -0
- package/dist/db.js.map +1 -0
- package/dist/decorators.cjs +67 -0
- package/dist/decorators.cjs.map +1 -0
- package/dist/decorators.d.cts +91 -0
- package/dist/decorators.d.ts +91 -0
- package/dist/decorators.js +65 -0
- package/dist/decorators.js.map +1 -0
- package/dist/event-subscriber.cjs +6 -0
- package/dist/event-subscriber.cjs.map +1 -0
- package/dist/event-subscriber.d.cts +116 -0
- package/dist/event-subscriber.d.ts +116 -0
- package/dist/event-subscriber.js +3 -0
- package/dist/event-subscriber.js.map +1 -0
- package/dist/event-testing.cjs +21 -0
- package/dist/event-testing.cjs.map +1 -0
- package/dist/event-testing.d.cts +110 -0
- package/dist/event-testing.d.ts +110 -0
- package/dist/event-testing.js +4 -0
- package/dist/event-testing.js.map +1 -0
- package/dist/event.cjs +30 -0
- package/dist/event.cjs.map +1 -0
- package/dist/event.d.cts +282 -0
- package/dist/event.d.ts +282 -0
- package/dist/event.js +13 -0
- package/dist/event.js.map +1 -0
- package/dist/exporters.cjs +17 -0
- package/dist/exporters.cjs.map +1 -0
- package/dist/exporters.d.cts +1 -0
- package/dist/exporters.d.ts +1 -0
- package/dist/exporters.js +4 -0
- package/dist/exporters.js.map +1 -0
- package/dist/functional.cjs +46 -0
- package/dist/functional.cjs.map +1 -0
- package/dist/functional.d.cts +478 -0
- package/dist/functional.d.ts +478 -0
- package/dist/functional.js +13 -0
- package/dist/functional.js.map +1 -0
- package/dist/http.cjs +189 -0
- package/dist/http.cjs.map +1 -0
- package/dist/http.d.cts +169 -0
- package/dist/http.d.ts +169 -0
- package/dist/http.js +184 -0
- package/dist/http.js.map +1 -0
- package/dist/index.cjs +333 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +758 -0
- package/dist/index.d.ts +758 -0
- package/dist/index.js +143 -0
- package/dist/index.js.map +1 -0
- package/dist/instrumentation.cjs +182 -0
- package/dist/instrumentation.cjs.map +1 -0
- package/dist/instrumentation.d.cts +49 -0
- package/dist/instrumentation.d.ts +49 -0
- package/dist/instrumentation.js +179 -0
- package/dist/instrumentation.js.map +1 -0
- package/dist/logger.cjs +19 -0
- package/dist/logger.cjs.map +1 -0
- package/dist/logger.d.cts +146 -0
- package/dist/logger.d.ts +146 -0
- package/dist/logger.js +6 -0
- package/dist/logger.js.map +1 -0
- package/dist/metric-helpers.cjs +31 -0
- package/dist/metric-helpers.cjs.map +1 -0
- package/dist/metric-helpers.d.cts +13 -0
- package/dist/metric-helpers.d.ts +13 -0
- package/dist/metric-helpers.js +6 -0
- package/dist/metric-helpers.js.map +1 -0
- package/dist/metric-testing.cjs +21 -0
- package/dist/metric-testing.cjs.map +1 -0
- package/dist/metric-testing.d.cts +110 -0
- package/dist/metric-testing.d.ts +110 -0
- package/dist/metric-testing.js +4 -0
- package/dist/metric-testing.js.map +1 -0
- package/dist/metric.cjs +26 -0
- package/dist/metric.cjs.map +1 -0
- package/dist/metric.d.cts +240 -0
- package/dist/metric.d.ts +240 -0
- package/dist/metric.js +9 -0
- package/dist/metric.js.map +1 -0
- package/dist/processors.cjs +17 -0
- package/dist/processors.cjs.map +1 -0
- package/dist/processors.d.cts +1 -0
- package/dist/processors.d.ts +1 -0
- package/dist/processors.js +4 -0
- package/dist/processors.js.map +1 -0
- package/dist/sampling.cjs +40 -0
- package/dist/sampling.cjs.map +1 -0
- package/dist/sampling.d.cts +260 -0
- package/dist/sampling.d.ts +260 -0
- package/dist/sampling.js +7 -0
- package/dist/sampling.js.map +1 -0
- package/dist/semantic-helpers.cjs +35 -0
- package/dist/semantic-helpers.cjs.map +1 -0
- package/dist/semantic-helpers.d.cts +442 -0
- package/dist/semantic-helpers.d.ts +442 -0
- package/dist/semantic-helpers.js +14 -0
- package/dist/semantic-helpers.js.map +1 -0
- package/dist/tail-sampling-processor.cjs +13 -0
- package/dist/tail-sampling-processor.cjs.map +1 -0
- package/dist/tail-sampling-processor.d.cts +27 -0
- package/dist/tail-sampling-processor.d.ts +27 -0
- package/dist/tail-sampling-processor.js +4 -0
- package/dist/tail-sampling-processor.js.map +1 -0
- package/dist/testing.cjs +286 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +291 -0
- package/dist/testing.d.ts +291 -0
- package/dist/testing.js +263 -0
- package/dist/testing.js.map +1 -0
- package/dist/trace-context-DRZdUvVY.d.cts +181 -0
- package/dist/trace-context-DRZdUvVY.d.ts +181 -0
- package/dist/trace-helpers.cjs +54 -0
- package/dist/trace-helpers.cjs.map +1 -0
- package/dist/trace-helpers.d.cts +524 -0
- package/dist/trace-helpers.d.ts +524 -0
- package/dist/trace-helpers.js +5 -0
- package/dist/trace-helpers.js.map +1 -0
- package/dist/tracer-provider.cjs +21 -0
- package/dist/tracer-provider.cjs.map +1 -0
- package/dist/tracer-provider.d.cts +169 -0
- package/dist/tracer-provider.d.ts +169 -0
- package/dist/tracer-provider.js +4 -0
- package/dist/tracer-provider.js.map +1 -0
- package/package.json +280 -0
- package/src/baggage-span-processor.test.ts +202 -0
- package/src/baggage-span-processor.ts +98 -0
- package/src/circuit-breaker.test.ts +341 -0
- package/src/circuit-breaker.ts +184 -0
- package/src/config.test.ts +94 -0
- package/src/config.ts +169 -0
- package/src/db.test.ts +252 -0
- package/src/db.ts +447 -0
- package/src/decorators.test.ts +203 -0
- package/src/decorators.ts +188 -0
- package/src/env-config.test.ts +246 -0
- package/src/env-config.ts +158 -0
- package/src/event-queue.test.ts +222 -0
- package/src/event-queue.ts +203 -0
- package/src/event-subscriber.ts +136 -0
- package/src/event-testing.ts +197 -0
- package/src/event.test.ts +718 -0
- package/src/event.ts +556 -0
- package/src/exporters.ts +96 -0
- package/src/functional.test.ts +1059 -0
- package/src/functional.ts +2295 -0
- package/src/http.test.ts +487 -0
- package/src/http.ts +424 -0
- package/src/index.ts +158 -0
- package/src/init.customization.test.ts +210 -0
- package/src/init.integrations.test.ts +366 -0
- package/src/init.openllmetry.test.ts +282 -0
- package/src/init.protocol.test.ts +215 -0
- package/src/init.ts +1426 -0
- package/src/instrumentation.test.ts +108 -0
- package/src/instrumentation.ts +308 -0
- package/src/logger.test.ts +117 -0
- package/src/logger.ts +246 -0
- package/src/metric-helpers.ts +47 -0
- package/src/metric-testing.ts +197 -0
- package/src/metric.ts +434 -0
- package/src/metrics.test.ts +205 -0
- package/src/operation-context.ts +93 -0
- package/src/processors.ts +106 -0
- package/src/rate-limiter.test.ts +199 -0
- package/src/rate-limiter.ts +98 -0
- package/src/sampling.test.ts +513 -0
- package/src/sampling.ts +428 -0
- package/src/semantic-helpers.test.ts +311 -0
- package/src/semantic-helpers.ts +584 -0
- package/src/shutdown.test.ts +311 -0
- package/src/shutdown.ts +222 -0
- package/src/stub.integration.test.ts +361 -0
- package/src/tail-sampling-processor.test.ts +226 -0
- package/src/tail-sampling-processor.ts +51 -0
- package/src/testing.ts +670 -0
- package/src/trace-context.ts +470 -0
- package/src/trace-helpers.new.test.ts +278 -0
- package/src/trace-helpers.test.ts +242 -0
- package/src/trace-helpers.ts +690 -0
- package/src/tracer-provider.test.ts +183 -0
- package/src/tracer-provider.ts +266 -0
- package/src/track.test.ts +153 -0
- package/src/track.ts +120 -0
- package/src/validation.test.ts +306 -0
- package/src/validation.ts +239 -0
- package/src/variable-name-inference.test.ts +178 -0
- package/src/variable-name-inference.ts +242 -0
package/src/track.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global track() function for business events
|
|
3
|
+
*
|
|
4
|
+
* Simple, no instantiation needed, auto-attaches trace context
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { trace } from '@opentelemetry/api';
|
|
8
|
+
import { EventQueue } from './event-queue';
|
|
9
|
+
import {
|
|
10
|
+
getConfig,
|
|
11
|
+
warnIfNotInitialized,
|
|
12
|
+
isInitialized,
|
|
13
|
+
getValidationConfig,
|
|
14
|
+
} from './init';
|
|
15
|
+
import { validateEvent } from './validation';
|
|
16
|
+
|
|
17
|
+
// Global events queue (initialized on first track call)
|
|
18
|
+
let eventsQueue: EventQueue | null = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initialize events queue lazily
|
|
22
|
+
*/
|
|
23
|
+
function getOrCreateQueue(): EventQueue | null {
|
|
24
|
+
if (!isInitialized()) {
|
|
25
|
+
warnIfNotInitialized('track()');
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!eventsQueue) {
|
|
30
|
+
const config = getConfig();
|
|
31
|
+
if (!config?.subscribers || config.subscribers.length === 0) {
|
|
32
|
+
// No subscribers configured - no-op
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
eventsQueue = new EventQueue(config.subscribers);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return eventsQueue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Track a business events event
|
|
44
|
+
*
|
|
45
|
+
* Features:
|
|
46
|
+
* - Auto-attaches traceId and spanId if in active span
|
|
47
|
+
* - Batched sending with retry
|
|
48
|
+
* - Type-safe with optional generic
|
|
49
|
+
* - No-op if init() not called or no subscribers configured
|
|
50
|
+
*
|
|
51
|
+
* @example Basic usage
|
|
52
|
+
* ```typescript
|
|
53
|
+
* track('user.signup', { userId: '123', plan: 'pro' })
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @example With type safety
|
|
57
|
+
* ```typescript
|
|
58
|
+
* interface EventDatas {
|
|
59
|
+
* 'user.signup': { userId: string; plan: string }
|
|
60
|
+
* 'plan.upgraded': { userId: string; revenue: number }
|
|
61
|
+
* }
|
|
62
|
+
*
|
|
63
|
+
* track<EventDatas>('user.signup', { userId: '123', plan: 'pro' })
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @example Trace correlation (automatic)
|
|
67
|
+
* ```typescript
|
|
68
|
+
* @Instrumented()
|
|
69
|
+
* class UserService {
|
|
70
|
+
* async createUser(data: CreateUserData) {
|
|
71
|
+
* // This track call automatically includes traceId + spanId
|
|
72
|
+
* track('user.signup', { userId: data.id })
|
|
73
|
+
* }
|
|
74
|
+
* }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
export function track<Events extends Record<string, any> = Record<string, any>>(
|
|
79
|
+
event: keyof Events & string,
|
|
80
|
+
data?: Events[typeof event],
|
|
81
|
+
): void {
|
|
82
|
+
const queue = getOrCreateQueue();
|
|
83
|
+
if (!queue) return; // No-op if not initialized or no subscribers
|
|
84
|
+
|
|
85
|
+
// Validate and sanitize input (with custom config if provided)
|
|
86
|
+
const validationConfig = getValidationConfig();
|
|
87
|
+
const validated = validateEvent(event, data, validationConfig || undefined);
|
|
88
|
+
|
|
89
|
+
// Auto-attach trace context if available (free win!)
|
|
90
|
+
const span = trace.getActiveSpan();
|
|
91
|
+
const enrichedData = span
|
|
92
|
+
? {
|
|
93
|
+
...validated.attributes,
|
|
94
|
+
traceId: span.spanContext().traceId,
|
|
95
|
+
spanId: span.spanContext().spanId,
|
|
96
|
+
}
|
|
97
|
+
: validated.attributes;
|
|
98
|
+
|
|
99
|
+
queue.enqueue({
|
|
100
|
+
name: validated.eventName,
|
|
101
|
+
attributes: enrichedData,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get events queue (for flush/shutdown)
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
export function getEventQueue(): EventQueue | null {
|
|
111
|
+
return eventsQueue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reset events queue (for shutdown/cleanup)
|
|
116
|
+
* @internal
|
|
117
|
+
*/
|
|
118
|
+
export function resetEventQueue(): void {
|
|
119
|
+
eventsQueue = null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for input validation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
validateEventName,
|
|
8
|
+
validateAttributes,
|
|
9
|
+
validateEvent,
|
|
10
|
+
ValidationError,
|
|
11
|
+
getDefaultValidationConfig,
|
|
12
|
+
} from './validation';
|
|
13
|
+
|
|
14
|
+
describe('validateEventName()', () => {
|
|
15
|
+
it('should accept valid event names', () => {
|
|
16
|
+
expect(validateEventName('user.signup')).toBe('user.signup');
|
|
17
|
+
expect(validateEventName('order_completed')).toBe('order_completed');
|
|
18
|
+
expect(validateEventName('feature-used')).toBe('feature-used');
|
|
19
|
+
expect(validateEventName('app123.event456')).toBe('app123.event456');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should trim whitespace', () => {
|
|
23
|
+
expect(validateEventName(' user.signup ')).toBe('user.signup');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should reject empty event names', () => {
|
|
27
|
+
expect(() => validateEventName('')).toThrow(ValidationError);
|
|
28
|
+
expect(() => validateEventName(' ')).toThrow(ValidationError);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should reject non-string event names', () => {
|
|
32
|
+
expect(() => validateEventName(123 as any)).toThrow(ValidationError);
|
|
33
|
+
|
|
34
|
+
expect(() => validateEventName(null as any)).toThrow(ValidationError);
|
|
35
|
+
|
|
36
|
+
expect(() => validateEventName(undefined as any)).toThrow(ValidationError);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should reject event names that are too long', () => {
|
|
40
|
+
const longName = 'a'.repeat(101);
|
|
41
|
+
expect(() => validateEventName(longName)).toThrow(ValidationError);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should reject event names with invalid characters', () => {
|
|
45
|
+
expect(() => validateEventName('user signup')).toThrow(ValidationError);
|
|
46
|
+
expect(() => validateEventName('user@signup')).toThrow(ValidationError);
|
|
47
|
+
expect(() => validateEventName('user/signup')).toThrow(ValidationError);
|
|
48
|
+
expect(() => validateEventName(String.raw`user\signup`)).toThrow(
|
|
49
|
+
ValidationError,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('validateAttributes()', () => {
|
|
55
|
+
it('should accept valid attributes', () => {
|
|
56
|
+
const attrs = {
|
|
57
|
+
userId: '123',
|
|
58
|
+
plan: 'pro',
|
|
59
|
+
count: 5,
|
|
60
|
+
active: true,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(validateAttributes(attrs)).toEqual(attrs);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should handle undefined attributes', () => {
|
|
67
|
+
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
68
|
+
expect(validateAttributes(undefined)).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should reject non-object attributes', () => {
|
|
72
|
+
expect(() => validateAttributes('string' as any)).toThrow(ValidationError);
|
|
73
|
+
|
|
74
|
+
expect(() => validateAttributes(123 as any)).toThrow(ValidationError);
|
|
75
|
+
|
|
76
|
+
expect(() => validateAttributes([] as any)).toThrow(ValidationError);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should reject too many attributes', () => {
|
|
80
|
+
const config = getDefaultValidationConfig();
|
|
81
|
+
const attrs: Record<string, unknown> = {};
|
|
82
|
+
for (let i = 0; i < config.maxAttributeCount + 1; i++) {
|
|
83
|
+
attrs[`key${i}`] = 'value';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
expect(() =>
|
|
87
|
+
validateAttributes(attrs as Record<string, string | number | boolean>),
|
|
88
|
+
).toThrow(ValidationError);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should reject attribute keys that are too long', () => {
|
|
92
|
+
const longKey = 'a'.repeat(101);
|
|
93
|
+
const attrs = { [longKey]: 'value' };
|
|
94
|
+
|
|
95
|
+
expect(() => validateAttributes(attrs)).toThrow(ValidationError);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should truncate long string values', () => {
|
|
99
|
+
const longValue = 'a'.repeat(1500);
|
|
100
|
+
const attrs = { field: longValue };
|
|
101
|
+
|
|
102
|
+
const result = validateAttributes(attrs);
|
|
103
|
+
expect(result?.field).toBe('a'.repeat(1000) + '...');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should redact sensitive fields', () => {
|
|
107
|
+
const attrs = {
|
|
108
|
+
email: 'user@example.com',
|
|
109
|
+
password: 'secret123',
|
|
110
|
+
apiKey: 'abc123',
|
|
111
|
+
normalField: 'value',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const result = validateAttributes(attrs);
|
|
115
|
+
expect(result?.email).toBe('user@example.com');
|
|
116
|
+
expect(result?.password).toBe('[REDACTED]');
|
|
117
|
+
expect(result?.apiKey).toBe('[REDACTED]');
|
|
118
|
+
expect(result?.normalField).toBe('value');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should handle nested objects within depth limit', () => {
|
|
122
|
+
const attrs = {
|
|
123
|
+
user: {
|
|
124
|
+
profile: {
|
|
125
|
+
name: 'John',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const result = validateAttributes(
|
|
131
|
+
attrs as unknown as Record<string, string | number | boolean>,
|
|
132
|
+
);
|
|
133
|
+
expect(result).toEqual(attrs);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should truncate deeply nested objects', () => {
|
|
137
|
+
const attrs = {
|
|
138
|
+
level1: {
|
|
139
|
+
level2: {
|
|
140
|
+
level3: {
|
|
141
|
+
level4: {
|
|
142
|
+
tooDeep: 'value',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const result = validateAttributes(attrs as any) as any;
|
|
150
|
+
expect(result.level1.level2.level3.level4).toBe('[MAX_DEPTH_EXCEEDED]');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should handle arrays', () => {
|
|
154
|
+
const attrs = {
|
|
155
|
+
tags: ['tag1', 'tag2', 'tag3'],
|
|
156
|
+
scores: [1, 2, 3],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const result = validateAttributes(attrs as any);
|
|
160
|
+
expect(result).toEqual(attrs);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should handle circular references', () => {
|
|
164
|
+
const circular: any = { name: 'test' };
|
|
165
|
+
circular.self = circular;
|
|
166
|
+
|
|
167
|
+
const attrs = { data: circular };
|
|
168
|
+
|
|
169
|
+
const result = validateAttributes(attrs) as any;
|
|
170
|
+
expect(result.data).toBe('[CIRCULAR]');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle null and undefined values', () => {
|
|
174
|
+
const attrs = {
|
|
175
|
+
nullable: null,
|
|
176
|
+
undefinedField: undefined,
|
|
177
|
+
normalField: 'value',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const result = validateAttributes(attrs as any);
|
|
181
|
+
expect(result?.nullable).toBeNull();
|
|
182
|
+
expect(result?.undefinedField).toBeUndefined();
|
|
183
|
+
expect(result?.normalField).toBe('value');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should handle unsupported types', () => {
|
|
187
|
+
const attrs = {
|
|
188
|
+
func: () => {},
|
|
189
|
+
symbol: Symbol('test'),
|
|
190
|
+
normalField: 'value',
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const result = validateAttributes(attrs as any);
|
|
194
|
+
expect(result?.func).toBe('[function]');
|
|
195
|
+
expect(result?.symbol).toBe('[symbol]');
|
|
196
|
+
expect(result?.normalField).toBe('value');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('validateEvent()', () => {
|
|
201
|
+
it('should validate both event name and attributes', () => {
|
|
202
|
+
const result = validateEvent('user.signup', {
|
|
203
|
+
userId: '123',
|
|
204
|
+
password: 'secret',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result.eventName).toBe('user.signup');
|
|
208
|
+
expect(result.attributes?.userId).toBe('123');
|
|
209
|
+
expect(result.attributes?.password).toBe('[REDACTED]');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should handle events without attributes', () => {
|
|
213
|
+
const result = validateEvent('page.viewed');
|
|
214
|
+
|
|
215
|
+
expect(result.eventName).toBe('page.viewed');
|
|
216
|
+
expect(result.attributes).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should allow custom validation config', () => {
|
|
220
|
+
const result = validateEvent(
|
|
221
|
+
'test.event',
|
|
222
|
+
{ field: 'value' },
|
|
223
|
+
{ maxEventNameLength: 50 },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(result.eventName).toBe('test.event');
|
|
227
|
+
expect(result.attributes?.field).toBe('value');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should throw on invalid event name', () => {
|
|
231
|
+
expect(() => validateEvent('', { userId: '123' })).toThrow(ValidationError);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should throw on invalid attributes', () => {
|
|
235
|
+
expect(() => validateEvent('user.signup', 'invalid' as any)).toThrow(
|
|
236
|
+
ValidationError,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('Sensitive data patterns', () => {
|
|
242
|
+
it('should redact password fields', () => {
|
|
243
|
+
const attrs = {
|
|
244
|
+
password: 'secret',
|
|
245
|
+
userPassword: 'secret',
|
|
246
|
+
PASSWORD: 'secret',
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const result = validateAttributes(attrs);
|
|
250
|
+
expect(result?.password).toBe('[REDACTED]');
|
|
251
|
+
expect(result?.userPassword).toBe('[REDACTED]');
|
|
252
|
+
expect(result?.PASSWORD).toBe('[REDACTED]');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should redact token fields', () => {
|
|
256
|
+
const attrs = {
|
|
257
|
+
token: 'abc123',
|
|
258
|
+
accessToken: 'abc123',
|
|
259
|
+
auth_token: 'abc123',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const result = validateAttributes(attrs);
|
|
263
|
+
expect(result?.token).toBe('[REDACTED]');
|
|
264
|
+
expect(result?.accessToken).toBe('[REDACTED]');
|
|
265
|
+
expect(result?.auth_token).toBe('[REDACTED]');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should redact API key fields', () => {
|
|
269
|
+
const attrs = {
|
|
270
|
+
apiKey: 'abc123',
|
|
271
|
+
api_key: 'abc123',
|
|
272
|
+
API_KEY: 'abc123',
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const result = validateAttributes(attrs);
|
|
276
|
+
expect(result?.apiKey).toBe('[REDACTED]');
|
|
277
|
+
expect(result?.api_key).toBe('[REDACTED]');
|
|
278
|
+
expect(result?.API_KEY).toBe('[REDACTED]');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should redact auth fields', () => {
|
|
282
|
+
const attrs = {
|
|
283
|
+
auth: 'abc123',
|
|
284
|
+
authorization: 'Bearer token',
|
|
285
|
+
authenticated: true, // Contains "auth" but should still be redacted
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const result = validateAttributes(attrs);
|
|
289
|
+
expect(result?.auth).toBe('[REDACTED]');
|
|
290
|
+
expect(result?.authorization).toBe('[REDACTED]');
|
|
291
|
+
expect(result?.authenticated).toBe('[REDACTED]');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should not redact non-sensitive fields with similar names', () => {
|
|
295
|
+
const attrs = {
|
|
296
|
+
email: 'user@example.com',
|
|
297
|
+
username: 'john',
|
|
298
|
+
userId: '123',
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const result = validateAttributes(attrs);
|
|
302
|
+
expect(result?.email).toBe('user@example.com');
|
|
303
|
+
expect(result?.username).toBe('john');
|
|
304
|
+
expect(result?.userId).toBe('123');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation for events events and attributes
|
|
3
|
+
*
|
|
4
|
+
* Prevents:
|
|
5
|
+
* - Invalid event names
|
|
6
|
+
* - Oversized payloads
|
|
7
|
+
* - Circular references
|
|
8
|
+
* - Sensitive data leaks
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { EventAttributes } from './event-subscriber';
|
|
12
|
+
|
|
13
|
+
export interface ValidationConfig {
|
|
14
|
+
/** Max event name length (default: 100) */
|
|
15
|
+
maxEventNameLength: number;
|
|
16
|
+
/** Max attribute key length (default: 100) */
|
|
17
|
+
maxAttributeKeyLength: number;
|
|
18
|
+
/** Max attribute value length for strings (default: 1000) */
|
|
19
|
+
maxAttributeValueLength: number;
|
|
20
|
+
/** Max total attributes per event (default: 50) */
|
|
21
|
+
maxAttributeCount: number;
|
|
22
|
+
/** Max nesting depth for objects (default: 3) */
|
|
23
|
+
maxNestingDepth: number;
|
|
24
|
+
/** Sensitive field patterns to redact */
|
|
25
|
+
sensitivePatterns: RegExp[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CONFIG: ValidationConfig = {
|
|
29
|
+
maxEventNameLength: 100,
|
|
30
|
+
maxAttributeKeyLength: 100,
|
|
31
|
+
maxAttributeValueLength: 1000,
|
|
32
|
+
maxAttributeCount: 50,
|
|
33
|
+
maxNestingDepth: 3,
|
|
34
|
+
sensitivePatterns: [
|
|
35
|
+
/password/i,
|
|
36
|
+
/secret/i,
|
|
37
|
+
/token/i,
|
|
38
|
+
/api[_-]?key/i,
|
|
39
|
+
/access[_-]?key/i,
|
|
40
|
+
/private[_-]?key/i,
|
|
41
|
+
/auth/i,
|
|
42
|
+
/credential/i,
|
|
43
|
+
/ssn/i,
|
|
44
|
+
/credit[_-]?card/i,
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class ValidationError extends Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'ValidationError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate and sanitize event name
|
|
57
|
+
* Throws ValidationError if invalid
|
|
58
|
+
*/
|
|
59
|
+
export function validateEventName(
|
|
60
|
+
eventName: string,
|
|
61
|
+
config: ValidationConfig = DEFAULT_CONFIG,
|
|
62
|
+
): string {
|
|
63
|
+
// Check type
|
|
64
|
+
if (typeof eventName !== 'string') {
|
|
65
|
+
throw new ValidationError(
|
|
66
|
+
`Event name must be a string, got ${typeof eventName}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check non-empty
|
|
71
|
+
const trimmed = eventName.trim();
|
|
72
|
+
if (trimmed.length === 0) {
|
|
73
|
+
throw new ValidationError('Event name cannot be empty');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check length
|
|
77
|
+
if (trimmed.length > config.maxEventNameLength) {
|
|
78
|
+
throw new ValidationError(
|
|
79
|
+
`Event name too long (${trimmed.length} chars). ` +
|
|
80
|
+
`Max: ${config.maxEventNameLength}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check valid characters (alphanumeric, dots, underscores, hyphens)
|
|
85
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
|
|
86
|
+
throw new ValidationError(
|
|
87
|
+
`Event name contains invalid characters: "${trimmed}". ` +
|
|
88
|
+
'Use only letters, numbers, dots, underscores, and hyphens.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return trimmed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate and sanitize attributes
|
|
97
|
+
* Returns sanitized attributes (sensitive data redacted)
|
|
98
|
+
*/
|
|
99
|
+
export function validateAttributes(
|
|
100
|
+
attributes: EventAttributes | undefined,
|
|
101
|
+
config: ValidationConfig = DEFAULT_CONFIG,
|
|
102
|
+
): EventAttributes | undefined {
|
|
103
|
+
if (attributes === undefined || attributes === null) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check type
|
|
108
|
+
if (typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
109
|
+
throw new ValidationError('Attributes must be an object');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Count attributes
|
|
113
|
+
const keys = Object.keys(attributes);
|
|
114
|
+
if (keys.length > config.maxAttributeCount) {
|
|
115
|
+
throw new ValidationError(
|
|
116
|
+
`Too many attributes (${keys.length}). ` +
|
|
117
|
+
`Max: ${config.maxAttributeCount}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Validate and sanitize each attribute
|
|
122
|
+
const sanitized: EventAttributes = {};
|
|
123
|
+
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
// Validate key
|
|
126
|
+
if (key.length > config.maxAttributeKeyLength) {
|
|
127
|
+
throw new ValidationError(
|
|
128
|
+
`Attribute key too long: "${key.slice(0, 20)}..." ` +
|
|
129
|
+
`(${key.length} chars). Max: ${config.maxAttributeKeyLength}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for sensitive field
|
|
134
|
+
const isSensitive = config.sensitivePatterns.some((pattern) =>
|
|
135
|
+
pattern.test(key),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (isSensitive) {
|
|
139
|
+
// Redact sensitive data
|
|
140
|
+
sanitized[key] = '[REDACTED]';
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sanitize value
|
|
145
|
+
const value = attributes[key];
|
|
146
|
+
sanitized[key] = sanitizeValue(value, config, 1) as
|
|
147
|
+
| string
|
|
148
|
+
| number
|
|
149
|
+
| boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return sanitized;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Sanitize attribute value (recursive)
|
|
157
|
+
*/
|
|
158
|
+
function sanitizeValue(
|
|
159
|
+
value: unknown,
|
|
160
|
+
config: ValidationConfig,
|
|
161
|
+
depth: number,
|
|
162
|
+
): unknown {
|
|
163
|
+
// Check nesting depth
|
|
164
|
+
if (depth > config.maxNestingDepth) {
|
|
165
|
+
return '[MAX_DEPTH_EXCEEDED]';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Handle null/undefined
|
|
169
|
+
if (value === null || value === undefined) {
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle primitives
|
|
174
|
+
if (typeof value === 'string') {
|
|
175
|
+
if (value.length > config.maxAttributeValueLength) {
|
|
176
|
+
return value.slice(0, config.maxAttributeValueLength) + '...';
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Handle arrays
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
return value.map((item) => sanitizeValue(item, config, depth + 1));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Handle objects
|
|
191
|
+
if (typeof value === 'object') {
|
|
192
|
+
try {
|
|
193
|
+
// Check for circular references
|
|
194
|
+
JSON.stringify(value);
|
|
195
|
+
|
|
196
|
+
const sanitized: Record<string, unknown> = {};
|
|
197
|
+
for (const key in value) {
|
|
198
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
199
|
+
sanitized[key] = sanitizeValue(
|
|
200
|
+
(value as Record<string, unknown>)[key],
|
|
201
|
+
config,
|
|
202
|
+
depth + 1,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return sanitized;
|
|
207
|
+
} catch {
|
|
208
|
+
// Circular reference detected
|
|
209
|
+
return '[CIRCULAR]';
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Unsupported type (function, symbol, etc.)
|
|
214
|
+
return `[${typeof value}]`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validate and sanitize an events event
|
|
219
|
+
* Returns { eventName, attributes } with sanitized values
|
|
220
|
+
*/
|
|
221
|
+
export function validateEvent(
|
|
222
|
+
eventName: string,
|
|
223
|
+
attributes?: EventAttributes,
|
|
224
|
+
config?: Partial<ValidationConfig>,
|
|
225
|
+
): { eventName: string; attributes?: EventAttributes } {
|
|
226
|
+
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
eventName: validateEventName(eventName, fullConfig),
|
|
230
|
+
attributes: validateAttributes(attributes, fullConfig),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get default validation config (for testing/customization)
|
|
236
|
+
*/
|
|
237
|
+
export function getDefaultValidationConfig(): ValidationConfig {
|
|
238
|
+
return { ...DEFAULT_CONFIG };
|
|
239
|
+
}
|