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:
committed by
GitHub
parent
38cc53890a
commit
da59622dce
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -212,7 +212,6 @@ export const chartRouter = createTRPCRouter({
|
||||
'origin',
|
||||
'referrer',
|
||||
'referrer_name',
|
||||
'duration',
|
||||
'created_at',
|
||||
'country',
|
||||
'city',
|
||||
|
||||
@@ -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(',')})`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user