@od-oneapp/analytics 2026.1.1301

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 (184) hide show
  1. package/README.md +509 -0
  2. package/dist/ai-YMnynb-t.mjs +3347 -0
  3. package/dist/ai-YMnynb-t.mjs.map +1 -0
  4. package/dist/chunk-DQk6qfdC.mjs +18 -0
  5. package/dist/client-CTzJVFU5.mjs +9 -0
  6. package/dist/client-CTzJVFU5.mjs.map +1 -0
  7. package/dist/client-CcFTauAh.mjs +54 -0
  8. package/dist/client-CcFTauAh.mjs.map +1 -0
  9. package/dist/client-CeOLjbac.mjs +281 -0
  10. package/dist/client-CeOLjbac.mjs.map +1 -0
  11. package/dist/client-D339NFJS.mjs +267 -0
  12. package/dist/client-D339NFJS.mjs.map +1 -0
  13. package/dist/client-next.d.mts +62 -0
  14. package/dist/client-next.d.mts.map +1 -0
  15. package/dist/client-next.mjs +525 -0
  16. package/dist/client-next.mjs.map +1 -0
  17. package/dist/client.d.mts +30 -0
  18. package/dist/client.d.mts.map +1 -0
  19. package/dist/client.mjs +186 -0
  20. package/dist/client.mjs.map +1 -0
  21. package/dist/config-DPS6bSYo.d.mts +34 -0
  22. package/dist/config-DPS6bSYo.d.mts.map +1 -0
  23. package/dist/config-P6P5adJg.mjs +287 -0
  24. package/dist/config-P6P5adJg.mjs.map +1 -0
  25. package/dist/console-8bND3mMU.mjs +128 -0
  26. package/dist/console-8bND3mMU.mjs.map +1 -0
  27. package/dist/ecommerce-Cgu4wlux.mjs +993 -0
  28. package/dist/ecommerce-Cgu4wlux.mjs.map +1 -0
  29. package/dist/emitters-6-nKo8i-.mjs +208 -0
  30. package/dist/emitters-6-nKo8i-.mjs.map +1 -0
  31. package/dist/emitters-DldkVSPp.d.mts +12 -0
  32. package/dist/emitters-DldkVSPp.d.mts.map +1 -0
  33. package/dist/index-BfNWgfa5.d.mts +1494 -0
  34. package/dist/index-BfNWgfa5.d.mts.map +1 -0
  35. package/dist/index-BkIWe--N.d.mts +953 -0
  36. package/dist/index-BkIWe--N.d.mts.map +1 -0
  37. package/dist/index-jPzXRn52.d.mts +184 -0
  38. package/dist/index-jPzXRn52.d.mts.map +1 -0
  39. package/dist/manager-DvRRjza6.d.mts +76 -0
  40. package/dist/manager-DvRRjza6.d.mts.map +1 -0
  41. package/dist/posthog-bootstrap-CYfIy_WS.mjs +1769 -0
  42. package/dist/posthog-bootstrap-CYfIy_WS.mjs.map +1 -0
  43. package/dist/posthog-bootstrap-DWxFrxlt.d.mts +81 -0
  44. package/dist/posthog-bootstrap-DWxFrxlt.d.mts.map +1 -0
  45. package/dist/providers-http-client.d.mts +37 -0
  46. package/dist/providers-http-client.d.mts.map +1 -0
  47. package/dist/providers-http-client.mjs +320 -0
  48. package/dist/providers-http-client.mjs.map +1 -0
  49. package/dist/providers-http-server.d.mts +31 -0
  50. package/dist/providers-http-server.d.mts.map +1 -0
  51. package/dist/providers-http-server.mjs +297 -0
  52. package/dist/providers-http-server.mjs.map +1 -0
  53. package/dist/providers-http.d.mts +46 -0
  54. package/dist/providers-http.d.mts.map +1 -0
  55. package/dist/providers-http.mjs +4 -0
  56. package/dist/server-edge.d.mts +9 -0
  57. package/dist/server-edge.d.mts.map +1 -0
  58. package/dist/server-edge.mjs +373 -0
  59. package/dist/server-edge.mjs.map +1 -0
  60. package/dist/server-next.d.mts +67 -0
  61. package/dist/server-next.d.mts.map +1 -0
  62. package/dist/server-next.mjs +193 -0
  63. package/dist/server-next.mjs.map +1 -0
  64. package/dist/server.d.mts +10 -0
  65. package/dist/server.mjs +7 -0
  66. package/dist/service-cYtBBL8x.mjs +945 -0
  67. package/dist/service-cYtBBL8x.mjs.map +1 -0
  68. package/dist/shared.d.mts +16 -0
  69. package/dist/shared.d.mts.map +1 -0
  70. package/dist/shared.mjs +93 -0
  71. package/dist/shared.mjs.map +1 -0
  72. package/dist/types-BxBnNQ0V.d.mts +354 -0
  73. package/dist/types-BxBnNQ0V.d.mts.map +1 -0
  74. package/dist/types-CBvxUEaF.d.mts +216 -0
  75. package/dist/types-CBvxUEaF.d.mts.map +1 -0
  76. package/dist/types.d.mts +4 -0
  77. package/dist/types.mjs +0 -0
  78. package/dist/vercel-types-lwakUfoI.d.mts +102 -0
  79. package/dist/vercel-types-lwakUfoI.d.mts.map +1 -0
  80. package/package.json +129 -0
  81. package/src/client/index.ts +164 -0
  82. package/src/client/manager.ts +71 -0
  83. package/src/client/next/components.tsx +270 -0
  84. package/src/client/next/hooks.ts +217 -0
  85. package/src/client/next/manager.ts +141 -0
  86. package/src/client/next.ts +144 -0
  87. package/src/client-next.ts +101 -0
  88. package/src/client.ts +89 -0
  89. package/src/examples/ai-sdk-patterns.ts +583 -0
  90. package/src/examples/emitter-patterns.ts +476 -0
  91. package/src/examples/nextjs-emitter-patterns.tsx +403 -0
  92. package/src/next/app-router.tsx +564 -0
  93. package/src/next/client.ts +419 -0
  94. package/src/next/index.ts +84 -0
  95. package/src/next/middleware.ts +429 -0
  96. package/src/next/rsc.tsx +300 -0
  97. package/src/next/server.ts +253 -0
  98. package/src/next/types.d.ts +220 -0
  99. package/src/providers/base-provider.ts +419 -0
  100. package/src/providers/console/client.ts +10 -0
  101. package/src/providers/console/index.ts +152 -0
  102. package/src/providers/console/server.ts +6 -0
  103. package/src/providers/console/types.ts +15 -0
  104. package/src/providers/http/client.ts +464 -0
  105. package/src/providers/http/index.ts +30 -0
  106. package/src/providers/http/server.ts +396 -0
  107. package/src/providers/http/types.ts +135 -0
  108. package/src/providers/posthog/client.ts +518 -0
  109. package/src/providers/posthog/index.ts +11 -0
  110. package/src/providers/posthog/server.ts +329 -0
  111. package/src/providers/posthog/types.ts +104 -0
  112. package/src/providers/segment/client.ts +113 -0
  113. package/src/providers/segment/index.ts +11 -0
  114. package/src/providers/segment/server.ts +115 -0
  115. package/src/providers/segment/types.ts +51 -0
  116. package/src/providers/vercel/client.ts +102 -0
  117. package/src/providers/vercel/index.ts +11 -0
  118. package/src/providers/vercel/server.ts +89 -0
  119. package/src/providers/vercel/types.ts +27 -0
  120. package/src/server/index.ts +103 -0
  121. package/src/server/manager.ts +62 -0
  122. package/src/server/next.ts +210 -0
  123. package/src/server-edge.ts +442 -0
  124. package/src/server-next.ts +39 -0
  125. package/src/server.ts +106 -0
  126. package/src/shared/emitters/ai/README.md +981 -0
  127. package/src/shared/emitters/ai/events/agent.ts +130 -0
  128. package/src/shared/emitters/ai/events/artifacts.ts +167 -0
  129. package/src/shared/emitters/ai/events/chat.ts +126 -0
  130. package/src/shared/emitters/ai/events/chatbot-ecommerce.ts +133 -0
  131. package/src/shared/emitters/ai/events/completion.ts +103 -0
  132. package/src/shared/emitters/ai/events/content-generation.ts +347 -0
  133. package/src/shared/emitters/ai/events/conversation.ts +332 -0
  134. package/src/shared/emitters/ai/events/product-features.ts +1402 -0
  135. package/src/shared/emitters/ai/events/streaming.ts +114 -0
  136. package/src/shared/emitters/ai/events/tool.ts +93 -0
  137. package/src/shared/emitters/ai/index.ts +69 -0
  138. package/src/shared/emitters/ai/track-ai-sdk.ts +74 -0
  139. package/src/shared/emitters/ai/track-ai.ts +50 -0
  140. package/src/shared/emitters/ai/types.ts +1041 -0
  141. package/src/shared/emitters/ai/utils.ts +468 -0
  142. package/src/shared/emitters/ecommerce/events/cart-checkout.ts +106 -0
  143. package/src/shared/emitters/ecommerce/events/coupon.ts +49 -0
  144. package/src/shared/emitters/ecommerce/events/engagement.ts +61 -0
  145. package/src/shared/emitters/ecommerce/events/marketplace.ts +119 -0
  146. package/src/shared/emitters/ecommerce/events/order.ts +199 -0
  147. package/src/shared/emitters/ecommerce/events/product.ts +205 -0
  148. package/src/shared/emitters/ecommerce/events/registry.ts +123 -0
  149. package/src/shared/emitters/ecommerce/events/wishlist-sharing.ts +140 -0
  150. package/src/shared/emitters/ecommerce/index.ts +46 -0
  151. package/src/shared/emitters/ecommerce/track-ecommerce.ts +53 -0
  152. package/src/shared/emitters/ecommerce/types.ts +314 -0
  153. package/src/shared/emitters/ecommerce/utils.ts +216 -0
  154. package/src/shared/emitters/emitter-types.ts +974 -0
  155. package/src/shared/emitters/emitters.ts +292 -0
  156. package/src/shared/emitters/helpers.ts +419 -0
  157. package/src/shared/emitters/index.ts +66 -0
  158. package/src/shared/index.ts +142 -0
  159. package/src/shared/ingestion/index.ts +66 -0
  160. package/src/shared/ingestion/schemas.ts +386 -0
  161. package/src/shared/ingestion/service.ts +628 -0
  162. package/src/shared/node22-features.ts +848 -0
  163. package/src/shared/providers/console-provider.ts +160 -0
  164. package/src/shared/types/base-types.ts +54 -0
  165. package/src/shared/types/console-types.ts +19 -0
  166. package/src/shared/types/posthog-types.ts +131 -0
  167. package/src/shared/types/segment-types.ts +15 -0
  168. package/src/shared/types/types.ts +397 -0
  169. package/src/shared/types/vercel-types.ts +19 -0
  170. package/src/shared/utils/config-client.ts +19 -0
  171. package/src/shared/utils/config.ts +250 -0
  172. package/src/shared/utils/emitter-adapter.ts +212 -0
  173. package/src/shared/utils/manager.test.ts +36 -0
  174. package/src/shared/utils/manager.ts +1322 -0
  175. package/src/shared/utils/posthog-bootstrap.ts +136 -0
  176. package/src/shared/utils/posthog-client-utils.ts +48 -0
  177. package/src/shared/utils/posthog-next-utils.ts +282 -0
  178. package/src/shared/utils/posthog-server-utils.ts +210 -0
  179. package/src/shared/utils/rate-limit.ts +289 -0
  180. package/src/shared/utils/security.ts +545 -0
  181. package/src/shared/utils/validation-client.ts +161 -0
  182. package/src/shared/utils/validation.ts +399 -0
  183. package/src/shared.ts +155 -0
  184. package/src/types/index.ts +62 -0
