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