* fix: ignore private ips * fix: performance related fixes * fix: simply event buffer * fix: default to 1 events queue shard * add: cleanup scripts * fix: comments * fix comments * fix * fix: groupmq * wip * fix: sync cachable * remove cluster names and add it behind env flag (if someone want to scale) * fix * wip * better logger * remove reqid and user agent * fix lock * remove wait_for_async_insert
525 lines
14 KiB
TypeScript
525 lines
14 KiB
TypeScript
import { getRedisCache } from '@openpanel/redis';
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ch } from '../clickhouse/client';
|
|
|
|
// Mock transformEvent to avoid circular dependency with buffers -> services -> buffers
|
|
vi.mock('../services/event.service', () => ({
|
|
transformEvent: (event: any) => ({
|
|
id: event.id ?? 'id',
|
|
name: event.name,
|
|
deviceId: event.device_id,
|
|
profileId: event.profile_id,
|
|
projectId: event.project_id,
|
|
sessionId: event.session_id,
|
|
properties: event.properties ?? {},
|
|
createdAt: new Date(event.created_at ?? Date.now()),
|
|
country: event.country,
|
|
city: event.city,
|
|
region: event.region,
|
|
longitude: event.longitude,
|
|
latitude: event.latitude,
|
|
os: event.os,
|
|
osVersion: event.os_version,
|
|
browser: event.browser,
|
|
browserVersion: event.browser_version,
|
|
device: event.device,
|
|
brand: event.brand,
|
|
model: event.model,
|
|
duration: event.duration ?? 0,
|
|
path: event.path ?? '',
|
|
origin: event.origin ?? '',
|
|
referrer: event.referrer,
|
|
referrerName: event.referrer_name,
|
|
referrerType: event.referrer_type,
|
|
meta: event.meta,
|
|
importedAt: undefined,
|
|
sdkName: event.sdk_name,
|
|
sdkVersion: event.sdk_version,
|
|
profile: event.profile,
|
|
}),
|
|
}));
|
|
|
|
import { EventBuffer } from './event-buffer';
|
|
|
|
const redis = getRedisCache();
|
|
|
|
beforeEach(async () => {
|
|
await redis.flushdb();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await redis.quit();
|
|
} catch {}
|
|
});
|
|
|
|
describe('EventBuffer', () => {
|
|
let eventBuffer: EventBuffer;
|
|
|
|
beforeEach(() => {
|
|
eventBuffer = new EventBuffer();
|
|
});
|
|
|
|
it('adds regular event directly to buffer queue', async () => {
|
|
const event = {
|
|
project_id: 'p1',
|
|
profile_id: 'u1',
|
|
name: 'custom_event',
|
|
created_at: new Date().toISOString(),
|
|
} as any;
|
|
|
|
// Get initial count
|
|
const initialCount = await eventBuffer.getBufferSize();
|
|
|
|
// Add event
|
|
await eventBuffer.add(event);
|
|
|
|
// Buffer counter should increase by 1
|
|
const newCount = await eventBuffer.getBufferSize();
|
|
expect(newCount).toBe(initialCount + 1);
|
|
});
|
|
|
|
it('adds multiple screen_views - moves previous to buffer with duration', async () => {
|
|
const t0 = Date.now();
|
|
const sessionId = 'session_1';
|
|
|
|
const view1 = {
|
|
project_id: 'p1',
|
|
profile_id: 'u1',
|
|
session_id: sessionId,
|
|
name: 'screen_view',
|
|
created_at: new Date(t0).toISOString(),
|
|
} as any;
|
|
|
|
const view2 = {
|
|
project_id: 'p1',
|
|
profile_id: 'u1',
|
|
session_id: sessionId,
|
|
name: 'screen_view',
|
|
created_at: new Date(t0 + 1000).toISOString(),
|
|
} as any;
|
|
|
|
const view3 = {
|
|
project_id: 'p1',
|
|
profile_id: 'u1',
|
|
session_id: sessionId,
|
|
name: 'screen_view',
|
|
created_at: new Date(t0 + 3000).toISOString(),
|
|
} as any;
|
|
|
|
// Add first screen_view
|
|
const count1 = await eventBuffer.getBufferSize();
|
|
await eventBuffer.add(view1);
|
|
|
|
// Should be stored as "last" but NOT in queue yet
|
|
const count2 = await eventBuffer.getBufferSize();
|
|
expect(count2).toBe(count1); // No change in buffer
|
|
|
|
// Last screen_view should be retrievable
|
|
const last1 = await eventBuffer.getLastScreenView({
|
|
projectId: 'p1',
|
|
sessionId: sessionId,
|
|
});
|
|
expect(last1).not.toBeNull();
|
|
expect(last1!.createdAt.toISOString()).toBe(view1.created_at);
|
|
|
|
// Add second screen_view
|
|
await eventBuffer.add(view2);
|
|
|
|
// Now view1 should be in buffer
|
|
const count3 = await eventBuffer.getBufferSize();
|
|
expect(count3).toBe(count1 + 1);
|
|
|
|
// view2 should now be the "last"
|
|
const last2 = await eventBuffer.getLastScreenView({
|
|
projectId: 'p1',
|
|
sessionId: sessionId,
|
|
});
|
|
expect(last2!.createdAt.toISOString()).toBe(view2.created_at);
|
|
|
|
// Add third screen_view
|
|
await eventBuffer.add(view3);
|
|
|
|
// Now view2 should also be in buffer
|
|
const count4 = await eventBuffer.getBufferSize();
|
|
expect(count4).toBe(count1 + 2);
|
|
|
|
// view3 should now be the "last"
|
|
const last3 = await eventBuffer.getLastScreenView({
|
|
projectId: 'p1',
|
|
sessionId: sessionId,
|
|
});
|
|
expect(last3!.createdAt.toISOString()).toBe(view3.created_at);
|
|
});
|
|
|
|
it('adds session_end - moves last screen_view and session_end to buffer', async () => {
|
|
const t0 = Date.now();
|
|
const sessionId = 'session_2';
|
|
|
|
const view = {
|
|
project_id: 'p2',
|
|
profile_id: 'u2',
|
|
session_id: sessionId,
|
|
name: 'screen_view',
|
|
created_at: new Date(t0).toISOString(),
|
|
} as any;
|
|
|
|
const sessionEnd = {
|
|
project_id: 'p2',
|
|
profile_id: 'u2',
|
|
session_id: sessionId,
|
|
name: 'session_end',
|
|
created_at: new Date(t0 + 5000).toISOString(),
|
|
} as any;
|
|
|
|
// Add screen_view
|
|
const count1 = await eventBuffer.getBufferSize();
|
|
await eventBuffer.add(view);
|
|
|
|
// Should be stored as "last", not in buffer yet
|
|
const count2 = await eventBuffer.getBufferSize();
|
|
expect(count2).toBe(count1);
|
|
|
|
// Add session_end
|
|
await eventBuffer.add(sessionEnd);
|
|
|
|
// Both should now be in buffer (+2)
|
|
const count3 = await eventBuffer.getBufferSize();
|
|
expect(count3).toBe(count1 + 2);
|
|
|
|
// Last screen_view should be cleared
|
|
const last = await eventBuffer.getLastScreenView({
|
|
projectId: 'p2',
|
|
sessionId: sessionId,
|
|
});
|
|
expect(last).toBeNull();
|
|
});
|
|
|
|
it('session_end with no previous screen_view - only adds session_end to buffer', async () => {
|
|
const sessionId = 'session_3';
|
|
|
|
const sessionEnd = {
|
|
project_id: 'p3',
|
|
profile_id: 'u3',
|
|
session_id: sessionId,
|
|
name: 'session_end',
|
|
created_at: new Date().toISOString(),
|
|
} as any;
|
|
|
|
const count1 = await eventBuffer.getBufferSize();
|
|
await eventBuffer.add(sessionEnd);
|
|
|
|
// Only session_end should be in buffer (+1)
|
|
const count2 = await eventBuffer.getBufferSize();
|
|
expect(count2).toBe(count1 + 1);
|
|
});
|
|
|
|
it('gets last screen_view by profileId', async () => {
|
|
const view = {
|
|
project_id: 'p4',
|
|
profile_id: 'u4',
|
|
session_id: 'session_4',
|
|
name: 'screen_view',
|
|
path: '/home',
|
|
created_at: new Date().toISOString(),
|
|
} as any;
|
|
|
|
await eventBuffer.add(view);
|
|
|
|
// Query by profileId
|
|
const result = await eventBuffer.getLastScreenView({
|
|
projectId: 'p4',
|
|
profileId: 'u4',
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.name).toBe('screen_view');
|
|
expect(result!.path).toBe('/home');
|
|
});
|
|
|
|
it('gets last screen_view by sessionId', async () => {
|
|
const sessionId = 'session_5';
|
|
const view = {
|
|
project_id: 'p5',
|
|
profile_id: 'u5',
|
|
session_id: sessionId,
|
|
name: 'screen_view',
|
|
path: '/about',
|
|
created_at: new Date().toISOString(),
|
|
} as any;
|
|
|
|
await eventBuffer.add(view);
|
|
|
|
// Query by sessionId
|
|
const result = await eventBuffer.getLastScreenView({
|
|
projectId: 'p5',
|
|
sessionId: sessionId,
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.name).toBe('screen_view');
|
|
expect(result!.path).toBe('/about');
|
|
});
|
|
|
|
it('returns null for non-existent last screen_view', async () => {
|
|
const result = await eventBuffer.getLastScreenView({
|
|
projectId: 'p_nonexistent',
|
|
profileId: 'u_nonexistent',
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('gets buffer count correctly', async () => {
|
|
// Initially 0
|
|
expect(await eventBuffer.getBufferSize()).toBe(0);
|
|
|
|
// Add regular event
|
|
await eventBuffer.add({
|
|
project_id: 'p6',
|
|
name: 'event1',
|
|
created_at: new Date().toISOString(),
|
|
} as any);
|
|
|
|
expect(await eventBuffer.getBufferSize()).toBe(1);
|
|
|
|
// Add another regular event
|
|
await eventBuffer.add({
|
|
project_id: 'p6',
|
|
name: 'event2',
|
|
created_at: new Date().toISOString(),
|
|
} as any);
|
|
|
|
expect(await eventBuffer.getBufferSize()).toBe(2);
|
|
|
|
// Add screen_view (not counted until flushed)
|
|
await eventBuffer.add({
|
|
project_id: 'p6',
|
|
profile_id: 'u6',
|
|
session_id: 'session_6',
|
|
name: 'screen_view',
|
|
created_at: new Date().toISOString(),
|
|
} as any);
|
|
|
|
// Still 2 (screen_view is pending)
|
|
expect(await eventBuffer.getBufferSize()).toBe(2);
|
|
|
|
// Add another screen_view (first one gets flushed)
|
|
await eventBuffer.add({
|
|
project_id: 'p6',
|
|
profile_id: 'u6',
|
|
session_id: 'session_6',
|
|
name: 'screen_view',
|
|
created_at: new Date(Date.now() + 1000).toISOString(),
|
|
} as any);
|
|
|
|
// Now 3 (2 regular + 1 flushed screen_view)
|
|
expect(await eventBuffer.getBufferSize()).toBe(3);
|
|
});
|
|
|
|
it('processes buffer and inserts events into ClickHouse', async () => {
|
|
const event1 = {
|
|
project_id: 'p7',
|
|
name: 'event1',
|
|
created_at: new Date(Date.now()).toISOString(),
|
|
} as any;
|
|
|
|
const event2 = {
|
|
project_id: 'p7',
|
|
name: 'event2',
|
|
created_at: new Date(Date.now() + 1000).toISOString(),
|
|
} as any;
|
|
|
|
await eventBuffer.add(event1);
|
|
await eventBuffer.add(event2);
|
|
|
|
expect(await eventBuffer.getBufferSize()).toBe(2);
|
|
|
|
const insertSpy = vi
|
|
.spyOn(ch, 'insert')
|
|
.mockResolvedValueOnce(undefined as any);
|
|
|
|
await eventBuffer.processBuffer();
|
|
|
|
// Should insert both events
|
|
expect(insertSpy).toHaveBeenCalled();
|
|
const callArgs = insertSpy.mock.calls[0]![0];
|
|
expect(callArgs.format).toBe('JSONEachRow');
|
|
expect(callArgs.table).toBe('events');
|
|
expect(Array.isArray(callArgs.values)).toBe(true);
|
|
|
|
// Buffer should be empty after processing
|
|
expect(await eventBuffer.getBufferSize()).toBe(0);
|
|
|
|
insertSpy.mockRestore();
|
|
});
|
|
|
|
it('processes buffer with chunking', async () => {
|
|
const prev = process.env.EVENT_BUFFER_CHUNK_SIZE;
|
|
process.env.EVENT_BUFFER_CHUNK_SIZE = '2';
|
|
const eb = new EventBuffer();
|
|
|
|
// Add 4 events
|
|
for (let i = 0; i < 4; i++) {
|
|
await eb.add({
|
|
project_id: 'p8',
|
|
name: `event${i}`,
|
|
created_at: new Date(Date.now() + i).toISOString(),
|
|
} as any);
|
|
}
|
|
|
|
const insertSpy = vi
|
|
.spyOn(ch, 'insert')
|
|
.mockResolvedValue(undefined as any);
|
|
|
|
await eb.processBuffer();
|
|
|
|
// With chunk size 2 and 4 events, should be called twice
|
|
expect(insertSpy).toHaveBeenCalledTimes(2);
|
|
const call1Values = insertSpy.mock.calls[0]![0].values as any[];
|
|
const call2Values = insertSpy.mock.calls[1]![0].values as any[];
|
|
expect(call1Values.length).toBe(2);
|
|
expect(call2Values.length).toBe(2);
|
|
|
|
// Restore
|
|
if (prev === undefined) delete process.env.EVENT_BUFFER_CHUNK_SIZE;
|
|
else process.env.EVENT_BUFFER_CHUNK_SIZE = prev;
|
|
|
|
insertSpy.mockRestore();
|
|
});
|
|
|
|
it('tracks active visitors', async () => {
|
|
const event = {
|
|
project_id: 'p9',
|
|
profile_id: 'u9',
|
|
name: 'custom',
|
|
created_at: new Date().toISOString(),
|
|
} as any;
|
|
|
|
await eventBuffer.add(event);
|
|
|
|
const count = await eventBuffer.getActiveVisitorCount('p9');
|
|
expect(count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('handles multiple sessions independently', async () => {
|
|
const t0 = Date.now();
|
|
|
|
// Session 1
|
|
const view1a = {
|
|
project_id: 'p10',
|
|
profile_id: 'u10',
|
|
session_id: 'session_10a',
|
|
name: 'screen_view',
|
|
created_at: new Date(t0).toISOString(),
|
|
} as any;
|
|
|
|
const view1b = {
|
|
project_id: 'p10',
|
|
profile_id: 'u10',
|
|
session_id: 'session_10a',
|
|
name: 'screen_view',
|
|
created_at: new Date(t0 + 1000).toISOString(),
|
|
} as any;
|
|
|
|
// Session 2
|
|
const view2a = {
|
|
project_id: 'p10',
|
|
profile_id: 'u11',
|
|
session_id: 'session_10b',
|
|
name: 'screen_view',
|
|
created_at: new Date(t0).toISOString(),
|
|
} as any;
|
|
|
|
const view2b = {
|
|
project_id: 'p10',
|
|
profile_id: 'u11',
|
|
session_id: 'session_10b',
|
|
name: 'screen_view',
|
|
created_at: new Date(t0 + 2000).toISOString(),
|
|
} as any;
|
|
|
|
await eventBuffer.add(view1a);
|
|
await eventBuffer.add(view2a);
|
|
await eventBuffer.add(view1b); // Flushes view1a
|
|
await eventBuffer.add(view2b); // Flushes view2a
|
|
|
|
// Should have 2 events in buffer (one from each session)
|
|
expect(await eventBuffer.getBufferSize()).toBe(2);
|
|
|
|
// Each session should have its own "last" screen_view
|
|
const last1 = await eventBuffer.getLastScreenView({
|
|
projectId: 'p10',
|
|
sessionId: 'session_10a',
|
|
});
|
|
expect(last1!.createdAt.toISOString()).toBe(view1b.created_at);
|
|
|
|
const last2 = await eventBuffer.getLastScreenView({
|
|
projectId: 'p10',
|
|
sessionId: 'session_10b',
|
|
});
|
|
expect(last2!.createdAt.toISOString()).toBe(view2b.created_at);
|
|
});
|
|
|
|
it('screen_view without session_id goes directly to buffer', async () => {
|
|
const view = {
|
|
project_id: 'p11',
|
|
profile_id: 'u11',
|
|
name: 'screen_view',
|
|
created_at: new Date().toISOString(),
|
|
} as any;
|
|
|
|
const count1 = await eventBuffer.getBufferSize();
|
|
await eventBuffer.add(view);
|
|
|
|
// Should go directly to buffer (no session_id)
|
|
const count2 = await eventBuffer.getBufferSize();
|
|
expect(count2).toBe(count1 + 1);
|
|
});
|
|
|
|
it('updates last screen_view when new one arrives from same profile but different session', async () => {
|
|
const t0 = Date.now();
|
|
|
|
const view1 = {
|
|
project_id: 'p12',
|
|
profile_id: 'u12',
|
|
session_id: 'session_12a',
|
|
name: 'screen_view',
|
|
path: '/page1',
|
|
created_at: new Date(t0).toISOString(),
|
|
} as any;
|
|
|
|
const view2 = {
|
|
project_id: 'p12',
|
|
profile_id: 'u12',
|
|
session_id: 'session_12b', // Different session!
|
|
name: 'screen_view',
|
|
path: '/page2',
|
|
created_at: new Date(t0 + 1000).toISOString(),
|
|
} as any;
|
|
|
|
await eventBuffer.add(view1);
|
|
await eventBuffer.add(view2);
|
|
|
|
// Both sessions should have their own "last"
|
|
const lastSession1 = await eventBuffer.getLastScreenView({
|
|
projectId: 'p12',
|
|
sessionId: 'session_12a',
|
|
});
|
|
expect(lastSession1!.path).toBe('/page1');
|
|
|
|
const lastSession2 = await eventBuffer.getLastScreenView({
|
|
projectId: 'p12',
|
|
sessionId: 'session_12b',
|
|
});
|
|
expect(lastSession2!.path).toBe('/page2');
|
|
|
|
// Profile should have the latest one
|
|
const lastProfile = await eventBuffer.getLastScreenView({
|
|
projectId: 'p12',
|
|
profileId: 'u12',
|
|
});
|
|
expect(lastProfile!.path).toBe('/page2');
|
|
});
|
|
});
|