@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
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
|
+
}
|