@@ -0,0 +1,628 @@
1
+ /**
2
+ * @fileoverview Event Ingestion Service
3
+ *
4
+ * Provides server-side event ingestion functionality. Handles validation,
5
+ * normalization, and forwarding of events to the analytics system.
6
+ *
7
+ * **Key Features**:
8
+ * - Validates incoming events against Zod schemas
9
+ * - Normalizes event payloads with defaults
10
+ * - Forwards events to AnalyticsManager
11
+ * - Tracks ingestion metrics
12
+ * - Handles batch processing efficiently
13
+ *
14
+ * @module @od-oneapp/analytics/shared/ingestion/service
15
+ */
16
+
17
+ import { logDebug, logError, logInfo, logWarn } from '@repo/shared/logs';
18
+
19
+ import { sanitizeProperties, validateEventName } from '../utils/security';
20
+
21
+ import { BatchEventRequestSchema, EventPayloadSchema, IngestionRequestSchema } from './schemas';
22
+
23
+ import type {
24
+ BatchEventRequest,
25
+ EmitterContext,
26
+ EventPayload,
27
+ EventResult,
28
+ IngestionErrorResponse,
29
+ IngestionRequest,
30
+ IngestionSuccessResponse,
31
+ } from './schemas';
32
+ import type { EmitterPayload } from '../emitters/emitter-types';
33
+ import type { AnalyticsManager } from '../types/types';
34
+
35
+ // =============================================================================
36
+ // Types
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Ingestion context provided by the caller (usually the API route handler).
41
+ */
42
+ export interface IngestionContext {
43
+ /** Source identifier (app/module/emitter id) */
44
+ source: string;
45
+
46
+ /** Tenant/project ID for multi-tenancy */
47
+ tenantId?: string;
48
+
49
+ /** User ID from auth context */
50
+ userId?: string;
51
+
52
+ /** Account/organization ID */
53
+ accountId?: string;
54
+
55
+ /** Client IP address */
56
+ ip?: string;
57
+
58
+ /** Client user agent */
59
+ userAgent?: string;
60
+
61
+ /** Environment (dev/stage/prod) */
62
+ environment?: string;
63
+
64
+ /** Trace ID for observability */
65
+ traceId?: string;
66
+
67
+ /** Correlation ID for request tracing */
68
+ correlationId?: string;
69
+
70
+ /** API version */
71
+ apiVersion?: string;
72
+
73
+ /** SDK version from client */
74
+ sdkVersion?: string;
75
+ }
76
+
77
+ /**
78
+ * Ingestion service configuration.
79
+ */
80
+ export interface IngestionServiceConfig {
81
+ /** Maximum events per batch (default: 100) */
82
+ maxBatchSize?: number;
83
+
84
+ /** Maximum payload size in bytes (default: 1MB) */
85
+ maxPayloadSize?: number;
86
+
87
+ /** Whether to strip PII from properties (default: true in production) */
88
+ stripPII?: boolean;
89
+
90
+ /** Whether to strip HTML from properties (default: true) */
91
+ stripHTML?: boolean;
92
+
93
+ /** Timeout for processing each event in ms (default: 5000) */
94
+ eventTimeout?: number;
95
+
96
+ /** Concurrency for batch processing (default: 10) */
97
+ batchConcurrency?: number;
98
+ }
99
+
100
+ /**
101
+ * Ingestion metrics for observability.
102
+ */
103
+ export interface IngestionMetrics {
104
+ /** Total events received */
105
+ totalReceived: number;
106
+
107
+ /** Events accepted */
108
+ accepted: number;
109
+
110
+ /** Events rejected */
111
+ rejected: number;
112
+
113
+ /** Events by type */
114
+ byType: Record<string, number>;
115
+
116
+ /** Processing time in ms */
117
+ processingTimeMs: number;
118
+ }
119
+
120
+ // =============================================================================
121
+ // Default Configuration
122
+ // =============================================================================
123
+
124
+ const DEFAULT_CONFIG: Required<IngestionServiceConfig> = {
125
+ maxBatchSize: 100,
126
+ maxPayloadSize: 1024 * 1024, // 1MB
127
+ stripPII: true, // Default to safe; callers can override
128
+ stripHTML: true,
129
+ eventTimeout: 5000,
130
+ batchConcurrency: 10,
131
+ };
132
+
133
+ // =============================================================================
134
+ // Utility Functions
135
+ // =============================================================================
136
+
137
+ /**
138
+ * Generate a UUID v4.
139
+ */
140
+ function generateUUID(): string {
141
+ return crypto.randomUUID();
142
+ }
143
+
144
+ /**
145
+ * Get current ISO timestamp.
146
+ */
147
+ function getCurrentTimestamp(): string {
148
+ return new Date().toISOString();
149
+ }
150
+
151
+ /**
152
+ * Check if request is a batch request.
153
+ */
154
+ function isBatchRequest(request: IngestionRequest): request is BatchEventRequest {
155
+ return 'batch' in request && Array.isArray(request.batch);
156
+ }
157
+
158
+ /**
159
+ * Normalize an event payload with defaults and server metadata.
160
+ */
161
+ function normalizeEvent(
162
+ event: EventPayload,
163
+ context: IngestionContext,
164
+ receivedAt: string,
165
+ ): EventPayload {
166
+ const normalized = { ...event };
167
+
168
+ // Generate message ID if not provided
169
+ if (!normalized.messageId) {
170
+ normalized.messageId = generateUUID();
171
+ }
172
+
173
+ // Set timestamp if not provided
174
+ if (!normalized.timestamp) {
175
+ normalized.timestamp = receivedAt;
176
+ }
177
+
178
+ // Preserve original timestamp
179
+ if (!normalized.originalTimestamp) {
180
+ normalized.originalTimestamp = normalized.timestamp;
181
+ }
182
+
183
+ // Merge context with server-provided context
184
+ const mergedContext: EmitterContext = {
185
+ ...normalized.context,
186
+ channel: normalized.context?.channel ?? 'api',
187
+ ip: context.ip ?? normalized.context?.ip,
188
+ userAgent: context.userAgent ?? normalized.context?.userAgent,
189
+ library: normalized.context?.library ?? {
190
+ name: context.source,
191
+ version: context.sdkVersion ?? 'unknown',
192
+ },
193
+ };
194
+
195
+ normalized.context = mergedContext;
196
+
197
+ // Set userId from auth context if not provided
198
+ if (!normalized.userId && context.userId) {
199
+ normalized.userId = context.userId;
200
+ }
201
+
202
+ return normalized;
203
+ }
204
+
205
+ /**
206
+ * Convert EventPayload to EmitterPayload format for AnalyticsManager.
207
+ * Explicitly maps fields to ensure type safety.
208
+ */
209
+ function toEmitterPayload(event: EventPayload): EmitterPayload {
210
+ // Explicitly construct the EmitterPayload from EventPayload fields
211
+ // to maintain type safety and avoid unsafe double casts
212
+ const basePayload = {
213
+ userId: event.userId,
214
+ anonymousId: event.anonymousId,
215
+ timestamp: event.timestamp,
216
+ context: event.context,
217
+ messageId: event.messageId,
218
+ };
219
+
220
+ switch (event.type) {
221
+ case 'track':
222
+ return {
223
+ type: 'track',
224
+ event: event.event,
225
+ properties: event.properties,
226
+ ...basePayload,
227
+ } as EmitterPayload;
228
+ case 'identify':
229
+ return {
230
+ type: 'identify',
231
+ traits: event.traits,
232
+ ...basePayload,
233
+ } as EmitterPayload;
234
+ case 'page':
235
+ return {
236
+ type: 'page',
237
+ name: event.name,
238
+ category: event.category,
239
+ properties: event.properties,
240
+ ...basePayload,
241
+ } as EmitterPayload;
242
+ case 'screen':
243
+ return {
244
+ type: 'screen',
245
+ name: event.name,
246
+ category: event.category,
247
+ properties: event.properties,
248
+ ...basePayload,
249
+ } as EmitterPayload;
250
+ case 'group':
251
+ return {
252
+ type: 'group',
253
+ groupId: event.groupId,
254
+ traits: event.traits,
255
+ ...basePayload,
256
+ } as EmitterPayload;
257
+ case 'alias':
258
+ return {
259
+ type: 'alias',
260
+ previousId: event.previousId,
261
+ ...basePayload,
262
+ } as EmitterPayload;
263
+ default:
264
+ // This should never happen due to discriminated union validation
265
+ throw new Error(`Unknown event type: ${(event as { type: string }).type}`);
266
+ }
267
+ }
268
+
269
+ // =============================================================================
270
+ // Ingestion Service Class
271
+ // =============================================================================
272
+
273
+ /**
274
+ * Event Ingestion Service.
275
+ *
276
+ * Handles validation, normalization, and forwarding of analytics events.
277
+ * Designed for high-volume ingestion with batching and rate limiting support.
278
+ *
279
+ * @example
280
+ * ```typescript
281
+ * const service = new IngestionService(analyticsManager);
282
+ *
283
+ * const result = await service.ingest(requestBody, {
284
+ * source: 'web-app',
285
+ * tenantId: 'tenant-123',
286
+ * userId: 'user-456',
287
+ * });
288
+ *
289
+ * if (result.success) {
290
+ * console.log(`Accepted ${result.accepted} events`);
291
+ * }
292
+ * ```
293
+ */
294
+ export class IngestionService {
295
+ private readonly config: Required<IngestionServiceConfig>;
296
+
297
+ constructor(
298
+ private readonly analyticsManager: AnalyticsManager,
299
+ config: IngestionServiceConfig = {},
300
+ ) {
301
+ this.config = { ...DEFAULT_CONFIG, ...config };
302
+ }
303
+
304
+ /**
305
+ * Parse and validate the ingestion request payload.
306
+ *
307
+ * @param payload - Raw request payload (parsed JSON)
308
+ * @returns Parsed and validated request, or error response
309
+ */
310
+ parseRequest(
311
+ payload: unknown,
312
+ ): { success: true; data: IngestionRequest } | { success: false; error: IngestionErrorResponse } {
313
+ // Validate payload size (rough estimate)
314
+ try {
315
+ const payloadSize = JSON.stringify(payload).length;
316
+ if (payloadSize > this.config.maxPayloadSize) {
317
+ return {
318
+ success: false,
319
+ error: {
320
+ success: false,
321
+ code: 'PAYLOAD_TOO_LARGE',
322
+ error: `Payload size ${payloadSize} exceeds maximum ${this.config.maxPayloadSize} bytes`,
323
+ },
324
+ };
325
+ }
326
+ } catch {
327
+ return {
328
+ success: false,
329
+ error: {
330
+ success: false,
331
+ code: 'INVALID_JSON',
332
+ error: 'Failed to serialize payload for size check',
333
+ },
334
+ };
335
+ }
336
+
337
+ // Validate against schema
338
+ const result = IngestionRequestSchema.safeParse(payload);
339
+
340
+ if (!result.success) {
341
+ const fieldErrors = result.error.issues.map(err => ({
342
+ path: err.path.map(p => (typeof p === 'symbol' ? String(p) : p)),
343
+ message: err.message,
344
+ }));
345
+
346
+ return {
347
+ success: false,
348
+ error: {
349
+ success: false,
350
+ code: 'VALIDATION_ERROR',
351
+ error: 'Request validation failed',
352
+ fieldErrors,
353
+ },
354
+ };
355
+ }
356
+
357
+ // Check batch size
358
+ if (isBatchRequest(result.data) && result.data.batch.length > this.config.maxBatchSize) {
359
+ return {
360
+ success: false,
361
+ error: {
362
+ success: false,
363
+ code: 'BATCH_TOO_LARGE',
364
+ error: `Batch size ${result.data.batch.length} exceeds maximum ${this.config.maxBatchSize}`,
365
+ },
366
+ };
367
+ }
368
+
369
+ return { success: true, data: result.data };
370
+ }
371
+
372
+ /**
373
+ * Ingest events from a validated request.
374
+ *
375
+ * @param request - Validated ingestion request
376
+ * @param context - Ingestion context from the caller
377
+ * @returns Ingestion response with per-event results
378
+ */
379
+ async ingest(
380
+ request: IngestionRequest,
381
+ context: IngestionContext,
382
+ ): Promise<IngestionSuccessResponse> {
383
+ const startTime = process.hrtime.bigint();
384
+ const receivedAt = getCurrentTimestamp();
385
+
386
+ // Extract events from request (single or batch)
387
+ const events: EventPayload[] = isBatchRequest(request) ? request.batch : [request];
388
+
389
+ const results: EventResult[] = [];
390
+ const metrics: IngestionMetrics = {
391
+ totalReceived: events.length,
392
+ accepted: 0,
393
+ rejected: 0,
394
+ byType: {},
395
+ processingTimeMs: 0,
396
+ };
397
+
398
+ // Process events
399
+ const processedEvents: EmitterPayload[] = [];
400
+
401
+ for (const event of events) {
402
+ const eventId = generateUUID();
403
+
404
+ try {
405
+ // Validate event name for track events
406
+ if (event.type === 'track') {
407
+ const validation = validateEventName(event.event);
408
+ if (!validation.valid) {
409
+ results.push({
410
+ id: eventId,
411
+ messageId: event.messageId,
412
+ type: event.type,
413
+ status: 'rejected',
414
+ error: `Invalid event name: ${validation.reason}`,
415
+ });
416
+ metrics.rejected++;
417
+ continue;
418
+ }
419
+ }
420
+
421
+ // Normalize event
422
+ const normalized = normalizeEvent(event, context, receivedAt);
423
+
424
+ // Sanitize properties
425
+ if ('properties' in normalized && normalized.properties) {
426
+ const sanitized = sanitizeProperties(normalized.properties, {
427
+ stripPII: this.config.stripPII,
428
+ stripHTML: this.config.stripHTML,
429
+ allowDangerousKeys: false,
430
+ });
431
+ (normalized as { properties: Record<string, unknown> }).properties = sanitized.data;
432
+
433
+ if (sanitized.warnings.length > 0) {
434
+ logDebug('Event properties sanitized', {
435
+ eventId,
436
+ warnings: sanitized.warnings,
437
+ });
438
+ }
439
+ }
440
+
441
+ if ('traits' in normalized && normalized.traits) {
442
+ const sanitized = sanitizeProperties(normalized.traits, {
443
+ stripPII: this.config.stripPII,
444
+ stripHTML: this.config.stripHTML,
445
+ allowDangerousKeys: false,
446
+ });
447
+ (normalized as { traits: Record<string, unknown> }).traits = sanitized.data;
448
+ }
449
+
450
+ // Convert to emitter payload and queue for processing
451
+ const emitterPayload = toEmitterPayload(normalized);
452
+ processedEvents.push(emitterPayload);
453
+
454
+ results.push({
455
+ id: eventId,
456
+ messageId: normalized.messageId,
457
+ type: event.type,
458
+ status: 'accepted',
459
+ });
460
+
461
+ metrics.accepted++;
462
+ metrics.byType[event.type] = (metrics.byType[event.type] ?? 0) + 1;
463
+ } catch (error) {
464
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
465
+
466
+ results.push({
467
+ id: eventId,
468
+ messageId: event.messageId,
469
+ type: event.type,
470
+ status: 'rejected',
471
+ error: errorMessage,
472
+ });
473
+
474
+ metrics.rejected++;
475
+
476
+ logWarn('Event processing failed', {
477
+ eventId,
478
+ type: event.type,
479
+ error: errorMessage,
480
+ });
481
+ }
482
+ }
483
+
484
+ // Forward accepted events to analytics manager (non-blocking)
485
+ // Note: This provides at-most-once delivery semantics. Events may be lost
486
+ // if the analytics manager fails. For guaranteed delivery, consider using
487
+ // a durable queue (Redis, SQS) for critical events.
488
+ if (processedEvents.length > 0) {
489
+ // Use emitBatch for efficient processing
490
+ void (async () => {
491
+ try {
492
+ await this.analyticsManager.emitBatch(processedEvents, {
493
+ timeout: this.config.eventTimeout,
494
+ concurrency: this.config.batchConcurrency,
495
+ failFast: false, // Process all events even if some fail
496
+ });
497
+ } catch (error) {
498
+ logError('Failed to emit events to analytics manager', {
499
+ error: error instanceof Error ? error.message : 'Unknown error',
500
+ eventCount: processedEvents.length,
501
+ traceId: context.traceId,
502
+ });
503
+ }
504
+ })();
505
+ }
506
+
507
+ // Calculate processing time
508
+ const endTime = process.hrtime.bigint();
509
+ metrics.processingTimeMs = Number(endTime - startTime) / 1_000_000;
510
+
511
+ // Log metrics
512
+ logInfo('Event ingestion completed', {
513
+ traceId: context.traceId,
514
+ source: context.source,
515
+ tenantId: context.tenantId,
516
+ totalReceived: metrics.totalReceived,
517
+ accepted: metrics.accepted,
518
+ rejected: metrics.rejected,
519
+ processingTimeMs: metrics.processingTimeMs.toFixed(2),
520
+ byType: metrics.byType,
521
+ });
522
+
523
+ return {
524
+ success: true,
525
+ accepted: metrics.accepted,
526
+ rejected: metrics.rejected,
527
+ results,
528
+ receivedAt,
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Process a raw request body through parsing and ingestion.
534
+ *
535
+ * Convenience method that combines parseRequest and ingest.
536
+ *
537
+ * @param payload - Raw request payload
538
+ * @param context - Ingestion context
539
+ * @returns Ingestion response (success or error)
540
+ */
541
+ async processRequest(
542
+ payload: unknown,
543
+ context: IngestionContext,
544
+ ): Promise<IngestionSuccessResponse | IngestionErrorResponse> {
545
+ const parseResult = this.parseRequest(payload);
546
+
547
+ if (!parseResult.success) {
548
+ return parseResult.error;
549
+ }
550
+
551
+ return this.ingest(parseResult.data, context);
552
+ }
553
+ }
554
+
555
+ // =============================================================================
556
+ // Factory Functions
557
+ // =============================================================================
558
+
559
+ /**
560
+ * Create an ingestion service instance.
561
+ *
562
+ * @param analyticsManager - Initialized AnalyticsManager instance
563
+ * @param config - Optional service configuration
564
+ * @returns Configured IngestionService instance
565
+ *
566
+ * @example
567
+ * ```typescript
568
+ * import { createServerAnalytics } from '@od-oneapp/analytics/server';
569
+ * import { createIngestionService } from '@od-oneapp/analytics/server';
570
+ *
571
+ * const analytics = await createServerAnalytics(config);
572
+ * const ingestionService = createIngestionService(analytics);
573
+ * ```
574
+ */
575
+ export function createIngestionService(
576
+ analyticsManager: AnalyticsManager,
577
+ config?: IngestionServiceConfig,
578
+ ): IngestionService {
579
+ return new IngestionService(analyticsManager, config);
580
+ }
581
+
582
+ /**
583
+ * Validate a single event payload.
584
+ *
585
+ * Useful for pre-validation before queuing events.
586
+ *
587
+ * @param payload - Event payload to validate
588
+ * @returns Validation result with parsed data or errors
589
+ */
590
+ export function validateEventPayload(
591
+ payload: unknown,
592
+ ): { success: true; data: EventPayload } | { success: false; errors: string[] } {
593
+ const result = EventPayloadSchema.safeParse(payload);
594
+
595
+ if (!result.success) {
596
+ return {
597
+ success: false,
598
+ errors: result.error.issues.map(
599
+ e => `${e.path.map(p => (typeof p === 'symbol' ? String(p) : p)).join('.')}: ${e.message}`,
600
+ ),
601
+ };
602
+ }
603
+
604
+ return { success: true, data: result.data };
605
+ }
606
+
607
+ /**
608
+ * Validate a batch of event payloads.
609
+ *
610
+ * @param payloads - Array of event payloads to validate
611
+ * @returns Validation result with parsed data or errors
612
+ */
613
+ export function validateBatchPayload(
614
+ payloads: unknown[],
615
+ ): { success: true; data: EventPayload[] } | { success: false; errors: string[] } {
616
+ const result = BatchEventRequestSchema.safeParse({ batch: payloads });
617
+
618
+ if (!result.success) {
619
+ return {
620
+ success: false,
621
+ errors: result.error.issues.map(
622
+ e => `${e.path.map(p => (typeof p === 'symbol' ? String(p) : p)).join('.')}: ${e.message}`,
623
+ ),
624
+ };
625
+ }
626
+
627
+ return { success: true, data: result.data.batch };
628
+ }