@upstash/redis-analytics 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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/dist/backend-client.d.ts +38 -0
  4. package/dist/backend-client.js +189 -0
  5. package/dist/client.d.ts +27 -0
  6. package/dist/client.js +157 -0
  7. package/dist/index.d.ts +11 -0
  8. package/dist/index.js +20 -0
  9. package/dist/protocol.d.ts +45 -0
  10. package/dist/protocol.js +4 -0
  11. package/dist/react.d.ts +33 -0
  12. package/dist/react.js +95 -0
  13. package/dist/services/events.d.ts +26 -0
  14. package/dist/services/events.js +143 -0
  15. package/dist/services/feature-flags.d.ts +14 -0
  16. package/dist/services/feature-flags.js +88 -0
  17. package/dist/services/logging.d.ts +13 -0
  18. package/dist/services/logging.js +66 -0
  19. package/dist/services/schema-registry.d.ts +15 -0
  20. package/dist/services/schema-registry.js +97 -0
  21. package/dist/services/search-index.d.ts +35 -0
  22. package/dist/services/search-index.js +293 -0
  23. package/dist/services/sessions.d.ts +18 -0
  24. package/dist/services/sessions.js +58 -0
  25. package/dist/types.d.ts +144 -0
  26. package/dist/types.js +2 -0
  27. package/dist/utils.d.ts +6 -0
  28. package/dist/utils.js +44 -0
  29. package/package.json +36 -0
  30. package/src/backend-client.ts +301 -0
  31. package/src/client.ts +245 -0
  32. package/src/index.ts +39 -0
  33. package/src/protocol.ts +57 -0
  34. package/src/react.ts +163 -0
  35. package/src/services/events.ts +187 -0
  36. package/src/services/feature-flags.ts +125 -0
  37. package/src/services/logging.ts +81 -0
  38. package/src/services/schema-registry.ts +125 -0
  39. package/src/services/search-index.ts +335 -0
  40. package/src/services/sessions.ts +86 -0
  41. package/src/types.ts +194 -0
  42. package/src/utils.ts +45 -0
