fix: overall perf improvements

* 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
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-15 22:13:59 +01:00
committed by GitHub
parent 38cc53890a
commit da59622dce
66 changed files with 5042 additions and 3860 deletions

View File

@@ -16,6 +16,7 @@
"dependencies": {
"@openpanel/constants": "workspace:*",
"date-fns": "^3.3.1",
"lru-cache": "^11.2.2",
"luxon": "^3.6.1",
"mathjs": "^12.3.2",
"nanoid": "^5.0.7",

View File

@@ -20,6 +20,14 @@ export const DEFAULT_HEADER_ORDER = [
'forwarded',
];
function isPublicIp(ip: string): boolean {
return (
!ip.startsWith('10.') &&
!ip.startsWith('172.16.') &&
!ip.startsWith('192.168.')
);
}
function getHeaderOrder(): string[] {
if (typeof process !== 'undefined' && process.env?.IP_HEADER_ORDER) {
return process.env.IP_HEADER_ORDER.split(',').map((h) => h.trim());
@@ -31,7 +39,7 @@ function isValidIp(ip: string): boolean {
// Basic IP validation
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
return ipv4.test(ip) || ipv6.test(ip);
return isPublicIp(ip) && (ipv4.test(ip) || ipv6.test(ip));
}
export function getClientIpFromHeaders(

View File

@@ -1,3 +1,4 @@
import { LRUCache } from 'lru-cache';
import { UAParser } from 'ua-parser-js';
const parsedServerUa = {
@@ -11,8 +12,30 @@ const parsedServerUa = {
model: '',
} as const;
// Pre-compile all regex patterns for better performance
const IPHONE_MODEL_REGEX = /(iPhone|iPad)\s*([0-9,]+)/i;
const IOS_MODEL_REGEX = /(iOS)\s*([0-9\.]+)/i;
const IPAD_OS_VERSION_REGEX = /iPadOS\s*([0-9_]+)/i;
const SINGLE_NAME_VERSION_REGEX = /^[^\/]+\/[\d.]+$/;
// Device detection regexes
const SAMSUNG_MOBILE_REGEX = /SM-[ABDEFGJMNRWZ][0-9]+/i;
const SAMSUNG_TABLET_REGEX = /SM-T[0-9]+/i;
const LG_MOBILE_REGEX = /LG-[A-Z0-9]+/i;
const MOBILE_REGEX_1 =
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i;
const MOBILE_REGEX_2 =
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i;
const TABLET_REGEX = /tablet|ipad|xoom|sch-i800|kindle|silk|playbook/i;
const ANDROID_REGEX = /android/i;
const MOBILE_KEYWORD_REGEX = /mobile/i;
// Cache for parsed results - stores up to 1000 unique user agents
const parseCache = new LRUCache<string, UAParser.IResult>({
ttl: 1000 * 60 * 5,
ttlAutopurge: true,
max: 1000,
});
const isIphone = (ua: string) => {
const model = ua.match(IPHONE_MODEL_REGEX);
@@ -27,6 +50,12 @@ const isIphone = (ua: string) => {
};
const parse = (ua: string): UAParser.IResult => {
// Check cache first
const cached = parseCache.get(ua);
if (cached) {
return cached;
}
const parser = new UAParser(ua);
const res = parser.getResult();
@@ -35,7 +64,7 @@ const parse = (ua: string): UAParser.IResult => {
if (!res.device.model && !res.os.name) {
const iphone = isIphone(ua);
if (iphone) {
return {
const result = {
...res,
device: {
...res.device,
@@ -48,27 +77,34 @@ const parse = (ua: string): UAParser.IResult => {
version: iphone.osVersion,
},
};
parseCache.set(ua, result);
return result;
}
}
// Mozilla/5.0 (iPad; iPadOS 18_0; like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/18.0
if (res.device.model === 'iPad' && !res.os.version) {
const osVersion = ua.match(/iPadOS\s*([0-9_]+)/i);
const osVersion = ua.match(IPAD_OS_VERSION_REGEX);
if (osVersion) {
return {
const result = {
...res,
os: {
...res.os,
version: osVersion[1]!.replace('_', '.'),
version: osVersion[1]!.replace(/_/g, '.'),
},
};
parseCache.set(ua, result);
return result;
}
}
// Cache the result
parseCache.set(ua, res);
return res;
};
export type UserAgentInfo = ReturnType<typeof parseUserAgent>;
export type UserAgentResult = ReturnType<typeof parseUserAgent>;
export function parseUserAgent(
ua?: string | null,
overrides?: Record<string, unknown>,
@@ -117,8 +153,7 @@ export function parseUserAgent(
function isServer(res: UAParser.IResult) {
// Matches user agents like "Go-http-client/1.0" or "Go Http Client/1.0"
// It should just match the first name (with optional spaces) and version
const isSingleNameWithVersion = !!res.ua.match(/^[^\/]+\/[\d.]+$/);
if (isSingleNameWithVersion) {
if (SINGLE_NAME_VERSION_REGEX.test(res.ua)) {
return true;
}
@@ -133,39 +168,39 @@ function isServer(res: UAParser.IResult) {
export function getDevice(ua: string) {
// Samsung mobile devices use SM-[A,G,N,etc]XXX pattern
if (/SM-[ABDEFGJMNRWZ][0-9]+/i.test(ua)) {
const isSamsungMobile = SAMSUNG_MOBILE_REGEX.test(ua);
if (isSamsungMobile) {
return 'mobile';
}
// Samsung tablets use SM-TXXX pattern
if (/SM-T[0-9]+/i.test(ua)) {
if (SAMSUNG_TABLET_REGEX.test(ua)) {
return 'tablet';
}
// LG mobile devices use LG-XXXX pattern
if (/LG-[A-Z0-9]+/i.test(ua)) {
const isLGMobile = LG_MOBILE_REGEX.test(ua);
if (isLGMobile) {
return 'mobile';
}
const mobile1 =
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
ua,
);
const mobile2 =
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(
ua.slice(0, 4),
);
const tablet =
/tablet|ipad|xoom|sch-i800|kindle|silk|playbook/i.test(ua) ||
(/android/i.test(ua) &&
!/mobile/i.test(ua) &&
!/SM-[ABDEFGJMNRWZ][0-9]+/i.test(ua) &&
!/LG-[A-Z0-9]+/i.test(ua));
// Check for mobile patterns
const mobile1 = MOBILE_REGEX_1.test(ua);
const mobile2 = MOBILE_REGEX_2.test(ua.slice(0, 4));
if (mobile1 || mobile2) {
return 'mobile';
}
// Check for tablet patterns
// Note: We already checked for Samsung mobile/tablet and LG mobile above
const isAndroid = ANDROID_REGEX.test(ua);
const hasMobileKeyword = MOBILE_KEYWORD_REGEX.test(ua);
const tablet =
TABLET_REGEX.test(ua) ||
(isAndroid && !hasMobileKeyword && !isSamsungMobile && !isLGMobile);
if (tablet) {
return 'tablet';
}

View File

@@ -8,18 +8,21 @@ export class BaseBuffer {
lockKey: string;
lockTimeout = 60;
onFlush: () => void;
enableParallelProcessing: boolean;
protected bufferCounterKey: string;
constructor(options: {
name: string;
onFlush: () => Promise<void>;
enableParallelProcessing?: boolean;
}) {
this.logger = createLogger({ name: options.name });
this.name = options.name;
this.lockKey = `lock:${this.name}`;
this.onFlush = options.onFlush;
this.bufferCounterKey = `${this.name}:buffer:count`;
this.enableParallelProcessing = options.enableParallelProcessing ?? false;
}
protected chunks<T>(items: T[], size: number) {
@@ -91,6 +94,26 @@ export class BaseBuffer {
async tryFlush() {
const now = performance.now();
// Parallel mode: No locking, multiple workers can process simultaneously
if (this.enableParallelProcessing) {
try {
this.logger.debug('Processing buffer (parallel mode)...');
await this.onFlush();
this.logger.debug('Flush completed (parallel mode)', {
elapsed: performance.now() - now,
});
} catch (error) {
this.logger.error('Failed to process buffer (parallel mode)', {
error,
});
// In parallel mode, we can't safely reset counter as other workers might be active
// Counter will be resynced automatically by the periodic job
}
return;
}
// Sequential mode: Use lock to ensure only one worker processes at a time
const lockId = generateSecureId('lock');
const acquired = await getRedisCache().set(
this.lockKey,
@@ -101,7 +124,7 @@ export class BaseBuffer {
);
if (acquired === 'OK') {
try {
this.logger.info('Acquired lock. Processing buffer...', {
this.logger.debug('Acquired lock. Processing buffer...', {
lockId,
});
await this.onFlush();
@@ -117,7 +140,7 @@ export class BaseBuffer {
}
} finally {
await this.releaseLock(lockId);
this.logger.info('Flush completed', {
this.logger.debug('Flush completed', {
elapsed: performance.now() - now,
lockId,
});

View File

@@ -71,7 +71,7 @@ export class BotBuffer extends BaseBuffer {
.decrby(this.bufferCounterKey, events.length)
.exec();
this.logger.info('Processed bot events', {
this.logger.debug('Processed bot events', {
count: events.length,
});
} catch (error) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,12 +12,12 @@ export class ProfileBuffer extends BaseBuffer {
private batchSize = process.env.PROFILE_BUFFER_BATCH_SIZE
? Number.parseInt(process.env.PROFILE_BUFFER_BATCH_SIZE, 10)
: 200;
private daysToKeep = process.env.PROFILE_BUFFER_DAYS_TO_KEEP
? Number.parseInt(process.env.PROFILE_BUFFER_DAYS_TO_KEEP, 10)
: 7;
private chunkSize = process.env.PROFILE_BUFFER_CHUNK_SIZE
? Number.parseInt(process.env.PROFILE_BUFFER_CHUNK_SIZE, 10)
: 1000;
private ttlInSeconds = process.env.PROFILE_BUFFER_TTL_IN_SECONDS
? Number.parseInt(process.env.PROFILE_BUFFER_TTL_IN_SECONDS, 10)
: 60 * 60;
private readonly redisKey = 'profile-buffer';
private readonly redisProfilePrefix = 'profile-cache:';
@@ -49,7 +49,7 @@ export class ProfileBuffer extends BaseBuffer {
profileId: profile.id,
projectId: profile.project_id,
});
return (await getRedisCache().exists(cacheKey)) === 1;
return (await this.redis.exists(cacheKey)) === 1;
}
async add(profile: IClickhouseProfile, isFromEvent = false) {
@@ -90,9 +90,6 @@ export class ProfileBuffer extends BaseBuffer {
profile,
});
const cacheTtl = profile.is_external
? 60 * 60 * 24 * this.daysToKeep
: 60 * 60; // 1 hour for internal profiles
const cacheKey = this.getProfileCacheKey({
profileId: profile.id,
projectId: profile.project_id,
@@ -100,7 +97,7 @@ export class ProfileBuffer extends BaseBuffer {
const result = await this.redis
.multi()
.set(cacheKey, JSON.stringify(mergedProfile), 'EX', cacheTtl)
.set(cacheKey, JSON.stringify(mergedProfile), 'EX', this.ttlInSeconds)
.rpush(this.redisKey, JSON.stringify(mergedProfile))
.incr(this.bufferCounterKey)
.llen(this.redisKey)
@@ -120,7 +117,6 @@ export class ProfileBuffer extends BaseBuffer {
batchSize: this.batchSize,
});
if (bufferLength >= this.batchSize) {
this.logger.info('Buffer full, initiating flush');
await this.tryFlush();
}
} catch (error) {
@@ -137,18 +133,33 @@ export class ProfileBuffer extends BaseBuffer {
projectId: profile.project_id,
});
const existingProfile = await getRedisCache().get(cacheKey);
const existingProfile = await this.fetchFromCache(
profile.id,
profile.project_id,
);
if (existingProfile) {
const parsedProfile = getSafeJson<IClickhouseProfile>(existingProfile);
if (parsedProfile) {
logger.debug('Profile found in Redis');
return parsedProfile;
}
logger.debug('Profile found in Redis');
return existingProfile;
}
return this.fetchFromClickhouse(profile, logger);
}
public async fetchFromCache(
profileId: string,
projectId: string,
): Promise<IClickhouseProfile | null> {
const cacheKey = this.getProfileCacheKey({
profileId,
projectId,
});
const existingProfile = await this.redis.get(cacheKey);
if (!existingProfile) {
return null;
}
return getSafeJson<IClickhouseProfile>(existingProfile);
}
private async fetchFromClickhouse(
profile: IClickhouseProfile,
logger: ILogger,
@@ -176,7 +187,7 @@ export class ProfileBuffer extends BaseBuffer {
async processBuffer() {
try {
this.logger.info('Starting profile buffer processing');
this.logger.debug('Starting profile buffer processing');
const profiles = await this.redis.lrange(
this.redisKey,
0,
@@ -188,7 +199,7 @@ export class ProfileBuffer extends BaseBuffer {
return;
}
this.logger.info(`Processing ${profiles.length} profiles in buffer`);
this.logger.debug(`Processing ${profiles.length} profiles in buffer`);
const parsedProfiles = profiles.map((p) =>
getSafeJson<IClickhouseProfile>(p),
);
@@ -208,7 +219,7 @@ export class ProfileBuffer extends BaseBuffer {
.decrby(this.bufferCounterKey, profiles.length)
.exec();
this.logger.info('Successfully completed profile processing', {
this.logger.debug('Successfully completed profile processing', {
totalProfiles: profiles.length,
});
} catch (error) {

View File

@@ -12,6 +12,9 @@ export class SessionBuffer extends BaseBuffer {
private batchSize = process.env.SESSION_BUFFER_BATCH_SIZE
? Number.parseInt(process.env.SESSION_BUFFER_BATCH_SIZE, 10)
: 1000;
private chunkSize = process.env.SESSION_BUFFER_CHUNK_SIZE
? Number.parseInt(process.env.SESSION_BUFFER_CHUNK_SIZE, 10)
: 1000;
private readonly redisKey = 'session-buffer';
private redis: Redis;
@@ -209,7 +212,7 @@ export class SessionBuffer extends BaseBuffer {
};
});
for (const chunk of this.chunks(sessions, 1000)) {
for (const chunk of this.chunks(sessions, this.chunkSize)) {
// Insert to ClickHouse
await ch.insert({
table: TABLE_NAMES.sessions,
@@ -225,7 +228,7 @@ export class SessionBuffer extends BaseBuffer {
.decrby(this.bufferCounterKey, events.length);
await multi.exec();
this.logger.info('Processed sessions', {
this.logger.debug('Processed sessions', {
count: events.length,
});
} catch (error) {

View File

@@ -24,10 +24,13 @@ type WarnLogParams = LogParams & { err?: Error };
class CustomLogger implements Logger {
trace({ message, args }: LogParams) {
logger.info(message, args);
logger.debug(message, args);
}
debug({ message, args }: LogParams) {
logger.info(message, args);
if (message.includes('Query:') && args?.response_status === 200) {
return;
}
logger.debug(message, args);
}
info({ message, args }: LogParams) {
logger.info(message, args);
@@ -157,8 +160,6 @@ export const ch = new Proxy(originalCh, {
return (...args: any[]) =>
withRetry(() => {
args[0].clickhouse_settings = {
// Allow bigger HTTP payloads/time to stream rows
wait_for_async_insert: 1,
// Increase insert timeouts and buffer sizes for large batches
max_execution_time: 300,
max_insert_block_size: '500000',

View File

@@ -1,4 +1,4 @@
import { cacheable } from '@openpanel/redis';
import { cacheable, cacheableLru } from '@openpanel/redis';
import type { Client, Prisma } from '../prisma-client';
import { db } from '../prisma-client';
@@ -34,4 +34,7 @@ export async function getClientById(
});
}
export const getClientByIdCached = cacheable(getClientById, 60 * 60 * 24);
export const getClientByIdCached = cacheableLru(getClientById, {
maxSize: 1000,
ttl: 60 * 5,
});

View File

@@ -19,12 +19,9 @@ import type { EventMeta, Prisma } from '../prisma-client';
import { db } from '../prisma-client';
import { type SqlBuilderObject, createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
import { getOrganizationByProjectIdCached } from './organization.service';
import type { IServiceProfile, IServiceUpsertProfile } from './profile.service';
import {
getProfileById,
getProfileByIdCached,
getProfiles,
getProfilesCached,
upsertProfile,
} from './profile.service';
@@ -156,8 +153,6 @@ export interface IServiceEvent {
properties: Record<string, unknown> & {
hash?: string;
query?: Record<string, unknown>;
__reqId?: string;
__user_agent?: string;
};
createdAt: Date;
country?: string | undefined;
@@ -343,7 +338,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
sdk_version: payload.sdkVersion ?? '',
};
await Promise.all([sessionBuffer.add(event), eventBuffer.add(event)]);
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];
if (payload.profileId) {
const profile: IServiceUpsertProfile = {
@@ -374,10 +369,12 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
profile.isExternal ||
(profile.isExternal === false && payload.name === 'session_start')
) {
await upsertProfile(profile, true);
promises.push(upsertProfile(profile, true));
}
}
await Promise.all(promises);
return {
document: event,
};
@@ -395,6 +392,7 @@ export interface GetEventListOptions {
endDate?: Date;
select?: SelectHelper<IServiceEvent>;
custom?: (sb: SqlBuilderObject) => void;
dateIntervalInDays?: number;
}
export async function getEventList(options: GetEventListOptions) {
@@ -408,28 +406,28 @@ export async function getEventList(options: GetEventListOptions) {
filters,
startDate,
endDate,
select: incomingSelect,
custom,
select: incomingSelect,
dateIntervalInDays = 0.5,
} = options;
const { sb, getSql, join } = createSqlBuilder();
const organization = await getOrganizationByProjectIdCached(projectId);
// This will speed up the query quite a lot for big organizations
const dateIntervalInDays =
organization?.subscriptionPeriodEventsLimit &&
organization?.subscriptionPeriodEventsLimit > 1_000_000
? 1
: 7;
const MAX_DATE_INTERVAL_IN_DAYS = 365;
// Cap the date interval to prevent infinity
const safeDateIntervalInDays = Math.min(
dateIntervalInDays,
MAX_DATE_INTERVAL_IN_DAYS,
);
if (typeof cursor === 'number') {
sb.offset = Math.max(0, (cursor ?? 0) * take);
} else if (cursor instanceof Date) {
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(cursor))}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(cursor))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
sb.where.cursor = `created_at <= ${sqlstring.escape(formatClickhouseDate(cursor))}`;
}
if (!cursor) {
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
}
sb.limit = take;
@@ -453,6 +451,9 @@ export async function getEventList(options: GetEventListOptions) {
incomingSelect ?? {},
);
sb.select.createdAt = 'created_at';
sb.select.projectId = 'project_id';
if (select.id) {
sb.select.id = 'id';
}
@@ -474,9 +475,6 @@ export async function getEventList(options: GetEventListOptions) {
if (select.properties) {
sb.select.properties = 'properties';
}
if (select.createdAt) {
sb.select.createdAt = 'created_at';
}
if (select.country) {
sb.select.country = 'country';
}
@@ -583,21 +581,20 @@ export async function getEventList(options: GetEventListOptions) {
custom(sb);
}
console.log('getSql()', getSql());
const data = await getEvents(getSql(), {
profile: select.profile ?? true,
meta: select.meta ?? true,
});
// If we dont get any events, try without the cursor window
if (data.length === 0 && sb.where.cursorWindow) {
if (
data.length === 0 &&
sb.where.cursorWindow &&
safeDateIntervalInDays < MAX_DATE_INTERVAL_IN_DAYS
) {
return getEventList({
...options,
custom(sb) {
options.custom?.(sb);
delete sb.where.cursorWindow;
},
dateIntervalInDays: dateIntervalInDays * 2,
});
}
@@ -945,7 +942,7 @@ class EventService {
]);
if (event?.profileId) {
const profile = await getProfileByIdCached(event?.profileId, projectId);
const profile = await getProfileById(event?.profileId, projectId);
if (profile) {
event.profile = profile;
}

View File

@@ -13,7 +13,7 @@ import type {
IServiceCreateEventPayload,
IServiceEvent,
} from './event.service';
import { getProfileById, getProfileByIdCached } from './profile.service';
import { getProfileById } from './profile.service';
import { getProjectByIdCached } from './project.service';
type ICreateNotification = Pick<
@@ -264,10 +264,7 @@ export async function checkNotificationRulesForEvent(
payload.profileId &&
rules.some((rule) => rule.template?.match(/{{profile\.[^}]*}}/))
) {
const profile = await getProfileByIdCached(
payload.profileId,
payload.projectId,
);
const profile = await getProfileById(payload.profileId, payload.projectId);
if (profile) {
(payload as any).profile = profile;
}

View File

@@ -106,6 +106,11 @@ export async function getProfileById(id: string, projectId: string) {
return null;
}
const cachedProfile = await profileBuffer.fetchFromCache(id, projectId);
if (cachedProfile) {
return transformProfile(cachedProfile);
}
const [profile] = await chQuery<IClickhouseProfile>(
`SELECT
id,
@@ -127,8 +132,6 @@ export async function getProfileById(id: string, projectId: string) {
return transformProfile(profile);
}
export const getProfileByIdCached = cacheable(getProfileById, 60 * 30);
interface GetProfileListOptions {
projectId: string;
take: number;
@@ -306,10 +309,5 @@ export async function upsertProfile(
is_external: isExternal,
};
if (!isFromEvent) {
// Save to cache directly since the profile might be used before its saved in clickhouse
getProfileByIdCached.set(id, projectId)(transformProfile(profile));
}
return profileBuffer.add(profile, isFromEvent);
}

View File

@@ -1,6 +1,6 @@
import { generateSalt } from '@openpanel/common/server';
import { getRedisCache } from '@openpanel/redis';
import { cacheableLru } from '@openpanel/redis';
import { db } from '../prisma-client';
export async function getCurrentSalt() {
@@ -17,36 +17,36 @@ export async function getCurrentSalt() {
return salt.salt;
}
export async function getSalts() {
const cache = await getRedisCache().get('op:salt');
if (cache) {
return JSON.parse(cache);
}
export const getSalts = cacheableLru(
'op:salt',
async () => {
const [curr, prev] = await db.salt.findMany({
orderBy: {
createdAt: 'desc',
},
take: 2,
});
const [curr, prev] = await db.salt.findMany({
orderBy: {
createdAt: 'desc',
},
take: 2,
});
if (!curr) {
throw new Error('No salt found');
}
if (!curr) {
throw new Error('No salt found');
}
if (!prev) {
throw new Error('No salt found');
}
if (!prev) {
throw new Error('No salt found');
}
const salts = {
current: curr.salt,
previous: prev.salt,
};
const salts = {
current: curr.salt,
previous: prev.salt,
};
await getRedisCache().set('op:salt', JSON.stringify(salts), 'EX', 60 * 10);
return salts;
}
return salts;
},
{
maxSize: 2,
ttl: 60 * 5,
},
);
export async function createInitialSalts() {
const MAX_RETRIES = 5;

View File

@@ -7,14 +7,15 @@
"codegen": "jiti scripts/download.ts"
},
"dependencies": {
"@maxmind/geoip2-node": "^6.1.0"
"@maxmind/geoip2-node": "^6.1.0",
"lru-cache": "^11.2.2"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"@types/node": "catalog:",
"fast-extract": "^1.4.3",
"jiti": "^2.4.1",
"tar": "^7.4.3",
"typescript": "catalog:",
"jiti": "^2.4.1"
"typescript": "catalog:"
}
}

View File

@@ -2,11 +2,12 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ReaderModel } from '@maxmind/geoip2-node';
import { Reader } from '@maxmind/geoip2-node';
import { LRUCache } from 'lru-cache';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import type { ReaderModel } from '@maxmind/geoip2-node';
import { Reader } from '@maxmind/geoip2-node';
const filename = 'GeoLite2-City.mmdb';
// From api or worker package
@@ -50,24 +51,37 @@ const DEFAULT_GEO: GeoLocation = {
const ignore = ['127.0.0.1', '::1'];
const cache = new LRUCache<string, GeoLocation>({
max: 1000,
ttl: 1000 * 60 * 5,
ttlAutopurge: true,
});
export async function getGeoLocation(ip?: string): Promise<GeoLocation> {
if (!ip || ignore.includes(ip)) {
return DEFAULT_GEO;
}
const cached = cache.get(ip);
if (cached) {
return cached;
}
if (!reader) {
await loadDatabase(dbPath);
}
try {
const response = await reader?.city(ip);
return {
const res = {
city: response?.city?.names.en,
country: response?.country?.isoCode,
region: response?.subdivisions?.[0]?.names.en,
longitude: response?.location?.longitude,
latitude: response?.location?.latitude,
};
cache.set(ip, res);
return res;
} catch (error) {
return DEFAULT_GEO;
}

View File

@@ -6,9 +6,12 @@ export { winston };
export type ILogger = winston.Logger;
const logLevel = process.env.LOG_LEVEL ?? 'info';
const silent = process.env.LOG_SILENT === 'true';
export function createLogger({ name }: { name: string }): ILogger {
const service = `${name}-${process.env.NODE_ENV ?? 'dev'}`;
const service = [process.env.LOG_PREFIX, name, process.env.NODE_ENV ?? 'dev']
.filter(Boolean)
.join('-');
const prettyError = (error: Error) => ({
...error,
@@ -64,13 +67,9 @@ export function createLogger({ name }: { name: string }): ILogger {
return Object.assign({}, info, redactObject(info));
});
const format = winston.format.combine(
errorFormatter(),
redactSensitiveInfo(),
winston.format.json(),
);
const transports: winston.transport[] = [];
let format: winston.Logform.Format;
const transports: winston.transport[] = [new winston.transports.Console()];
if (process.env.HYPERDX_API_KEY) {
transports.push(
HyperDX.getWinstonTransport(logLevel, {
@@ -78,6 +77,24 @@ export function createLogger({ name }: { name: string }): ILogger {
service,
}),
);
format = winston.format.combine(
errorFormatter(),
redactSensitiveInfo(),
winston.format.json(),
);
} else {
transports.push(new winston.transports.Console());
format = winston.format.combine(
errorFormatter(),
redactSensitiveInfo(),
winston.format.colorize(),
winston.format.printf((info) => {
const { level, message, service, ...meta } = info;
const metaStr =
Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
return `${level} ${message}${metaStr}`;
}),
);
}
const logger = winston.createLogger({
@@ -85,7 +102,7 @@ export function createLogger({ name }: { name: string }): ILogger {
level: logLevel,
format,
transports,
silent: process.env.NODE_ENV === 'test',
silent,
// Add ISO levels of logging from PINO
levels: Object.assign(
{ fatal: 0, warn: 4, trace: 7 },

View File

@@ -10,8 +10,8 @@
"@openpanel/db": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/redis": "workspace:*",
"bullmq": "^5.8.7",
"groupmq": "1.0.0-next.19"
"bullmq": "^5.63.0",
"groupmq": "1.1.0-next.6"
},
"devDependencies": {
"@openpanel/sdk": "workspace:*",

View File

@@ -1,5 +1,6 @@
import { Queue, QueueEvents } from 'bullmq';
import { createHash } from 'node:crypto';
import type {
IServiceCreateEventPayload,
IServiceEvent,
@@ -10,6 +11,21 @@ import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
import type { TrackPayload } from '@openpanel/sdk';
import { Queue as GroupQueue } from 'groupmq';
export const EVENTS_GROUP_QUEUES_SHARDS = Number.parseInt(
process.env.EVENTS_GROUP_QUEUES_SHARDS || '1',
10,
);
export const getQueueName = (name: string) =>
process.env.QUEUE_CLUSTER ? `{${name}}` : name;
function pickShard(projectId: string) {
const h = createHash('sha1').update(projectId).digest(); // 20 bytes
// take first 4 bytes as unsigned int
const x = h.readUInt32BE(0);
return x % EVENTS_GROUP_QUEUES_SHARDS; // 0..n-1
}
export const queueLogger = createLogger({ name: 'queue' });
export interface EventsQueuePayloadIncomingEvent {
@@ -17,9 +33,30 @@ export interface EventsQueuePayloadIncomingEvent {
payload: {
projectId: string;
event: TrackPayload & {
timestamp: string;
timestamp: string | number;
isTimestampFromThePast: boolean;
};
uaInfo:
| {
readonly isServer: true;
readonly device: 'server';
readonly os: '';
readonly osVersion: '';
readonly browser: '';
readonly browserVersion: '';
readonly brand: '';
readonly model: '';
}
| {
readonly os: string | undefined;
readonly osVersion: string | undefined;
readonly browser: string | undefined;
readonly browserVersion: string | undefined;
readonly device: string;
readonly brand: string | undefined;
readonly model: string | undefined;
readonly isServer: false;
};
geo: {
country: string | undefined;
city: string | undefined;
@@ -93,54 +130,70 @@ export type MiscQueuePayload = MiscQueuePayloadTrialEndingSoon;
export type CronQueueType = CronQueuePayload['type'];
const orderingWindowMs = Number.parseInt(
process.env.ORDERING_WINDOW_MS || '50',
10,
);
const orderingGracePeriodDecay = Number.parseFloat(
process.env.ORDERING_GRACE_PERIOD_DECAY || '0.9',
);
const orderingMaxWaitMultiplier = Number.parseInt(
process.env.ORDERING_MAX_WAIT_MULTIPLIER || '8',
const orderingDelayMs = Number.parseInt(
process.env.ORDERING_DELAY_MS || '100',
10,
);
export const eventsGroupQueue = new GroupQueue<
EventsQueuePayloadIncomingEvent['payload']
>({
logger: queueLogger,
namespace: 'group_events',
redis: getRedisGroupQueue(),
orderingMethod: 'in-memory',
orderingWindowMs,
orderingGracePeriodDecay,
orderingMaxWaitMultiplier,
keepCompleted: 10,
keepFailed: 10_000,
});
const autoBatchMaxWaitMs = Number.parseInt(
process.env.AUTO_BATCH_MAX_WAIT_MS || '0',
10,
);
const autoBatchSize = Number.parseInt(process.env.AUTO_BATCH_SIZE || '0', 10);
export const sessionsQueue = new Queue<SessionsQueuePayload>('sessions', {
// @ts-ignore
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
export const eventsGroupQueues = Array.from({
length: EVENTS_GROUP_QUEUES_SHARDS,
}).map(
(_, index, list) =>
new GroupQueue<EventsQueuePayloadIncomingEvent['payload']>({
logger: queueLogger,
namespace: getQueueName(
list.length === 1 ? 'group_events' : `group_events_${index}`,
),
redis: getRedisGroupQueue(),
keepCompleted: 1_000,
keepFailed: 10_000,
orderingDelayMs: orderingDelayMs,
autoBatch:
autoBatchMaxWaitMs && autoBatchSize
? {
maxWaitMs: autoBatchMaxWaitMs,
size: autoBatchSize,
}
: undefined,
}),
);
export const getEventsGroupQueueShard = (groupId: string) => {
const shard = pickShard(groupId);
const queue = eventsGroupQueues[shard];
if (!queue) {
throw new Error(`Queue not found for group ${groupId}`);
}
return queue;
};
export const sessionsQueue = new Queue<SessionsQueuePayload>(
getQueueName('sessions'),
{
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
},
},
});
export const sessionsQueueEvents = new QueueEvents('sessions', {
// @ts-ignore
);
export const sessionsQueueEvents = new QueueEvents(getQueueName('sessions'), {
connection: getRedisQueue(),
});
export const cronQueue = new Queue<CronQueuePayload>('cron', {
// @ts-ignore
export const cronQueue = new Queue<CronQueuePayload>(getQueueName('cron'), {
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
},
});
export const miscQueue = new Queue<MiscQueuePayload>('misc', {
// @ts-ignore
export const miscQueue = new Queue<MiscQueuePayload>(getQueueName('misc'), {
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
@@ -155,9 +208,8 @@ export type NotificationQueuePayload = {
};
export const notificationQueue = new Queue<NotificationQueuePayload>(
'notification',
getQueueName('notification'),
{
// @ts-ignore
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
@@ -172,13 +224,16 @@ export type ImportQueuePayload = {
};
};
export const importQueue = new Queue<ImportQueuePayload>('import', {
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
removeOnFail: 50,
export const importQueue = new Queue<ImportQueuePayload>(
getQueueName('import'),
{
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
removeOnFail: 50,
},
},
});
);
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
return miscQueue.add(

View File

@@ -446,12 +446,6 @@ describe('cachable', () => {
expect(cached).toBe(JSON.stringify(payload));
});
it('should throw error when function is not provided', () => {
expect(() => {
cacheable('test', 3600);
}).toThrow('fn is not a function');
});
it('should throw error when expire time is not provided', () => {
const fn = async (arg1: string, arg2: string) => ({});
expect(() => {

View File

@@ -1,17 +1,34 @@
import { LRUCache } from 'lru-cache';
import { getRedisCache } from './redis';
export const deleteCache = async (key: string) => {
return getRedisCache().del(key);
};
// Global LRU cache for getCache function
const globalLruCache = new LRUCache<string, any>({
max: 5000, // Store up to 5000 entries
ttl: 1000 * 60, // 1 minutes default TTL
});
export async function getCache<T>(
key: string,
expireInSec: number,
fn: () => Promise<T>,
useLruCache?: boolean,
): Promise<T> {
// L1 Cache: Check global LRU cache first (in-memory, instant)
if (useLruCache) {
const lruHit = globalLruCache.get(key);
if (lruHit !== undefined) {
return lruHit as T;
}
}
// L2 Cache: Check Redis cache (shared across instances)
const hit = await getRedisCache().get(key);
if (hit) {
return JSON.parse(hit, (_, value) => {
const parsed = JSON.parse(hit, (_, value) => {
if (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/.test(value)
@@ -20,13 +37,49 @@ export async function getCache<T>(
}
return value;
});
// Store in LRU cache for next time
if (useLruCache) {
globalLruCache.set(key, parsed, {
ttl: expireInSec * 1000, // Use the same TTL as Redis
});
}
return parsed;
}
// Cache miss: Execute function
const data = await fn();
await getRedisCache().setex(key, expireInSec, JSON.stringify(data));
// Store in both caches
if (useLruCache) {
globalLruCache.set(key, data, {
ttl: expireInSec * 1000,
});
}
// Fire and forget Redis write for better performance
getRedisCache().setex(key, expireInSec, JSON.stringify(data));
return data;
}
// Helper functions for managing global LRU cache
export function clearGlobalLruCache(key?: string) {
if (key) {
return globalLruCache.delete(key);
}
globalLruCache.clear();
return true;
}
export function getGlobalLruCacheStats() {
return {
size: globalLruCache.size,
max: globalLruCache.max,
calculatedSize: globalLruCache.calculatedSize,
};
}
function stringify(obj: unknown): string {
if (obj === null) return 'null';
if (obj === undefined) return 'undefined';
@@ -75,6 +128,39 @@ function hasResult(result: unknown): boolean {
return true;
}
export interface CacheableLruOptions {
/** TTL in seconds for LRU cache */
ttl: number;
/** Maximum number of entries in LRU cache */
maxSize?: number;
}
// Overload 1: cacheable(fn, expireInSec)
export function cacheable<T extends (...args: any) => any>(
fn: T,
expireInSec: number,
): T & {
getKey: (...args: Parameters<T>) => string;
clear: (...args: Parameters<T>) => Promise<number>;
set: (
...args: Parameters<T>
) => (payload: Awaited<ReturnType<T>>) => Promise<'OK'>;
};
// Overload 2: cacheable(name, fn, expireInSec)
export function cacheable<T extends (...args: any) => any>(
name: string,
fn: T,
expireInSec: number,
): T & {
getKey: (...args: Parameters<T>) => string;
clear: (...args: Parameters<T>) => Promise<number>;
set: (
...args: Parameters<T>
) => (payload: Awaited<ReturnType<T>>) => Promise<'OK'>;
};
// Implementation for cacheable (Redis-only - async)
export function cacheable<T extends (...args: any) => any>(
fnOrName: T | string,
fnOrExpireInSec: number | T,
@@ -87,12 +173,17 @@ export function cacheable<T extends (...args: any) => any>(
: typeof fnOrExpireInSec === 'function'
? fnOrExpireInSec
: null;
const expireInSec =
typeof fnOrExpireInSec === 'number'
? fnOrExpireInSec
: typeof _expireInSec === 'number'
? _expireInSec
: null;
let expireInSec: number | null = null;
// Parse parameters based on function signature
if (typeof fnOrName === 'function') {
// Overload 1: cacheable(fn, expireInSec)
expireInSec = typeof fnOrExpireInSec === 'number' ? fnOrExpireInSec : null;
} else {
// Overload 2: cacheable(name, fn, expireInSec)
expireInSec = typeof _expireInSec === 'number' ? _expireInSec : null;
}
if (typeof fn !== 'function') {
throw new Error('fn is not a function');
@@ -105,11 +196,14 @@ export function cacheable<T extends (...args: any) => any>(
const cachePrefix = `cachable:${name}`;
const getKey = (...args: Parameters<T>) =>
`${cachePrefix}:${stringify(args)}`;
// Redis-only mode: asynchronous implementation
const cachedFn = async (
...args: Parameters<T>
): Promise<Awaited<ReturnType<T>>> => {
// JSON.stringify here is not bullet proof since ordering of object keys matters etc
const key = getKey(...args);
// Check Redis cache (shared across instances)
const cached = await getRedisCache().get(key);
if (cached) {
try {
@@ -129,10 +223,15 @@ export function cacheable<T extends (...args: any) => any>(
console.error('Failed to parse cache', e);
}
}
// Cache miss: Execute function
const result = await fn(...(args as any));
if (hasResult(result)) {
getRedisCache().setex(key, expireInSec, JSON.stringify(result));
// Don't await Redis write - fire and forget for better performance
getRedisCache()
.setex(key, expireInSec, JSON.stringify(result))
.catch(() => {});
}
return result;
@@ -147,7 +246,134 @@ export function cacheable<T extends (...args: any) => any>(
(...args: Parameters<T>) =>
async (payload: Awaited<ReturnType<T>>) => {
const key = getKey(...args);
return getRedisCache().setex(key, expireInSec, JSON.stringify(payload));
return getRedisCache()
.setex(key, expireInSec, JSON.stringify(payload))
.catch(() => {});
};
return cachedFn;
}
// Overload 1: cacheableLru(fn, options)
export function cacheableLru<T extends (...args: any) => any>(
fn: T,
options: CacheableLruOptions,
): T & {
getKey: (...args: Parameters<T>) => string;
clear: (...args: Parameters<T>) => boolean;
set: (...args: Parameters<T>) => (payload: ReturnType<T>) => void;
};
// Overload 2: cacheableLru(name, fn, options)
export function cacheableLru<T extends (...args: any) => any>(
name: string,
fn: T,
options: CacheableLruOptions,
): T & {
getKey: (...args: Parameters<T>) => string;
clear: (...args: Parameters<T>) => boolean;
set: (...args: Parameters<T>) => (payload: ReturnType<T>) => void;
};
// Implementation for cacheableLru (LRU-only - synchronous)
export function cacheableLru<T extends (...args: any) => any>(
fnOrName: T | string,
fnOrOptions: T | CacheableLruOptions,
_options?: CacheableLruOptions,
) {
const name = typeof fnOrName === 'string' ? fnOrName : fnOrName.name;
const fn =
typeof fnOrName === 'function'
? fnOrName
: typeof fnOrOptions === 'function'
? fnOrOptions
: null;
let options: CacheableLruOptions;
// Parse parameters based on function signature
if (typeof fnOrName === 'function') {
// Overload 1: cacheableLru(fn, options)
options =
typeof fnOrOptions === 'object' && fnOrOptions !== null
? fnOrOptions
: ({} as CacheableLruOptions);
} else {
// Overload 2: cacheableLru(name, fn, options)
options =
typeof _options === 'object' && _options !== null
? _options
: ({} as CacheableLruOptions);
}
if (typeof fn !== 'function') {
throw new Error('fn is not a function');
}
if (typeof options.ttl !== 'number') {
throw new Error('options.ttl is required and must be a number');
}
const cachePrefix = `cachable:${name}`;
const getKey = (...args: Parameters<T>) =>
`${cachePrefix}:${stringify(args)}`;
const maxSize = options.maxSize ?? 1000;
const ttl = options.ttl;
// Create function-specific LRU cache
const functionLruCache = new LRUCache<string, any>({
max: maxSize,
ttl: ttl * 1000, // Convert seconds to milliseconds for LRU
});
// LRU-only mode: synchronous implementation (or returns promise if fn is async)
const cachedFn = ((...args: Parameters<T>): ReturnType<T> => {
const key = getKey(...args);
// Check LRU cache
const lruHit = functionLruCache.get(key);
if (lruHit !== undefined && hasResult(lruHit)) {
return lruHit as ReturnType<T>;
}
// Cache miss: Execute function
const result = fn(...(args as any)) as ReturnType<T>;
// If result is a Promise, handle it asynchronously but cache the resolved value
if (result && typeof (result as any).then === 'function') {
return (result as Promise<any>).then((resolved: any) => {
if (hasResult(resolved)) {
functionLruCache.set(key, resolved);
}
return resolved;
}) as ReturnType<T>;
}
// Synchronous result: cache and return
if (hasResult(result)) {
functionLruCache.set(key, result);
}
return result;
}) as T & {
getKey: (...args: Parameters<T>) => string;
clear: (...args: Parameters<T>) => boolean;
set: (...args: Parameters<T>) => (payload: ReturnType<T>) => void;
};
cachedFn.getKey = getKey;
cachedFn.clear = (...args: Parameters<T>) => {
const key = getKey(...args);
return functionLruCache.delete(key);
};
cachedFn.set =
(...args: Parameters<T>) =>
(payload: ReturnType<T>) => {
const key = getKey(...args);
if (hasResult(payload)) {
functionLruCache.set(key, payload);
}
};
return cachedFn;

View File

@@ -8,7 +8,8 @@
},
"dependencies": {
"@openpanel/json": "workspace:*",
"ioredis": "5.8.2"
"ioredis": "5.8.2",
"lru-cache": "^11.2.2"
},
"devDependencies": {
"@openpanel/db": "workspace:*",

View File

@@ -212,7 +212,6 @@ export const chartRouter = createTRPCRouter({
'origin',
'referrer',
'referrer_name',
'duration',
'created_at',
'country',
'city',

View File

@@ -127,23 +127,20 @@ export const eventRouter = createTRPCRouter({
startDate: z.date().optional(),
endDate: z.date().optional(),
events: z.array(z.string()).optional(),
columnVisibility: z.record(z.string(), z.boolean()).optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input: { columnVisibility, ...input } }) => {
const items = await getEventList({
...input,
take: 50,
cursor: input.cursor ? new Date(input.cursor) : undefined,
select: {
profile: true,
properties: true,
sessionId: true,
deviceId: true,
profileId: true,
referrerName: true,
referrerType: true,
referrer: true,
origin: true,
...columnVisibility,
city: columnVisibility?.country ?? true,
path: columnVisibility?.name ?? true,
duration: columnVisibility?.name ?? true,
projectId: false,
},
});
@@ -191,9 +188,10 @@ export const eventRouter = createTRPCRouter({
startDate: z.date().optional(),
endDate: z.date().optional(),
events: z.array(z.string()).optional(),
columnVisibility: z.record(z.string(), z.boolean()).optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input: { columnVisibility, ...input } }) => {
const conversions = await getConversionEventNames(input.projectId);
const filteredConversions = conversions.filter((event) => {
if (input.events && input.events.length > 0) {
@@ -216,15 +214,11 @@ export const eventRouter = createTRPCRouter({
take: 50,
cursor: input.cursor ? new Date(input.cursor) : undefined,
select: {
profile: true,
properties: true,
sessionId: true,
deviceId: true,
profileId: true,
referrerName: true,
referrerType: true,
referrer: true,
origin: true,
...columnVisibility,
city: columnVisibility?.country ?? true,
path: columnVisibility?.name ?? true,
duration: columnVisibility?.name ?? true,
projectId: false,
},
custom: (sb) => {
sb.where.name = `name IN (${filteredConversions.map((event) => sqlstring.escape(event.name)).join(',')})`;

View File

@@ -6,7 +6,7 @@ import {
TABLE_NAMES,
chQuery,
createSqlBuilder,
getProfileByIdCached,
getProfileById,
getProfileList,
getProfileListCount,
getProfileMetrics,
@@ -19,7 +19,7 @@ export const profileRouter = createTRPCRouter({
byId: protectedProcedure
.input(z.object({ profileId: z.string(), projectId: z.string() }))
.query(async ({ input: { profileId, projectId } }) => {
return getProfileByIdCached(profileId, projectId);
return getProfileById(profileId, projectId);
}),
metrics: protectedProcedure

View File

@@ -62,10 +62,12 @@ export const realtimeRouter = createTRPCRouter({
path: string;
count: number;
avg_duration: number;
unique_sessions: number;
}>([
'origin',
'path',
'COUNT(*) as count',
'COUNT(DISTINCT session_id) as unique_sessions',
'round(avg(duration)/1000, 2) as avg_duration',
])
.from(TABLE_NAMES.events)
@@ -91,9 +93,11 @@ export const realtimeRouter = createTRPCRouter({
referrer_name: string;
count: number;
avg_duration: number;
unique_sessions: number;
}>([
'referrer_name',
'COUNT(*) as count',
'COUNT(DISTINCT session_id) as unique_sessions',
'round(avg(duration)/1000, 2) as avg_duration',
])
.from(TABLE_NAMES.events)
@@ -120,10 +124,12 @@ export const realtimeRouter = createTRPCRouter({
city: string;
count: number;
avg_duration: number;
unique_sessions: number;
}>([
'country',
'city',
'COUNT(*) as count',
'COUNT(DISTINCT session_id) as unique_sessions',
'round(avg(duration)/1000, 2) as avg_duration',
])
.from(TABLE_NAMES.events)