@mostly-good-metrics/javascript 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 +319 -0
- package/dist/cjs/client.js +416 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/index.js +65 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/logger.js +64 -0
- package/dist/cjs/logger.js.map +1 -0
- package/dist/cjs/network.js +192 -0
- package/dist/cjs/network.js.map +1 -0
- package/dist/cjs/storage.js +227 -0
- package/dist/cjs/storage.js.map +1 -0
- package/dist/cjs/types.js +70 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/utils.js +249 -0
- package/dist/cjs/utils.js.map +1 -0
- package/dist/esm/client.js +412 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/index.js +40 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/logger.js +55 -0
- package/dist/esm/logger.js.map +1 -0
- package/dist/esm/network.js +187 -0
- package/dist/esm/network.js.map +1 -0
- package/dist/esm/storage.js +221 -0
- package/dist/esm/storage.js.map +1 -0
- package/dist/esm/types.js +66 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/utils.js +236 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/types/client.d.ts +126 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/index.d.ts +34 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/logger.d.ts +37 -0
- package/dist/types/logger.d.ts.map +1 -0
- package/dist/types/network.d.ts +28 -0
- package/dist/types/network.d.ts.map +1 -0
- package/dist/types/storage.d.ts +76 -0
- package/dist/types/storage.d.ts.map +1 -0
- package/dist/types/types.d.ts +279 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/utils.d.ts +48 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/package.json +68 -0
- package/src/client.test.ts +346 -0
- package/src/client.ts +510 -0
- package/src/index.ts +79 -0
- package/src/logger.ts +63 -0
- package/src/network.ts +230 -0
- package/src/storage.test.ts +175 -0
- package/src/storage.ts +249 -0
- package/src/types.ts +347 -0
- package/src/utils.test.ts +239 -0
- package/src/utils.ts +315 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration options for the MostlyGoodMetrics SDK.
|
|
3
|
+
*/
|
|
4
|
+
export interface MGMConfiguration {
|
|
5
|
+
/**
|
|
6
|
+
* The API key for authenticating with MostlyGoodMetrics.
|
|
7
|
+
* Required.
|
|
8
|
+
*/
|
|
9
|
+
apiKey: string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The base URL for the MostlyGoodMetrics API.
|
|
13
|
+
* @default "https://mostlygoodmetrics.com"
|
|
14
|
+
*/
|
|
15
|
+
baseURL?: string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The environment name (e.g., "production", "staging", "development").
|
|
19
|
+
* @default "production"
|
|
20
|
+
*/
|
|
21
|
+
environment?: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Maximum number of events to send in a single batch.
|
|
25
|
+
* Must be between 1 and 1000.
|
|
26
|
+
* @default 100
|
|
27
|
+
*/
|
|
28
|
+
maxBatchSize?: number;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Interval in seconds between automatic flush attempts.
|
|
32
|
+
* Must be at least 1 second.
|
|
33
|
+
* @default 30
|
|
34
|
+
*/
|
|
35
|
+
flushInterval?: number;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Maximum number of events to store locally before dropping oldest events.
|
|
39
|
+
* Must be at least 100.
|
|
40
|
+
* @default 10000
|
|
41
|
+
*/
|
|
42
|
+
maxStoredEvents?: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether to enable debug logging to the console.
|
|
46
|
+
* @default false
|
|
47
|
+
*/
|
|
48
|
+
enableDebugLogging?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether to automatically track app lifecycle events
|
|
52
|
+
* ($app_opened, $app_backgrounded, $app_installed, $app_updated).
|
|
53
|
+
* @default true
|
|
54
|
+
*/
|
|
55
|
+
trackAppLifecycleEvents?: boolean;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Custom bundle identifier to use instead of auto-detection.
|
|
59
|
+
* Useful for multi-tenant applications.
|
|
60
|
+
*/
|
|
61
|
+
bundleId?: string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The app version string to include with events.
|
|
65
|
+
* If not provided, will attempt to auto-detect from the environment.
|
|
66
|
+
*/
|
|
67
|
+
appVersion?: string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The OS version string to include with events.
|
|
71
|
+
* If not provided, will attempt to auto-detect from the environment.
|
|
72
|
+
* For React Native, pass Platform.Version.toString().
|
|
73
|
+
*/
|
|
74
|
+
osVersion?: string;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Custom storage adapter. If not provided, uses localStorage in browsers
|
|
78
|
+
* or in-memory storage in non-browser environments.
|
|
79
|
+
*/
|
|
80
|
+
storage?: IEventStorage;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Custom network client. If not provided, uses fetch-based client.
|
|
84
|
+
*/
|
|
85
|
+
networkClient?: INetworkClient;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Internal resolved configuration with all defaults applied.
|
|
90
|
+
*/
|
|
91
|
+
export interface ResolvedConfiguration extends Required<
|
|
92
|
+
Omit<MGMConfiguration, 'storage' | 'networkClient'>
|
|
93
|
+
> {
|
|
94
|
+
storage?: IEventStorage;
|
|
95
|
+
networkClient?: INetworkClient;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Properties that can be attached to an event.
|
|
100
|
+
* Supports nested objects up to 3 levels deep.
|
|
101
|
+
*/
|
|
102
|
+
export type EventProperties = Record<string, EventPropertyValue>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Valid types for event property values.
|
|
106
|
+
*/
|
|
107
|
+
export type EventPropertyValue =
|
|
108
|
+
| null
|
|
109
|
+
| boolean
|
|
110
|
+
| number
|
|
111
|
+
| string
|
|
112
|
+
| EventPropertyValue[]
|
|
113
|
+
| { [key: string]: EventPropertyValue };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* An analytics event to be tracked.
|
|
117
|
+
*/
|
|
118
|
+
export interface MGMEvent {
|
|
119
|
+
/**
|
|
120
|
+
* The name of the event. Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*$
|
|
121
|
+
* Max 255 characters.
|
|
122
|
+
*/
|
|
123
|
+
name: string;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* ISO8601 timestamp when the event occurred.
|
|
127
|
+
*/
|
|
128
|
+
timestamp: string;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The user ID associated with this event.
|
|
132
|
+
*/
|
|
133
|
+
userId?: string;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The session ID associated with this event.
|
|
137
|
+
*/
|
|
138
|
+
sessionId?: string;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The platform this event was generated from.
|
|
142
|
+
*/
|
|
143
|
+
platform: Platform;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The app version string.
|
|
147
|
+
*/
|
|
148
|
+
appVersion?: string;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The OS version string.
|
|
152
|
+
*/
|
|
153
|
+
osVersion?: string;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The environment name.
|
|
157
|
+
*/
|
|
158
|
+
environment: string;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Custom properties attached to this event.
|
|
162
|
+
*/
|
|
163
|
+
properties?: EventProperties;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Context sent with each batch of events.
|
|
168
|
+
* Applied to all events in the batch server-side.
|
|
169
|
+
*/
|
|
170
|
+
export interface MGMEventContext {
|
|
171
|
+
platform: Platform;
|
|
172
|
+
appVersion?: string;
|
|
173
|
+
osVersion?: string;
|
|
174
|
+
userId?: string;
|
|
175
|
+
sessionId?: string;
|
|
176
|
+
environment: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The payload sent to the events API.
|
|
181
|
+
*/
|
|
182
|
+
export interface MGMEventsPayload {
|
|
183
|
+
events: MGMEvent[];
|
|
184
|
+
context: MGMEventContext;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Supported platforms.
|
|
189
|
+
*/
|
|
190
|
+
export type Platform = 'web' | 'ios' | 'android' | 'react-native' | 'expo' | 'node';
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Device types for automatic device detection.
|
|
194
|
+
*/
|
|
195
|
+
export type DeviceType = 'phone' | 'tablet' | 'desktop' | 'tv' | 'watch' | 'unknown';
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Error types that can occur in the SDK.
|
|
199
|
+
*/
|
|
200
|
+
export type MGMErrorType =
|
|
201
|
+
| 'NETWORK_ERROR'
|
|
202
|
+
| 'ENCODING_ERROR'
|
|
203
|
+
| 'BAD_REQUEST'
|
|
204
|
+
| 'UNAUTHORIZED'
|
|
205
|
+
| 'FORBIDDEN'
|
|
206
|
+
| 'RATE_LIMITED'
|
|
207
|
+
| 'SERVER_ERROR'
|
|
208
|
+
| 'INVALID_EVENT_NAME'
|
|
209
|
+
| 'STORAGE_ERROR'
|
|
210
|
+
| 'UNKNOWN_ERROR';
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Result of a network send operation.
|
|
214
|
+
*/
|
|
215
|
+
export type SendResult =
|
|
216
|
+
| { success: true }
|
|
217
|
+
| { success: false; error: MGMError; shouldRetry: boolean };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Interface for event storage implementations.
|
|
221
|
+
*/
|
|
222
|
+
export interface IEventStorage {
|
|
223
|
+
/**
|
|
224
|
+
* Store an event for later sending.
|
|
225
|
+
*/
|
|
226
|
+
store(event: MGMEvent): Promise<void>;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Fetch up to `limit` events from storage (FIFO order).
|
|
230
|
+
*/
|
|
231
|
+
fetchEvents(limit: number): Promise<MGMEvent[]>;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove events from storage after successful send.
|
|
235
|
+
*/
|
|
236
|
+
removeEvents(count: number): Promise<void>;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get the current count of stored events.
|
|
240
|
+
*/
|
|
241
|
+
eventCount(): Promise<number>;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Clear all stored events.
|
|
245
|
+
*/
|
|
246
|
+
clear(): Promise<void>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Interface for network client implementations.
|
|
251
|
+
*/
|
|
252
|
+
export interface INetworkClient {
|
|
253
|
+
/**
|
|
254
|
+
* Send a batch of events to the server.
|
|
255
|
+
*/
|
|
256
|
+
sendEvents(payload: MGMEventsPayload, config: ResolvedConfiguration): Promise<SendResult>;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Check if the client is currently rate-limited.
|
|
260
|
+
*/
|
|
261
|
+
isRateLimited(): boolean;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get the retry-after time if rate-limited, or null if not.
|
|
265
|
+
*/
|
|
266
|
+
getRetryAfterTime(): Date | null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Custom error class for SDK errors.
|
|
271
|
+
*/
|
|
272
|
+
export class MGMError extends Error {
|
|
273
|
+
public readonly type: MGMErrorType;
|
|
274
|
+
public readonly retryAfter?: number;
|
|
275
|
+
public readonly statusCode?: number;
|
|
276
|
+
|
|
277
|
+
constructor(
|
|
278
|
+
type: MGMErrorType,
|
|
279
|
+
message: string,
|
|
280
|
+
options?: { retryAfter?: number; statusCode?: number }
|
|
281
|
+
) {
|
|
282
|
+
super(message);
|
|
283
|
+
this.name = 'MGMError';
|
|
284
|
+
this.type = type;
|
|
285
|
+
this.retryAfter = options?.retryAfter;
|
|
286
|
+
this.statusCode = options?.statusCode;
|
|
287
|
+
|
|
288
|
+
// Maintains proper stack trace for where error was thrown (V8 engines)
|
|
289
|
+
if (Error.captureStackTrace) {
|
|
290
|
+
Error.captureStackTrace(this, MGMError);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* System event names (prefixed with $).
|
|
297
|
+
*/
|
|
298
|
+
export const SystemEvents = {
|
|
299
|
+
APP_INSTALLED: '$app_installed',
|
|
300
|
+
APP_UPDATED: '$app_updated',
|
|
301
|
+
APP_OPENED: '$app_opened',
|
|
302
|
+
APP_BACKGROUNDED: '$app_backgrounded',
|
|
303
|
+
} as const;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* System property keys (prefixed with $).
|
|
307
|
+
*/
|
|
308
|
+
export const SystemProperties = {
|
|
309
|
+
DEVICE_TYPE: '$device_type',
|
|
310
|
+
DEVICE_MODEL: '$device_model',
|
|
311
|
+
VERSION: '$version',
|
|
312
|
+
PREVIOUS_VERSION: '$previous_version',
|
|
313
|
+
} as const;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Default configuration values.
|
|
317
|
+
*/
|
|
318
|
+
export const DefaultConfiguration = {
|
|
319
|
+
baseURL: 'https://mostlygoodmetrics.com',
|
|
320
|
+
environment: 'production',
|
|
321
|
+
maxBatchSize: 100,
|
|
322
|
+
flushInterval: 30,
|
|
323
|
+
maxStoredEvents: 10000,
|
|
324
|
+
enableDebugLogging: false,
|
|
325
|
+
trackAppLifecycleEvents: true,
|
|
326
|
+
} as const;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Validation constraints.
|
|
330
|
+
*/
|
|
331
|
+
export const Constraints = {
|
|
332
|
+
MAX_EVENT_NAME_LENGTH: 255,
|
|
333
|
+
MAX_BATCH_SIZE: 1000,
|
|
334
|
+
MIN_BATCH_SIZE: 1,
|
|
335
|
+
MIN_FLUSH_INTERVAL: 1,
|
|
336
|
+
MIN_STORED_EVENTS: 100,
|
|
337
|
+
MAX_STRING_PROPERTY_LENGTH: 1000,
|
|
338
|
+
MAX_PROPERTY_DEPTH: 3,
|
|
339
|
+
MAX_PROPERTY_SIZE_BYTES: 10 * 1024, // 10KB
|
|
340
|
+
COMPRESSION_THRESHOLD_BYTES: 1024, // 1KB
|
|
341
|
+
} as const;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Regular expression for validating event names.
|
|
345
|
+
* Must start with a letter (or $ for system events) followed by alphanumeric and underscores.
|
|
346
|
+
*/
|
|
347
|
+
export const EVENT_NAME_REGEX = /^\$?[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateUUID,
|
|
3
|
+
getISOTimestamp,
|
|
4
|
+
isValidEventName,
|
|
5
|
+
validateEventName,
|
|
6
|
+
sanitizeProperties,
|
|
7
|
+
resolveConfiguration,
|
|
8
|
+
} from './utils';
|
|
9
|
+
import { Constraints, DefaultConfiguration, MGMError } from './types';
|
|
10
|
+
|
|
11
|
+
describe('generateUUID', () => {
|
|
12
|
+
it('should generate a valid UUID v4 format', () => {
|
|
13
|
+
const uuid = generateUUID();
|
|
14
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
15
|
+
expect(uuid).toMatch(uuidRegex);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should generate unique UUIDs', () => {
|
|
19
|
+
const uuids = new Set<string>();
|
|
20
|
+
for (let i = 0; i < 100; i++) {
|
|
21
|
+
uuids.add(generateUUID());
|
|
22
|
+
}
|
|
23
|
+
expect(uuids.size).toBe(100);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('getISOTimestamp', () => {
|
|
28
|
+
it('should return a valid ISO8601 timestamp', () => {
|
|
29
|
+
const timestamp = getISOTimestamp();
|
|
30
|
+
const parsed = new Date(timestamp);
|
|
31
|
+
expect(parsed.toISOString()).toBe(timestamp);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return current time', () => {
|
|
35
|
+
const before = Date.now();
|
|
36
|
+
const timestamp = getISOTimestamp();
|
|
37
|
+
const after = Date.now();
|
|
38
|
+
|
|
39
|
+
const timestampMs = new Date(timestamp).getTime();
|
|
40
|
+
expect(timestampMs).toBeGreaterThanOrEqual(before);
|
|
41
|
+
expect(timestampMs).toBeLessThanOrEqual(after);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('isValidEventName', () => {
|
|
46
|
+
it('should accept valid event names', () => {
|
|
47
|
+
expect(isValidEventName('button_clicked')).toBe(true);
|
|
48
|
+
expect(isValidEventName('PageView')).toBe(true);
|
|
49
|
+
expect(isValidEventName('event123')).toBe(true);
|
|
50
|
+
expect(isValidEventName('a')).toBe(true);
|
|
51
|
+
expect(isValidEventName('ABC_123_xyz')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should accept system event names (starting with $)', () => {
|
|
55
|
+
expect(isValidEventName('$app_opened')).toBe(true);
|
|
56
|
+
expect(isValidEventName('$app_installed')).toBe(true);
|
|
57
|
+
expect(isValidEventName('$custom_system_event')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should reject invalid event names', () => {
|
|
61
|
+
expect(isValidEventName('')).toBe(false);
|
|
62
|
+
expect(isValidEventName('123_event')).toBe(false); // starts with number
|
|
63
|
+
expect(isValidEventName('_event')).toBe(false); // starts with underscore
|
|
64
|
+
expect(isValidEventName('event-name')).toBe(false); // contains hyphen
|
|
65
|
+
expect(isValidEventName('event.name')).toBe(false); // contains dot
|
|
66
|
+
expect(isValidEventName('event name')).toBe(false); // contains space
|
|
67
|
+
expect(isValidEventName('event@name')).toBe(false); // contains @
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should reject event names exceeding max length', () => {
|
|
71
|
+
const longName = 'a'.repeat(Constraints.MAX_EVENT_NAME_LENGTH + 1);
|
|
72
|
+
expect(isValidEventName(longName)).toBe(false);
|
|
73
|
+
|
|
74
|
+
const maxLengthName = 'a'.repeat(Constraints.MAX_EVENT_NAME_LENGTH);
|
|
75
|
+
expect(isValidEventName(maxLengthName)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('validateEventName', () => {
|
|
80
|
+
it('should not throw for valid event names', () => {
|
|
81
|
+
expect(() => validateEventName('valid_event')).not.toThrow();
|
|
82
|
+
expect(() => validateEventName('$system_event')).not.toThrow();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should throw MGMError for empty event names', () => {
|
|
86
|
+
expect(() => validateEventName('')).toThrow(MGMError);
|
|
87
|
+
try {
|
|
88
|
+
validateEventName('');
|
|
89
|
+
} catch (e) {
|
|
90
|
+
expect(e).toBeInstanceOf(MGMError);
|
|
91
|
+
expect((e as MGMError).type).toBe('INVALID_EVENT_NAME');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should throw MGMError for invalid event names', () => {
|
|
96
|
+
expect(() => validateEventName('123invalid')).toThrow(MGMError);
|
|
97
|
+
try {
|
|
98
|
+
validateEventName('invalid-name');
|
|
99
|
+
} catch (e) {
|
|
100
|
+
expect(e).toBeInstanceOf(MGMError);
|
|
101
|
+
expect((e as MGMError).type).toBe('INVALID_EVENT_NAME');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('sanitizeProperties', () => {
|
|
107
|
+
it('should return undefined for undefined input', () => {
|
|
108
|
+
expect(sanitizeProperties(undefined)).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should pass through valid properties unchanged', () => {
|
|
112
|
+
const props = {
|
|
113
|
+
string: 'value',
|
|
114
|
+
number: 42,
|
|
115
|
+
boolean: true,
|
|
116
|
+
null: null,
|
|
117
|
+
};
|
|
118
|
+
expect(sanitizeProperties(props)).toEqual(props);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should truncate long strings', () => {
|
|
122
|
+
const longString = 'a'.repeat(Constraints.MAX_STRING_PROPERTY_LENGTH + 100);
|
|
123
|
+
const props = { text: longString };
|
|
124
|
+
|
|
125
|
+
const sanitized = sanitizeProperties(props);
|
|
126
|
+
expect(sanitized?.text).toHaveLength(Constraints.MAX_STRING_PROPERTY_LENGTH);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle nested objects within depth limit', () => {
|
|
130
|
+
const props = {
|
|
131
|
+
level1: {
|
|
132
|
+
level2: {
|
|
133
|
+
level3: 'value',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const sanitized = sanitizeProperties(props);
|
|
139
|
+
expect(sanitized).toEqual(props);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should truncate objects exceeding max depth', () => {
|
|
143
|
+
const props = {
|
|
144
|
+
level1: {
|
|
145
|
+
level2: {
|
|
146
|
+
level3: {
|
|
147
|
+
level4: 'too deep',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const sanitized = sanitizeProperties(props);
|
|
154
|
+
expect(sanitized?.level1).toBeDefined();
|
|
155
|
+
expect((sanitized?.level1 as Record<string, unknown>).level2).toBeDefined();
|
|
156
|
+
expect(
|
|
157
|
+
((sanitized?.level1 as Record<string, unknown>).level2 as Record<string, unknown>).level3
|
|
158
|
+
).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should handle arrays', () => {
|
|
162
|
+
const props = {
|
|
163
|
+
items: ['a', 'b', 'c'],
|
|
164
|
+
numbers: [1, 2, 3],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
expect(sanitizeProperties(props)).toEqual(props);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should handle mixed content', () => {
|
|
171
|
+
const props = {
|
|
172
|
+
user: {
|
|
173
|
+
name: 'John',
|
|
174
|
+
age: 30,
|
|
175
|
+
tags: ['premium', 'active'],
|
|
176
|
+
},
|
|
177
|
+
active: true,
|
|
178
|
+
score: null,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
expect(sanitizeProperties(props)).toEqual(props);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('resolveConfiguration', () => {
|
|
186
|
+
it('should apply default values', () => {
|
|
187
|
+
const config = resolveConfiguration({ apiKey: 'test-key' });
|
|
188
|
+
|
|
189
|
+
expect(config.apiKey).toBe('test-key');
|
|
190
|
+
expect(config.baseURL).toBe(DefaultConfiguration.baseURL);
|
|
191
|
+
expect(config.environment).toBe(DefaultConfiguration.environment);
|
|
192
|
+
expect(config.maxBatchSize).toBe(DefaultConfiguration.maxBatchSize);
|
|
193
|
+
expect(config.flushInterval).toBe(DefaultConfiguration.flushInterval);
|
|
194
|
+
expect(config.maxStoredEvents).toBe(DefaultConfiguration.maxStoredEvents);
|
|
195
|
+
expect(config.enableDebugLogging).toBe(DefaultConfiguration.enableDebugLogging);
|
|
196
|
+
expect(config.trackAppLifecycleEvents).toBe(DefaultConfiguration.trackAppLifecycleEvents);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should use provided values', () => {
|
|
200
|
+
const config = resolveConfiguration({
|
|
201
|
+
apiKey: 'test-key',
|
|
202
|
+
baseURL: 'https://custom.api.com',
|
|
203
|
+
environment: 'staging',
|
|
204
|
+
maxBatchSize: 50,
|
|
205
|
+
flushInterval: 60,
|
|
206
|
+
maxStoredEvents: 5000,
|
|
207
|
+
enableDebugLogging: true,
|
|
208
|
+
trackAppLifecycleEvents: false,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(config.baseURL).toBe('https://custom.api.com');
|
|
212
|
+
expect(config.environment).toBe('staging');
|
|
213
|
+
expect(config.maxBatchSize).toBe(50);
|
|
214
|
+
expect(config.flushInterval).toBe(60);
|
|
215
|
+
expect(config.maxStoredEvents).toBe(5000);
|
|
216
|
+
expect(config.enableDebugLogging).toBe(true);
|
|
217
|
+
expect(config.trackAppLifecycleEvents).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should enforce maxBatchSize constraints', () => {
|
|
221
|
+
// Too high
|
|
222
|
+
let config = resolveConfiguration({ apiKey: 'test', maxBatchSize: 2000 });
|
|
223
|
+
expect(config.maxBatchSize).toBe(Constraints.MAX_BATCH_SIZE);
|
|
224
|
+
|
|
225
|
+
// Too low
|
|
226
|
+
config = resolveConfiguration({ apiKey: 'test', maxBatchSize: 0 });
|
|
227
|
+
expect(config.maxBatchSize).toBe(Constraints.MIN_BATCH_SIZE);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should enforce flushInterval minimum', () => {
|
|
231
|
+
const config = resolveConfiguration({ apiKey: 'test', flushInterval: 0 });
|
|
232
|
+
expect(config.flushInterval).toBe(Constraints.MIN_FLUSH_INTERVAL);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should enforce maxStoredEvents minimum', () => {
|
|
236
|
+
const config = resolveConfiguration({ apiKey: 'test', maxStoredEvents: 10 });
|
|
237
|
+
expect(config.maxStoredEvents).toBe(Constraints.MIN_STORED_EVENTS);
|
|
238
|
+
});
|
|
239
|
+
});
|