package/src/react.ts ADDED
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { AnalyticsClient } from "./client";
5
+ import type {
6
+ CaptureEventInput,
7
+ ClientConfig,
8
+ FeatureFlagAssignment,
9
+ Session,
10
+ } from "./types";
11
+
12
+ type CreateAnalyticsHookConfig<
13
+ TFeatureFlags extends Record<string, string> = Record<string, string>,
14
+ > = ClientConfig & {
15
+ /** Feature flag distributions for auto-created sessions */
16
+ featureFlags?: {
17
+ [K in keyof TFeatureFlags]?: TFeatureFlags[K] | Record<string, number>;
18
+ };
19
+ };
20
+
21
+ type UseAnalyticsReturn<
22
+ TCustomEvents extends Record<string, Record<string, unknown>> = Record<
23
+ string,
24
+ Record<string, unknown>
25
+ >,
26
+ TFeatureFlags extends Record<string, string> = Record<string, string>,
27
+ > = {
28
+ client: AnalyticsClient<TCustomEvents, TFeatureFlags>;
29
+ sessionId: string | null;
30
+ featureFlags: TFeatureFlags;
31
+ captureEvent: (input: CaptureEventInput<TCustomEvents>) => void;
32
+ createSession: (flags?: {
33
+ [K in keyof TFeatureFlags]?: TFeatureFlags[K] | Record<string, number>;
34
+ }) => Promise<Session<TFeatureFlags>>;
35
+ };
36
+
37
+ /**
38
+ * Creates a shared analytics hook. Call once at module level,
39
+ * then use the returned hook in any component — no provider needed.
40
+ *
41
+ * ```ts
42
+ * export const useAnalytics = createAnalyticsHook<MyEvents, MyFlags>({
43
+ * endpoint: "/api/analytics",
44
+ * flushInterval: 2000,
45
+ * featureFlags: { theme: { light: 50, dark: 50 } },
46
+ * });
47
+ * ```
48
+ */
49
+ export function createAnalyticsHook<
50
+ TCustomEvents extends Record<string, Record<string, unknown>> = Record<
51
+ string,
52
+ Record<string, unknown>
53
+ >,
54
+ TFeatureFlags extends Record<string, string> = Record<string, string>,
55
+ >(
56
+ config: CreateAnalyticsHookConfig<TFeatureFlags>
57
+ ) {
58
+ // ── Shared state across all hook instances ──────────────────
59
+ let client: AnalyticsClient<TCustomEvents, TFeatureFlags> | null = null;
60
+ let currentSessionId: string | null = null;
61
+ let currentFeatureFlags: TFeatureFlags = {} as TFeatureFlags;
62
+ let sessionPromise: Promise<Session<TFeatureFlags>> | null = null;
63
+ const listeners = new Set<() => void>();
64
+
65
+ function getClient(): AnalyticsClient<TCustomEvents, TFeatureFlags> {
66
+ if (!client) {
67
+ client = new AnalyticsClient<TCustomEvents, TFeatureFlags>({
68
+ endpoint: config.endpoint,
69
+ flushInterval: config.flushInterval,
70
+ maxBatchSize: config.maxBatchSize,
71
+ });
72
+ }
73
+ return client;
74
+ }
75
+
76
+ function notify() {
77
+ listeners.forEach((l) => l());
78
+ }
79
+
80
+ async function doCreateSession(
81
+ flags?: {
82
+ [K in keyof TFeatureFlags]?: TFeatureFlags[K] | Record<string, number>;
83
+ }
84
+ ): Promise<Session<TFeatureFlags>> {
85
+ const session = await getClient().createSession({
86
+ featureFlags: flags as
87
+ | { [K in keyof TFeatureFlags]?: TFeatureFlags[K] | Record<string, number> }
88
+ | undefined,
89
+ });
90
+ currentSessionId = session.id;
91
+ currentFeatureFlags = session.featureFlags;
92
+ sessionPromise = null;
93
+ notify();
94
+ return session;
95
+ }
96
+
97
+ // Track the last captured path to avoid duplicate pageview events
98
+ let lastCapturedPath: string | null = null;
99
+
100
+ // ── The hook ───────────────────────────────────────────────
101
+ return function useAnalytics(
102
+ options?: { enabled?: boolean }
103
+ ): UseAnalyticsReturn<TCustomEvents, TFeatureFlags> {
104
+ const enabled = options?.enabled ?? true;
105
+ const [, rerender] = useState(0);
106
+ const subscribedRef = useRef(false);
107
+
108
+ // Subscribe to shared state changes
109
+ useEffect(() => {
110
+ const listener = () => rerender((n) => n + 1);
111
+ listeners.add(listener);
112
+ subscribedRef.current = true;
113
+ return () => {
114
+ listeners.delete(listener);
115
+ };
116
+ }, []);
117
+
118
+ // Auto-create session
119
+ useEffect(() => {
120
+ if (enabled && !currentSessionId && !sessionPromise) {
121
+ sessionPromise = doCreateSession(
122
+ config.featureFlags as
123
+ | { [K in keyof TFeatureFlags]?: TFeatureFlags[K] | Record<string, number> }
124
+ | undefined
125
+ );
126
+ }
127
+ }, [enabled]);
128
+
129
+ // Auto-capture pageview on path change
130
+ useEffect(() => {
131
+ if (!enabled || !currentSessionId) return;
132
+
133
+ const path = window.location.pathname;
134
+ if (path === lastCapturedPath) return;
135
+
136
+ lastCapturedPath = path;
137
+ getClient().capturePageView(currentSessionId, path);
138
+ });
139
+
140
+ const createSession = useCallback(
141
+ (flags?: {
142
+ [K in keyof TFeatureFlags]?: TFeatureFlags[K] | Record<string, number>;
143
+ }) => doCreateSession(flags),
144
+ []
145
+ );
146
+
147
+ const captureEvent = useCallback(
148
+ (input: CaptureEventInput<TCustomEvents>) => {
149
+ if (!currentSessionId) return;
150
+ getClient().captureEvent(input);
151
+ },
152
+ []
153
+ );
154
+
155
+ return {
156
+ client: getClient(),
157
+ sessionId: currentSessionId,
158
+ featureFlags: currentFeatureFlags,
159
+ captureEvent,
160
+ createSession,
161
+ };
162
+ };
163
+ }
@@ -0,0 +1,187 @@
1
+ import type { Redis } from "@upstash/redis";
2
+ import type { AnalyticsConfig, EventData, SessionMetadata } from "../types";
3
+ import { LoggingService } from "./logging";
4
+ import { SchemaRegistry } from "./schema-registry";
5
+ import { generateEventKey } from "../utils";
6
+
7
+ const SESSION_PREFIX = "analytics:session:";
8
+
9
+ export class EventService {
10
+ constructor(
11
+ private redis: Redis,
12
+ private config: AnalyticsConfig,
13
+ private schemaRegistry: SchemaRegistry,
14
+ private logger: LoggingService
15
+ ) {}
16
+
17
+ async captureEvent(input: {
18
+ sessionId: string;
19
+ eventName: string;
20
+ properties?: Record<string, unknown>;
21
+ }): Promise<void> {
22
+ // Get session data
23
+ const sessionKey = `${SESSION_PREFIX}${input.sessionId}`;
24
+ const sessionData = await this.redis.get<SessionMetadata | string>(
25
+ sessionKey
26
+ );
27
+
28
+ if (!sessionData) {
29
+ throw new Error(`Session "${input.sessionId}" not found or expired`);
30
+ }
31
+
32
+ const session: SessionMetadata =
33
+ typeof sessionData === "string"
34
+ ? JSON.parse(sessionData)
35
+ : sessionData;
36
+
37
+ // Build event data
38
+ const now = new Date();
39
+ const eventData: EventData = {
40
+ sessionCreationTimestamp: session.createdAt,
41
+ sessionId: input.sessionId,
42
+ eventTime: now.toISOString(),
43
+ eventTimeMs: now.getTime(),
44
+ eventName: input.eventName,
45
+ featureFlags: session.featureFlags,
46
+ ...(session.metadata ? { sessionMetadata: session.metadata } : {}),
47
+ properties: input.properties,
48
+ };
49
+
50
+ // Store event as JSON
51
+ const eventKey = generateEventKey();
52
+ await this.redis.json.set(eventKey, "$", eventData as never);
53
+
54
+ // Track schema for events with properties
55
+ if (input.properties) {
56
+ await this.schemaRegistry.validateAndUpdateSchema(
57
+ input.eventName,
58
+ input.properties
59
+ );
60
+ }
61
+
62
+ // Track first-seen timestamp for custom events
63
+ if (input.eventName.startsWith("custom:")) {
64
+ await this.schemaRegistry.trackCustomEventFirstSeen(input.eventName);
65
+ }
66
+ }
67
+
68
+ async captureBatchEvents(
69
+ events: Array<{
70
+ sessionId: string;
71
+ eventName: string;
72
+ properties?: Record<string, unknown>;
73
+ }>
74
+ ): Promise<void> {
75
+ if (events.length === 0) return;
76
+
77
+ const maxBatchSize = this.config.events.maxBatchSize;
78
+ if (events.length > maxBatchSize) {
79
+ throw new Error(
80
+ `Batch size ${events.length} exceeds maximum of ${maxBatchSize}`
81
+ );
82
+ }
83
+
84
+ // Group events by sessionId
85
+ const bySession = new Map<string, typeof events>();
86
+ for (const event of events) {
87
+ const existing = bySession.get(event.sessionId) ?? [];
88
+ existing.push(event);
89
+ bySession.set(event.sessionId, existing);
90
+ }
91
+
92
+ // Fetch all unique sessions
93
+ const sessionIds = [...bySession.keys()];
94
+ const sessionPipeline = this.redis.pipeline();
95
+ for (const sid of sessionIds) {
96
+ sessionPipeline.get(`${SESSION_PREFIX}${sid}`);
97
+ }
98
+ const sessionResults = await sessionPipeline.exec();
99
+
100
+ const sessions = new Map<string, SessionMetadata>();
101
+ for (let i = 0; i < sessionIds.length; i++) {
102
+ const raw = sessionResults[i] as SessionMetadata | string | null;
103
+ if (!raw) {
104
+ throw new Error(
105
+ `Session "${sessionIds[i]}" not found or expired`
106
+ );
107
+ }
108
+ const session: SessionMetadata =
109
+ typeof raw === "string" ? JSON.parse(raw) : raw;
110
+ sessions.set(sessionIds[i], session);
111
+ }
112
+
113
+ // Store all events in a pipeline
114
+ const eventPipeline = this.redis.pipeline();
115
+ for (const event of events) {
116
+ const session = sessions.get(event.sessionId)!;
117
+ const now = new Date();
118
+ const eventData: EventData = {
119
+ sessionCreationTimestamp: session.createdAt,
120
+ sessionId: event.sessionId,
121
+ eventTime: now.toISOString(),
122
+ eventTimeMs: now.getTime(),
123
+ eventName: event.eventName,
124
+ featureFlags: session.featureFlags,
125
+ ...(session.metadata ? { sessionMetadata: session.metadata } : {}),
126
+ properties: event.properties,
127
+ };
128
+ const eventKey = generateEventKey();
129
+ eventPipeline.json.set(eventKey, "$", eventData as never);
130
+ }
131
+ await eventPipeline.exec();
132
+
133
+ // Track schemas for events with properties
134
+ for (const event of events) {
135
+ if (event.properties) {
136
+ await this.schemaRegistry.validateAndUpdateSchema(
137
+ event.eventName,
138
+ event.properties
139
+ );
140
+ }
141
+ if (event.eventName.startsWith("custom:")) {
142
+ await this.schemaRegistry.trackCustomEventFirstSeen(event.eventName);
143
+ }
144
+ }
145
+ }
146
+
147
+ // Convenience methods
148
+ async capturePageView(sessionId: string, path: string): Promise<void> {
149
+ await this.captureEvent({
150
+ sessionId,
151
+ eventName: "pageview",
152
+ properties: { path },
153
+ });
154
+ }
155
+
156
+ async captureClick(sessionId: string, element: string): Promise<void> {
157
+ await this.captureEvent({
158
+ sessionId,
159
+ eventName: "click",
160
+ properties: { element },
161
+ });
162
+ }
163
+
164
+ async captureError(sessionId: string, message: string): Promise<void> {
165
+ await this.captureEvent({
166
+ sessionId,
167
+ eventName: "error",
168
+ properties: { message },
169
+ });
170
+ }
171
+
172
+ async captureWarning(sessionId: string, message: string): Promise<void> {
173
+ await this.captureEvent({
174
+ sessionId,
175
+ eventName: "warning",
176
+ properties: { message },
177
+ });
178
+ }
179
+
180
+ async captureInfo(sessionId: string, message: string): Promise<void> {
181
+ await this.captureEvent({
182
+ sessionId,
183
+ eventName: "info",
184
+ properties: { message },
185
+ });
186
+ }
187
+ }
@@ -0,0 +1,125 @@
1
+ import type { Redis } from "@upstash/redis";
2
+ import type {
3
+ AnalyticsConfig,
4
+ FeatureFlagAssignment,
5
+ FeatureFlagConfig,
6
+ FeatureFlagDefinition,
7
+ FeatureFlagDefinitions,
8
+ } from "../types";
9
+ import { LoggingService } from "./logging";
10
+ import { selectByDistribution } from "../utils";
11
+
12
+ const FEATURE_FLAGS_KEY = "analytics:feature-flags";
13
+
14
+ export class FeatureFlagService {
15
+ constructor(
16
+ private redis: Redis,
17
+ private config: AnalyticsConfig,
18
+ private logger: LoggingService
19
+ ) {}
20
+
21
+ async getDefinitions(): Promise<FeatureFlagDefinitions> {
22
+ const stored = await this.redis.get<FeatureFlagDefinitions>(
23
+ FEATURE_FLAGS_KEY
24
+ );
25
+ return stored ?? this.config.featureFlags;
26
+ }
27
+
28
+ async defineFlag(flagConfig: FeatureFlagConfig): Promise<void> {
29
+ const definitions = await this.getDefinitions();
30
+ const before = definitions[flagConfig.name];
31
+
32
+ const { name, ...definition } = flagConfig;
33
+ definitions[name] = definition;
34
+
35
+ await this.redis.set(FEATURE_FLAGS_KEY, JSON.stringify(definitions));
36
+
37
+ await this.logger.log({
38
+ logType: "feature_flag_update",
39
+ eventName: name,
40
+ changes: {
41
+ before: before ?? null,
42
+ after: definition,
43
+ },
44
+ });
45
+ }
46
+
47
+ async updateFlag(
48
+ name: string,
49
+ update: Partial<FeatureFlagDefinition>
50
+ ): Promise<void> {
51
+ const definitions = await this.getDefinitions();
52
+ const existing = definitions[name];
53
+
54
+ if (!existing) {
55
+ throw new Error(`Feature flag "${name}" does not exist`);
56
+ }
57
+
58
+ const before = { ...existing };
59
+ definitions[name] = { ...existing, ...update };
60
+
61
+ await this.redis.set(FEATURE_FLAGS_KEY, JSON.stringify(definitions));
62
+
63
+ await this.logger.log({
64
+ logType: "feature_flag_update",
65
+ eventName: name,
66
+ changes: { before, after: definitions[name] },
67
+ });
68
+ }
69
+
70
+ async resolveFlags(
71
+ assignments?: Record<string, FeatureFlagAssignment>
72
+ ): Promise<Record<string, string>> {
73
+ const definitions = await this.getDefinitions();
74
+ const resolved: Record<string, string> = {};
75
+
76
+ for (const [flagName, definition] of Object.entries(definitions)) {
77
+ const assignment = assignments?.[flagName];
78
+
79
+ if (assignment === undefined) {
80
+ // Use default value
81
+ resolved[flagName] = definition.defaultValue;
82
+ } else if (typeof assignment === "string") {
83
+ // Explicit value - validate
84
+ if (!definition.possibleValues.includes(assignment)) {
85
+ throw new Error(
86
+ `Invalid value "${assignment}" for feature flag "${flagName}". ` +
87
+ `Possible values: ${definition.possibleValues.join(", ")}`
88
+ );
89
+ }
90
+ resolved[flagName] = assignment;
91
+ } else {
92
+ // Distribution-based assignment
93
+ const distributionValues = Object.keys(assignment);
94
+ for (const v of distributionValues) {
95
+ if (!definition.possibleValues.includes(v)) {
96
+ throw new Error(
97
+ `Invalid distribution value "${v}" for feature flag "${flagName}". ` +
98
+ `Possible values: ${definition.possibleValues.join(", ")}`
99
+ );
100
+ }
101
+ }
102
+ resolved[flagName] = selectByDistribution(assignment);
103
+ }
104
+ }
105
+
106
+ return resolved;
107
+ }
108
+
109
+ private validateFlagValue(
110
+ flagName: string,
111
+ value: string,
112
+ definitions: FeatureFlagDefinitions
113
+ ): void {
114
+ const definition = definitions[flagName];
115
+ if (!definition) {
116
+ throw new Error(`Feature flag "${flagName}" does not exist`);
117
+ }
118
+ if (!definition.possibleValues.includes(value)) {
119
+ throw new Error(
120
+ `Invalid value "${value}" for feature flag "${flagName}". ` +
121
+ `Possible values: ${definition.possibleValues.join(", ")}`
122
+ );
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,81 @@
1
+ import type { Redis } from "@upstash/redis";
2
+ import type { AnalyticsConfig, LogType, SystemLog } from "../types";
3
+ import { generateLogKey } from "../utils";
4
+
5
+ export class LoggingService {
6
+ constructor(
7
+ private redis: Redis,
8
+ private config: AnalyticsConfig
9
+ ) {}
10
+
11
+ async log(log: Omit<SystemLog, "timestamp">): Promise<void> {
12
+ if (!this.config.logging.enabledTypes.includes(log.logType)) {
13
+ return;
14
+ }
15
+
16
+ const key = generateLogKey();
17
+ const logEntry: SystemLog = {
18
+ ...log,
19
+ timestamp: Date.now(),
20
+ };
21
+
22
+ const retentionMs = this.config.logging.retentionDays * 24 * 60 * 60 * 1000;
23
+ const px = Math.max(retentionMs, 1000);
24
+
25
+ await this.redis.set(key, JSON.stringify(logEntry), { px });
26
+ }
27
+
28
+ async getLogs(options?: {
29
+ type?: LogType;
30
+ limit?: number;
31
+ offset?: number;
32
+ }): Promise<SystemLog[]> {
33
+ const limit = options?.limit ?? 50;
34
+ const offset = options?.offset ?? 0;
35
+
36
+ // Scan for log keys
37
+ const keys: string[] = [];
38
+ let cursor = 0;
39
+ const maxScan = 1000; // safety limit
40
+ let scanned = 0;
41
+
42
+ do {
43
+ const [nextCursor, batch] = await this.redis.scan(cursor, {
44
+ match: "system-log-*",
45
+ count: 100,
46
+ });
47
+ cursor = typeof nextCursor === "string" ? parseInt(nextCursor) : nextCursor;
48
+ keys.push(...batch);
49
+ scanned += batch.length;
50
+ } while (cursor !== 0 && scanned < maxScan);
51
+
52
+ if (keys.length === 0) return [];
53
+
54
+ // Sort by key (timestamp is embedded in key)
55
+ keys.sort().reverse();
56
+
57
+ // Paginate
58
+ const paginatedKeys = keys.slice(offset, offset + limit);
59
+ if (paginatedKeys.length === 0) return [];
60
+
61
+ // Fetch logs
62
+ const pipeline = this.redis.pipeline();
63
+ for (const key of paginatedKeys) {
64
+ pipeline.get(key);
65
+ }
66
+ const results = await pipeline.exec<(string | null)[]>();
67
+
68
+ const logs: SystemLog[] = [];
69
+ for (const result of results) {
70
+ if (result) {
71
+ const log: SystemLog =
72
+ typeof result === "string" ? JSON.parse(result) : (result as SystemLog);
73
+ if (!options?.type || log.logType === options.type) {
74
+ logs.push(log);
75
+ }
76
+ }
77
+ }
78
+
79
+ return logs;
80
+ }
81
+ }
@@ -0,0 +1,125 @@
1
+ import type { Redis } from "@upstash/redis";
2
+ import type { AnalyticsConfig, EventSchema, SchemaProperty } from "../types";
3
+ import { LoggingService } from "./logging";
4
+ import { inferPropertyType } from "../utils";
5
+
6
+ const SCHEMA_REGISTRY_KEY = "analytics:schema-registry";
7
+ const CUSTOM_EVENT_FIRST_SEEN_KEY = "analytics:custom-events-first-seen";
8
+
9
+ export class SchemaRegistry {
10
+ constructor(
11
+ private redis: Redis,
12
+ private config: AnalyticsConfig,
13
+ private logger: LoggingService
14
+ ) {}
15
+
16
+ async getSchema(eventName: string): Promise<EventSchema | null> {
17
+ const data = await this.redis.hget<EventSchema | string>(
18
+ SCHEMA_REGISTRY_KEY,
19
+ eventName
20
+ );
21
+ if (!data) return null;
22
+ return typeof data === "string" ? JSON.parse(data) : data;
23
+ }
24
+
25
+ async getAllSchemas(): Promise<Record<string, EventSchema>> {
26
+ const data = await this.redis.hgetall<
27
+ Record<string, EventSchema | string>
28
+ >(SCHEMA_REGISTRY_KEY);
29
+ if (!data) return {};
30
+
31
+ const result: Record<string, EventSchema> = {};
32
+ for (const [key, value] of Object.entries(data)) {
33
+ result[key] = typeof value === "string" ? JSON.parse(value) : value;
34
+ }
35
+ return result;
36
+ }
37
+
38
+ async trackCustomEventFirstSeen(eventName: string): Promise<void> {
39
+ if (!this.shouldValidate()) return;
40
+
41
+ const exists = await this.redis.hexists(
42
+ CUSTOM_EVENT_FIRST_SEEN_KEY,
43
+ eventName
44
+ );
45
+ if (!exists) {
46
+ await this.redis.hset(CUSTOM_EVENT_FIRST_SEEN_KEY, {
47
+ [eventName]: Date.now(),
48
+ });
49
+ }
50
+ }
51
+
52
+ async getCustomEventFirstSeen(): Promise<Record<string, number>> {
53
+ const data = await this.redis.hgetall<Record<string, number>>(
54
+ CUSTOM_EVENT_FIRST_SEEN_KEY
55
+ );
56
+ return data ?? {};
57
+ }
58
+
59
+ shouldValidate(): boolean {
60
+ const freq = this.config.schemaValidation.checkFrequency;
61
+ if (freq >= 1) return true;
62
+ if (freq <= 0) return false;
63
+ return Math.random() < freq;
64
+ }
65
+
66
+ async validateAndUpdateSchema(
67
+ eventName: string,
68
+ properties: Record<string, unknown>
69
+ ): Promise<void> {
70
+ if (!this.shouldValidate()) return;
71
+
72
+ const currentSchema = await this.getSchema(eventName);
73
+ const inferredProperties: Record<string, SchemaProperty> = {};
74
+
75
+ for (const [key, value] of Object.entries(properties)) {
76
+ inferredProperties[key] = { type: inferPropertyType(value) };
77
+ }
78
+
79
+ const newSchema: EventSchema = {
80
+ properties: inferredProperties,
81
+ lastUpdated: Date.now(),
82
+ version: currentSchema ? currentSchema.version + 1 : 1,
83
+ };
84
+
85
+ // Check if schema has changed
86
+ if (currentSchema) {
87
+ const hasChanged = !schemasEqual(
88
+ currentSchema.properties,
89
+ inferredProperties
90
+ );
91
+ if (!hasChanged) return;
92
+
93
+ // Log schema change
94
+ await this.logger.log({
95
+ logType: "schema_update",
96
+ eventName,
97
+ changes: {
98
+ before: currentSchema,
99
+ after: newSchema,
100
+ },
101
+ });
102
+ }
103
+
104
+ await this.redis.hset(SCHEMA_REGISTRY_KEY, {
105
+ [eventName]: JSON.stringify(newSchema),
106
+ });
107
+ }
108
+ }
109
+
110
+ function schemasEqual(
111
+ a: Record<string, SchemaProperty>,
112
+ b: Record<string, SchemaProperty>
113
+ ): boolean {
114
+ const keysA = Object.keys(a).sort();
115
+ const keysB = Object.keys(b).sort();
116
+
117
+ if (keysA.length !== keysB.length) return false;
118
+
119
+ for (let i = 0; i < keysA.length; i++) {
120
+ if (keysA[i] !== keysB[i]) return false;
121
+ if (a[keysA[i]].type !== b[keysB[i]].type) return false;
122
+ }
123
+
124
+ return true;
125
+ }