@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.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/backend-client.d.ts +38 -0
- package/dist/backend-client.js +189 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +157 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +20 -0
- package/dist/protocol.d.ts +45 -0
- package/dist/protocol.js +4 -0
- package/dist/react.d.ts +33 -0
- package/dist/react.js +95 -0
- package/dist/services/events.d.ts +26 -0
- package/dist/services/events.js +143 -0
- package/dist/services/feature-flags.d.ts +14 -0
- package/dist/services/feature-flags.js +88 -0
- package/dist/services/logging.d.ts +13 -0
- package/dist/services/logging.js +66 -0
- package/dist/services/schema-registry.d.ts +15 -0
- package/dist/services/schema-registry.js +97 -0
- package/dist/services/search-index.d.ts +35 -0
- package/dist/services/search-index.js +293 -0
- package/dist/services/sessions.d.ts +18 -0
- package/dist/services/sessions.js +58 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +2 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +44 -0
- package/package.json +36 -0
- package/src/backend-client.ts +301 -0
- package/src/client.ts +245 -0
- package/src/index.ts +39 -0
- package/src/protocol.ts +57 -0
- package/src/react.ts +163 -0
- package/src/services/events.ts +187 -0
- package/src/services/feature-flags.ts +125 -0
- package/src/services/logging.ts +81 -0
- package/src/services/schema-registry.ts +125 -0
- package/src/services/search-index.ts +335 -0
- package/src/services/sessions.ts +86 -0
- package/src/types.ts +194 -0
- 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
|
+
};
|
package/dist/protocol.js
ADDED
package/dist/react.d.ts
ADDED
|
@@ -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
|
+
}
|