@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.
- package/README.md +509 -0
- package/dist/ai-YMnynb-t.mjs +3347 -0
- package/dist/ai-YMnynb-t.mjs.map +1 -0
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/client-CTzJVFU5.mjs +9 -0
- package/dist/client-CTzJVFU5.mjs.map +1 -0
- package/dist/client-CcFTauAh.mjs +54 -0
- package/dist/client-CcFTauAh.mjs.map +1 -0
- package/dist/client-CeOLjbac.mjs +281 -0
- package/dist/client-CeOLjbac.mjs.map +1 -0
- package/dist/client-D339NFJS.mjs +267 -0
- package/dist/client-D339NFJS.mjs.map +1 -0
- package/dist/client-next.d.mts +62 -0
- package/dist/client-next.d.mts.map +1 -0
- package/dist/client-next.mjs +525 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client.d.mts +30 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +186 -0
- package/dist/client.mjs.map +1 -0
- package/dist/config-DPS6bSYo.d.mts +34 -0
- package/dist/config-DPS6bSYo.d.mts.map +1 -0
- package/dist/config-P6P5adJg.mjs +287 -0
- package/dist/config-P6P5adJg.mjs.map +1 -0
- package/dist/console-8bND3mMU.mjs +128 -0
- package/dist/console-8bND3mMU.mjs.map +1 -0
- package/dist/ecommerce-Cgu4wlux.mjs +993 -0
- package/dist/ecommerce-Cgu4wlux.mjs.map +1 -0
- package/dist/emitters-6-nKo8i-.mjs +208 -0
- package/dist/emitters-6-nKo8i-.mjs.map +1 -0
- package/dist/emitters-DldkVSPp.d.mts +12 -0
- package/dist/emitters-DldkVSPp.d.mts.map +1 -0
- package/dist/index-BfNWgfa5.d.mts +1494 -0
- package/dist/index-BfNWgfa5.d.mts.map +1 -0
- package/dist/index-BkIWe--N.d.mts +953 -0
- package/dist/index-BkIWe--N.d.mts.map +1 -0
- package/dist/index-jPzXRn52.d.mts +184 -0
- package/dist/index-jPzXRn52.d.mts.map +1 -0
- package/dist/manager-DvRRjza6.d.mts +76 -0
- package/dist/manager-DvRRjza6.d.mts.map +1 -0
- package/dist/posthog-bootstrap-CYfIy_WS.mjs +1769 -0
- package/dist/posthog-bootstrap-CYfIy_WS.mjs.map +1 -0
- package/dist/posthog-bootstrap-DWxFrxlt.d.mts +81 -0
- package/dist/posthog-bootstrap-DWxFrxlt.d.mts.map +1 -0
- package/dist/providers-http-client.d.mts +37 -0
- package/dist/providers-http-client.d.mts.map +1 -0
- package/dist/providers-http-client.mjs +320 -0
- package/dist/providers-http-client.mjs.map +1 -0
- package/dist/providers-http-server.d.mts +31 -0
- package/dist/providers-http-server.d.mts.map +1 -0
- package/dist/providers-http-server.mjs +297 -0
- package/dist/providers-http-server.mjs.map +1 -0
- package/dist/providers-http.d.mts +46 -0
- package/dist/providers-http.d.mts.map +1 -0
- package/dist/providers-http.mjs +4 -0
- package/dist/server-edge.d.mts +9 -0
- package/dist/server-edge.d.mts.map +1 -0
- package/dist/server-edge.mjs +373 -0
- package/dist/server-edge.mjs.map +1 -0
- package/dist/server-next.d.mts +67 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +193 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +10 -0
- package/dist/server.mjs +7 -0
- package/dist/service-cYtBBL8x.mjs +945 -0
- package/dist/service-cYtBBL8x.mjs.map +1 -0
- package/dist/shared.d.mts +16 -0
- package/dist/shared.d.mts.map +1 -0
- package/dist/shared.mjs +93 -0
- package/dist/shared.mjs.map +1 -0
- package/dist/types-BxBnNQ0V.d.mts +354 -0
- package/dist/types-BxBnNQ0V.d.mts.map +1 -0
- package/dist/types-CBvxUEaF.d.mts +216 -0
- package/dist/types-CBvxUEaF.d.mts.map +1 -0
- package/dist/types.d.mts +4 -0
- package/dist/types.mjs +0 -0
- package/dist/vercel-types-lwakUfoI.d.mts +102 -0
- package/dist/vercel-types-lwakUfoI.d.mts.map +1 -0
- package/package.json +129 -0
- package/src/client/index.ts +164 -0
- package/src/client/manager.ts +71 -0
- package/src/client/next/components.tsx +270 -0
- package/src/client/next/hooks.ts +217 -0
- package/src/client/next/manager.ts +141 -0
- package/src/client/next.ts +144 -0
- package/src/client-next.ts +101 -0
- package/src/client.ts +89 -0
- package/src/examples/ai-sdk-patterns.ts +583 -0
- package/src/examples/emitter-patterns.ts +476 -0
- package/src/examples/nextjs-emitter-patterns.tsx +403 -0
- package/src/next/app-router.tsx +564 -0
- package/src/next/client.ts +419 -0
- package/src/next/index.ts +84 -0
- package/src/next/middleware.ts +429 -0
- package/src/next/rsc.tsx +300 -0
- package/src/next/server.ts +253 -0
- package/src/next/types.d.ts +220 -0
- package/src/providers/base-provider.ts +419 -0
- package/src/providers/console/client.ts +10 -0
- package/src/providers/console/index.ts +152 -0
- package/src/providers/console/server.ts +6 -0
- package/src/providers/console/types.ts +15 -0
- package/src/providers/http/client.ts +464 -0
- package/src/providers/http/index.ts +30 -0
- package/src/providers/http/server.ts +396 -0
- package/src/providers/http/types.ts +135 -0
- package/src/providers/posthog/client.ts +518 -0
- package/src/providers/posthog/index.ts +11 -0
- package/src/providers/posthog/server.ts +329 -0
- package/src/providers/posthog/types.ts +104 -0
- package/src/providers/segment/client.ts +113 -0
- package/src/providers/segment/index.ts +11 -0
- package/src/providers/segment/server.ts +115 -0
- package/src/providers/segment/types.ts +51 -0
- package/src/providers/vercel/client.ts +102 -0
- package/src/providers/vercel/index.ts +11 -0
- package/src/providers/vercel/server.ts +89 -0
- package/src/providers/vercel/types.ts +27 -0
- package/src/server/index.ts +103 -0
- package/src/server/manager.ts +62 -0
- package/src/server/next.ts +210 -0
- package/src/server-edge.ts +442 -0
- package/src/server-next.ts +39 -0
- package/src/server.ts +106 -0
- package/src/shared/emitters/ai/README.md +981 -0
- package/src/shared/emitters/ai/events/agent.ts +130 -0
- package/src/shared/emitters/ai/events/artifacts.ts +167 -0
- package/src/shared/emitters/ai/events/chat.ts +126 -0
- package/src/shared/emitters/ai/events/chatbot-ecommerce.ts +133 -0
- package/src/shared/emitters/ai/events/completion.ts +103 -0
- package/src/shared/emitters/ai/events/content-generation.ts +347 -0
- package/src/shared/emitters/ai/events/conversation.ts +332 -0
- package/src/shared/emitters/ai/events/product-features.ts +1402 -0
- package/src/shared/emitters/ai/events/streaming.ts +114 -0
- package/src/shared/emitters/ai/events/tool.ts +93 -0
- package/src/shared/emitters/ai/index.ts +69 -0
- package/src/shared/emitters/ai/track-ai-sdk.ts +74 -0
- package/src/shared/emitters/ai/track-ai.ts +50 -0
- package/src/shared/emitters/ai/types.ts +1041 -0
- package/src/shared/emitters/ai/utils.ts +468 -0
- package/src/shared/emitters/ecommerce/events/cart-checkout.ts +106 -0
- package/src/shared/emitters/ecommerce/events/coupon.ts +49 -0
- package/src/shared/emitters/ecommerce/events/engagement.ts +61 -0
- package/src/shared/emitters/ecommerce/events/marketplace.ts +119 -0
- package/src/shared/emitters/ecommerce/events/order.ts +199 -0
- package/src/shared/emitters/ecommerce/events/product.ts +205 -0
- package/src/shared/emitters/ecommerce/events/registry.ts +123 -0
- package/src/shared/emitters/ecommerce/events/wishlist-sharing.ts +140 -0
- package/src/shared/emitters/ecommerce/index.ts +46 -0
- package/src/shared/emitters/ecommerce/track-ecommerce.ts +53 -0
- package/src/shared/emitters/ecommerce/types.ts +314 -0
- package/src/shared/emitters/ecommerce/utils.ts +216 -0
- package/src/shared/emitters/emitter-types.ts +974 -0
- package/src/shared/emitters/emitters.ts +292 -0
- package/src/shared/emitters/helpers.ts +419 -0
- package/src/shared/emitters/index.ts +66 -0
- package/src/shared/index.ts +142 -0
- package/src/shared/ingestion/index.ts +66 -0
- package/src/shared/ingestion/schemas.ts +386 -0
- package/src/shared/ingestion/service.ts +628 -0
- package/src/shared/node22-features.ts +848 -0
- package/src/shared/providers/console-provider.ts +160 -0
- package/src/shared/types/base-types.ts +54 -0
- package/src/shared/types/console-types.ts +19 -0
- package/src/shared/types/posthog-types.ts +131 -0
- package/src/shared/types/segment-types.ts +15 -0
- package/src/shared/types/types.ts +397 -0
- package/src/shared/types/vercel-types.ts +19 -0
- package/src/shared/utils/config-client.ts +19 -0
- package/src/shared/utils/config.ts +250 -0
- package/src/shared/utils/emitter-adapter.ts +212 -0
- package/src/shared/utils/manager.test.ts +36 -0
- package/src/shared/utils/manager.ts +1322 -0
- package/src/shared/utils/posthog-bootstrap.ts +136 -0
- package/src/shared/utils/posthog-client-utils.ts +48 -0
- package/src/shared/utils/posthog-next-utils.ts +282 -0
- package/src/shared/utils/posthog-server-utils.ts +210 -0
- package/src/shared/utils/rate-limit.ts +289 -0
- package/src/shared/utils/security.ts +545 -0
- package/src/shared/utils/validation-client.ts +161 -0
- package/src/shared/utils/validation.ts +399 -0
- package/src/shared.ts +155 -0
- 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
|
+
}
|