Compare commits
18 Commits
feature/gu
...
feature/pe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0abf16e73 | ||
|
|
fc531ce971 | ||
|
|
e6c7dec048 | ||
|
|
2ddc1754e0 | ||
|
|
3d1cdadb6f | ||
|
|
2286c331d3 | ||
|
|
9ee3d61a25 | ||
|
|
d8661acd66 | ||
|
|
bb0e413b06 | ||
|
|
37246f57f0 | ||
|
|
843f95f237 | ||
|
|
ed8deeec3c | ||
|
|
310a867cfa | ||
|
|
9bae0fb2db | ||
|
|
730c953bbc | ||
|
|
742ee8dc1c | ||
|
|
1285ad85a2 | ||
|
|
8bb0c87ec9 |
@@ -13,11 +13,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^1.2.10",
|
"@ai-sdk/anthropic": "^1.2.10",
|
||||||
"@ai-sdk/openai": "^1.3.12",
|
"@ai-sdk/openai": "^1.3.12",
|
||||||
"@fastify/compress": "^8.0.1",
|
"@fastify/compress": "^8.1.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.0.0",
|
"@fastify/cors": "^11.1.0",
|
||||||
"@fastify/rate-limit": "^10.2.2",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/websocket": "^11.0.2",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@openpanel/auth": "workspace:^",
|
"@openpanel/auth": "workspace:^",
|
||||||
"@openpanel/common": "workspace:*",
|
"@openpanel/common": "workspace:*",
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
"ai": "^4.2.10",
|
"ai": "^4.2.10",
|
||||||
"fast-json-stable-hash": "^1.0.3",
|
"fast-json-stable-hash": "^1.0.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.6.1",
|
||||||
"fastify-metrics": "^12.1.0",
|
"fastify-metrics": "^12.1.0",
|
||||||
"fastify-raw-body": "^5.0.0",
|
"fastify-raw-body": "^5.0.0",
|
||||||
"groupmq": "1.0.0-next.19",
|
"groupmq": "1.1.0-next.6",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
|
// Regex special characters that indicate we need actual regex
|
||||||
|
const regexSpecialChars = /[|^$.*+?(){}\[\]\\]/;
|
||||||
|
|
||||||
|
function transformBots(bots: any[]): any[] {
|
||||||
|
return bots.map((bot) => {
|
||||||
|
const { regex, ...rest } = bot;
|
||||||
|
const hasRegexChars = regexSpecialChars.test(regex);
|
||||||
|
|
||||||
|
if (hasRegexChars) {
|
||||||
|
// Keep as regex
|
||||||
|
return { regex, ...rest };
|
||||||
|
}
|
||||||
|
// Convert to includes
|
||||||
|
return { includes: regex, ...rest };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Get document, or throw exception on error
|
// Get document, or throw exception on error
|
||||||
try {
|
try {
|
||||||
@@ -14,6 +31,9 @@ async function main() {
|
|||||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||||
).then((res) => res.text());
|
).then((res) => res.text());
|
||||||
|
|
||||||
|
const parsedData = yaml.load(data) as any[];
|
||||||
|
const transformedBots = transformBots(parsedData);
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.resolve(__dirname, '../src/bots/bots.ts'),
|
path.resolve(__dirname, '../src/bots/bots.ts'),
|
||||||
[
|
[
|
||||||
@@ -21,11 +41,20 @@ async function main() {
|
|||||||
'',
|
'',
|
||||||
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||||
'',
|
'',
|
||||||
`const bots = ${JSON.stringify(yaml.load(data))} as const;`,
|
`const bots = ${JSON.stringify(transformedBots, null, 2)} as const;`,
|
||||||
'export default bots;',
|
'export default bots;',
|
||||||
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
|
||||||
|
);
|
||||||
|
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
|
||||||
|
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
|
||||||
|
console.log(` - ${includesCount} simple string matches (includes)`);
|
||||||
|
console.log(` - ${regexCount} regex patterns`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ async function main() {
|
|||||||
properties: {
|
properties: {
|
||||||
hash: 'test-hash',
|
hash: 'test-hash',
|
||||||
'query.utm_source': 'test',
|
'query.utm_source': 'test',
|
||||||
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
|
|
||||||
__user_agent: 'Mozilla/5.0 (Test)',
|
|
||||||
},
|
},
|
||||||
created_at: formatClickhouseDate(eventTime),
|
created_at: formatClickhouseDate(eventTime),
|
||||||
country: 'US',
|
country: 'US',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,47 @@
|
|||||||
|
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||||
import bots from './bots';
|
import bots from './bots';
|
||||||
|
|
||||||
export function isBot(ua: string) {
|
// Pre-compile regex patterns at module load time
|
||||||
const res = bots.find((bot) => {
|
const compiledBots = bots.map((bot) => {
|
||||||
if (new RegExp(bot.regex).test(ua)) {
|
if ('regex' in bot) {
|
||||||
return true;
|
return {
|
||||||
}
|
...bot,
|
||||||
return false;
|
compiledRegex: new RegExp(bot.regex),
|
||||||
});
|
};
|
||||||
|
|
||||||
if (!res) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return bot;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||||
name: res.name,
|
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||||
type: 'category' in res ? res.category : 'Unknown',
|
|
||||||
};
|
export const isBot = cacheableLru(
|
||||||
}
|
'is-bot',
|
||||||
|
(ua: string) => {
|
||||||
|
// Check simple string patterns first (fast)
|
||||||
|
for (const bot of includesBots) {
|
||||||
|
if (ua.includes(bot.includes)) {
|
||||||
|
return {
|
||||||
|
name: bot.name,
|
||||||
|
type: 'category' in bot ? bot.category : 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check regex patterns (slower)
|
||||||
|
for (const bot of regexBots) {
|
||||||
|
if (bot.compiledRegex.test(ua)) {
|
||||||
|
return {
|
||||||
|
name: bot.name,
|
||||||
|
type: 'category' in bot ? bot.category : 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxSize: 1000,
|
||||||
|
ttl: 60 * 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
|||||||
|
|
||||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||||
import { getSalts } from '@openpanel/db';
|
import { getSalts } from '@openpanel/db';
|
||||||
import { eventsGroupQueue } from '@openpanel/queue';
|
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||||
import type { PostEventPayload } from '@openpanel/sdk';
|
import type { PostEventPayload } from '@openpanel/sdk';
|
||||||
|
|
||||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
|
||||||
import { generateId } from '@openpanel/common';
|
import { generateId } from '@openpanel/common';
|
||||||
import { getGeoLocation } from '@openpanel/geo';
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||||
@@ -44,28 +43,22 @@ export async function postEvent(
|
|||||||
ua,
|
ua,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
timestamp,
|
|
||||||
previousDeviceId,
|
|
||||||
currentDeviceId,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||||
const groupId = uaInfo.isServer
|
const groupId = uaInfo.isServer
|
||||||
? request.body?.profileId
|
? request.body?.profileId
|
||||||
? `${projectId}:${request.body?.profileId}`
|
? `${projectId}:${request.body?.profileId}`
|
||||||
: `${projectId}:${generateId()}`
|
: `${projectId}:${generateId()}`
|
||||||
: currentDeviceId;
|
: currentDeviceId;
|
||||||
await eventsGroupQueue.add({
|
const jobId = [
|
||||||
|
request.body.name,
|
||||||
|
timestamp,
|
||||||
|
projectId,
|
||||||
|
currentDeviceId,
|
||||||
|
groupId,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('-');
|
||||||
|
await getEventsGroupQueueShard(groupId).add({
|
||||||
orderMs: new Date(timestamp).getTime(),
|
orderMs: new Date(timestamp).getTime(),
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -75,11 +68,13 @@ export async function postEvent(
|
|||||||
timestamp,
|
timestamp,
|
||||||
isTimestampFromThePast,
|
isTimestampFromThePast,
|
||||||
},
|
},
|
||||||
|
uaInfo,
|
||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
},
|
},
|
||||||
groupId,
|
groupId,
|
||||||
|
jobId,
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.status(202).send('ok');
|
reply.status(202).send('ok');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import superjson from 'superjson';
|
|||||||
import type { WebSocket } from '@fastify/websocket';
|
import type { WebSocket } from '@fastify/websocket';
|
||||||
import {
|
import {
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
getProfileByIdCached,
|
getProfileById,
|
||||||
transformMinimalEvent,
|
transformMinimalEvent,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { setSuperJson } from '@openpanel/json';
|
import { setSuperJson } from '@openpanel/json';
|
||||||
@@ -92,10 +92,7 @@ export async function wsProjectEvents(
|
|||||||
type,
|
type,
|
||||||
async (event) => {
|
async (event) => {
|
||||||
if (event.projectId === params.projectId) {
|
if (event.projectId === params.projectId) {
|
||||||
const profile = await getProfileByIdCached(
|
const profile = await getProfileById(event.profileId, event.projectId);
|
||||||
event.profileId,
|
|
||||||
event.projectId,
|
|
||||||
);
|
|
||||||
socket.send(
|
socket.send(
|
||||||
superjson.stringify(
|
superjson.stringify(
|
||||||
access
|
access
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ async function processImage(
|
|||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
// If it's an ICO file, just return it as-is (no conversion needed)
|
// If it's an ICO file, just return it as-is (no conversion needed)
|
||||||
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
||||||
logger.info('Serving ICO file directly', {
|
logger.debug('Serving ICO file directly', {
|
||||||
originalUrl,
|
originalUrl,
|
||||||
bufferSize: buffer.length,
|
bufferSize: buffer.length,
|
||||||
});
|
});
|
||||||
@@ -140,7 +140,7 @@ async function processImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (originalUrl && isSvgFile(originalUrl, contentType)) {
|
if (originalUrl && isSvgFile(originalUrl, contentType)) {
|
||||||
logger.info('Serving SVG file directly', {
|
logger.debug('Serving SVG file directly', {
|
||||||
originalUrl,
|
originalUrl,
|
||||||
bufferSize: buffer.length,
|
bufferSize: buffer.length,
|
||||||
});
|
});
|
||||||
@@ -149,7 +149,7 @@ async function processImage(
|
|||||||
|
|
||||||
// If buffer isnt to big just return it as well
|
// If buffer isnt to big just return it as well
|
||||||
if (buffer.length < 5000) {
|
if (buffer.length < 5000) {
|
||||||
logger.info('Serving image directly without processing', {
|
logger.debug('Serving image directly without processing', {
|
||||||
originalUrl,
|
originalUrl,
|
||||||
bufferSize: buffer.length,
|
bufferSize: buffer.length,
|
||||||
});
|
});
|
||||||
@@ -193,7 +193,7 @@ async function processOgImage(
|
|||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
// If buffer is small enough, return it as-is
|
// If buffer is small enough, return it as-is
|
||||||
if (buffer.length < 10000) {
|
if (buffer.length < 10000) {
|
||||||
logger.info('Serving OG image directly without processing', {
|
logger.debug('Serving OG image directly without processing', {
|
||||||
originalUrl,
|
originalUrl,
|
||||||
bufferSize: buffer.length,
|
bufferSize: buffer.length,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { assocPath, pathOr } from 'ramda';
|
import { assocPath, pathOr } from 'ramda';
|
||||||
|
|
||||||
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
|
|
||||||
import { parseUserAgent } from '@openpanel/common/server';
|
import { parseUserAgent } from '@openpanel/common/server';
|
||||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||||
import { getGeoLocation } from '@openpanel/geo';
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
@@ -16,41 +15,39 @@ export async function updateProfile(
|
|||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const { profileId, properties, ...rest } = request.body;
|
const payload = request.body;
|
||||||
const projectId = request.client!.projectId;
|
const projectId = request.client!.projectId;
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
return reply.status(400).send('No projectId');
|
return reply.status(400).send('No projectId');
|
||||||
}
|
}
|
||||||
const ip = request.clientIp;
|
const ip = request.clientIp;
|
||||||
const ua = request.headers['user-agent']!;
|
const ua = request.headers['user-agent']!;
|
||||||
const uaInfo = parseUserAgent(ua, properties);
|
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||||
const geo = await getGeoLocation(ip);
|
const geo = await getGeoLocation(ip);
|
||||||
|
|
||||||
if (
|
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertProfile({
|
await upsertProfile({
|
||||||
id: profileId,
|
...payload,
|
||||||
|
id: payload.profileId,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
projectId,
|
projectId,
|
||||||
properties: {
|
properties: {
|
||||||
...(properties ?? {}),
|
...(payload.properties ?? {}),
|
||||||
...(ip ? geo : {}),
|
country: geo.country,
|
||||||
...uaInfo,
|
city: geo.city,
|
||||||
|
region: geo.region,
|
||||||
|
longitude: geo.longitude,
|
||||||
|
latitude: geo.latitude,
|
||||||
|
os: uaInfo.os,
|
||||||
|
os_version: uaInfo.osVersion,
|
||||||
|
browser: uaInfo.browser,
|
||||||
|
browser_version: uaInfo.browserVersion,
|
||||||
|
device: uaInfo.device,
|
||||||
|
brand: uaInfo.brand,
|
||||||
|
model: uaInfo.model,
|
||||||
},
|
},
|
||||||
...rest,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.status(202).send(profileId);
|
reply.status(202).send(payload.profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function incrementProfileProperty(
|
export async function incrementProfileProperty(
|
||||||
@@ -65,18 +62,6 @@ export async function incrementProfileProperty(
|
|||||||
return reply.status(400).send('No projectId');
|
return reply.status(400).send('No projectId');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await getProfileById(profileId, projectId);
|
const profile = await getProfileById(profileId, projectId);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return reply.status(404).send('Not found');
|
return reply.status(404).send('Not found');
|
||||||
@@ -119,18 +104,6 @@ export async function decrementProfileProperty(
|
|||||||
return reply.status(400).send('No projectId');
|
return reply.status(400).send('No projectId');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await getProfileById(profileId, projectId);
|
const profile = await getProfileById(profileId, projectId);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return reply.status(404).send('Not found');
|
return reply.status(404).send('Not found');
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { path, assocPath, pathOr, pick } from 'ramda';
|
import { assocPath, pathOr, pick } from 'ramda';
|
||||||
|
|
||||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
|
||||||
import { generateId } from '@openpanel/common';
|
import { generateId } from '@openpanel/common';
|
||||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||||
import { eventsGroupQueue } from '@openpanel/queue';
|
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||||
import type {
|
import type {
|
||||||
DecrementPayload,
|
DecrementPayload,
|
||||||
IdentifyPayload,
|
IdentifyPayload,
|
||||||
@@ -37,10 +36,10 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||||
const identity = path<IdentifyPayload>(
|
const identity =
|
||||||
['properties', '__identify'],
|
'properties' in body.payload
|
||||||
body.payload,
|
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
|
||||||
);
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
identity ||
|
identity ||
|
||||||
@@ -56,27 +55,28 @@ export function getTimestamp(
|
|||||||
timestamp: FastifyRequest['timestamp'],
|
timestamp: FastifyRequest['timestamp'],
|
||||||
payload: TrackHandlerPayload['payload'],
|
payload: TrackHandlerPayload['payload'],
|
||||||
) {
|
) {
|
||||||
const safeTimestamp = new Date(timestamp || Date.now()).toISOString();
|
const safeTimestamp = timestamp || Date.now();
|
||||||
const userDefinedTimestamp = path<string>(
|
const userDefinedTimestamp =
|
||||||
['properties', '__timestamp'],
|
'properties' in payload
|
||||||
payload,
|
? (payload?.properties?.__timestamp as string | undefined)
|
||||||
);
|
: undefined;
|
||||||
|
|
||||||
if (!userDefinedTimestamp) {
|
if (!userDefinedTimestamp) {
|
||||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientTimestamp = new Date(userDefinedTimestamp);
|
const clientTimestamp = new Date(userDefinedTimestamp);
|
||||||
|
const clientTimestampNumber = clientTimestamp.getTime();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Number.isNaN(clientTimestamp.getTime()) ||
|
Number.isNaN(clientTimestampNumber) ||
|
||||||
clientTimestamp > new Date(safeTimestamp)
|
clientTimestampNumber > safeTimestamp
|
||||||
) {
|
) {
|
||||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timestamp: clientTimestamp.toISOString(),
|
timestamp: clientTimestampNumber,
|
||||||
isTimestampFromThePast: true,
|
isTimestampFromThePast: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,18 +89,19 @@ export async function handler(
|
|||||||
) {
|
) {
|
||||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||||
const ip =
|
const ip =
|
||||||
path<string>(['properties', '__ip'], request.body.payload) ||
|
'properties' in request.body.payload &&
|
||||||
request.clientIp;
|
request.body.payload.properties?.__ip
|
||||||
|
? (request.body.payload.properties.__ip as string)
|
||||||
|
: request.clientIp;
|
||||||
const ua = request.headers['user-agent']!;
|
const ua = request.headers['user-agent']!;
|
||||||
const projectId = request.client?.projectId;
|
const projectId = request.client?.projectId;
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
reply.status(400).send({
|
return reply.status(400).send({
|
||||||
status: 400,
|
status: 400,
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: 'Missing projectId',
|
message: 'Missing projectId',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const identity = getIdentity(request.body);
|
const identity = getIdentity(request.body);
|
||||||
@@ -132,33 +133,7 @@ export async function handler(
|
|||||||
})
|
})
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (
|
const promises = [];
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
timestamp,
|
|
||||||
previousDeviceId,
|
|
||||||
currentDeviceId,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promises = [
|
|
||||||
track({
|
|
||||||
payload: request.body.payload,
|
|
||||||
currentDeviceId,
|
|
||||||
previousDeviceId,
|
|
||||||
projectId,
|
|
||||||
geo,
|
|
||||||
headers: getStringHeaders(request.headers),
|
|
||||||
timestamp: timestamp.timestamp,
|
|
||||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
// If we have more than one property in the identity object, we should identify the user
|
// If we have more than one property in the identity object, we should identify the user
|
||||||
// Otherwise its only a profileId and we should not identify the user
|
// Otherwise its only a profileId and we should not identify the user
|
||||||
@@ -173,23 +148,23 @@ export async function handler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
track({
|
||||||
|
payload: request.body.payload,
|
||||||
|
currentDeviceId,
|
||||||
|
previousDeviceId,
|
||||||
|
projectId,
|
||||||
|
geo,
|
||||||
|
headers: getStringHeaders(request.headers),
|
||||||
|
timestamp: timestamp.timestamp,
|
||||||
|
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'identify': {
|
case 'identify': {
|
||||||
if (
|
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
timestamp,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const geo = await getGeoLocation(ip);
|
const geo = await getGeoLocation(ip);
|
||||||
await identify({
|
await identify({
|
||||||
payload: request.body.payload,
|
payload: request.body.payload,
|
||||||
@@ -200,27 +175,13 @@ export async function handler(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'alias': {
|
case 'alias': {
|
||||||
reply.status(400).send({
|
return reply.status(400).send({
|
||||||
status: 400,
|
status: 400,
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: 'Alias is not supported',
|
message: 'Alias is not supported',
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'increment': {
|
case 'increment': {
|
||||||
if (
|
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
timestamp,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await increment({
|
await increment({
|
||||||
payload: request.body.payload,
|
payload: request.body.payload,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -228,19 +189,6 @@ export async function handler(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'decrement': {
|
case 'decrement': {
|
||||||
if (
|
|
||||||
await checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload: {
|
|
||||||
...request.body,
|
|
||||||
timestamp,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await decrement({
|
await decrement({
|
||||||
payload: request.body.payload,
|
payload: request.body.payload,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -248,12 +196,11 @@ export async function handler(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
reply.status(400).send({
|
return reply.status(400).send({
|
||||||
status: 400,
|
status: 400,
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: 'Invalid type',
|
message: 'Invalid type',
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +223,7 @@ async function track({
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
geo: GeoLocation;
|
geo: GeoLocation;
|
||||||
headers: Record<string, string | undefined>;
|
headers: Record<string, string | undefined>;
|
||||||
timestamp: string;
|
timestamp: number;
|
||||||
isTimestampFromThePast: boolean;
|
isTimestampFromThePast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||||
@@ -285,8 +232,11 @@ async function track({
|
|||||||
? `${projectId}:${payload.profileId}`
|
? `${projectId}:${payload.profileId}`
|
||||||
: `${projectId}:${generateId()}`
|
: `${projectId}:${generateId()}`
|
||||||
: currentDeviceId;
|
: currentDeviceId;
|
||||||
await eventsGroupQueue.add({
|
const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
|
||||||
orderMs: new Date(timestamp).getTime(),
|
.filter(Boolean)
|
||||||
|
.join('-');
|
||||||
|
await getEventsGroupQueueShard(groupId).add({
|
||||||
|
orderMs: timestamp,
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
headers,
|
headers,
|
||||||
@@ -295,11 +245,13 @@ async function track({
|
|||||||
timestamp,
|
timestamp,
|
||||||
isTimestampFromThePast,
|
isTimestampFromThePast,
|
||||||
},
|
},
|
||||||
|
uaInfo,
|
||||||
geo,
|
geo,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
},
|
},
|
||||||
groupId,
|
groupId,
|
||||||
|
jobId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,8 +274,18 @@ async function identify({
|
|||||||
projectId,
|
projectId,
|
||||||
properties: {
|
properties: {
|
||||||
...(payload.properties ?? {}),
|
...(payload.properties ?? {}),
|
||||||
...(geo ?? {}),
|
country: geo.country,
|
||||||
...uaInfo,
|
city: geo.city,
|
||||||
|
region: geo.region,
|
||||||
|
longitude: geo.longitude,
|
||||||
|
latitude: geo.latitude,
|
||||||
|
os: uaInfo.os,
|
||||||
|
os_version: uaInfo.osVersion,
|
||||||
|
browser: uaInfo.browser,
|
||||||
|
browser_version: uaInfo.browserVersion,
|
||||||
|
device: uaInfo.device,
|
||||||
|
brand: uaInfo.brand,
|
||||||
|
model: uaInfo.model,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
28
apps/api/src/hooks/duplicate.hook.ts
Normal file
28
apps/api/src/hooks/duplicate.hook.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||||
|
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
export async function duplicateHook(
|
||||||
|
req: FastifyRequest<{
|
||||||
|
Body: PostEventPayload | TrackHandlerPayload;
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const ip = req.clientIp;
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const clientId = req.headers['openpanel-client-id'];
|
||||||
|
const shouldCheck = ip && origin && clientId;
|
||||||
|
|
||||||
|
const isDuplicate = shouldCheck
|
||||||
|
? await isDuplicatedEvent({
|
||||||
|
ip,
|
||||||
|
origin,
|
||||||
|
payload: req.body,
|
||||||
|
projectId: clientId as string,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
return reply.status(200).send('Duplicate event');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
export async function fixHook(request: FastifyRequest) {
|
|
||||||
const ua = request.headers['user-agent'];
|
|
||||||
// Swift SDK issue: https://github.com/Openpanel-dev/swift-sdk/commit/d588fa761a36a33f3b78eb79d83bfd524e3c7144
|
|
||||||
if (ua) {
|
|
||||||
const regex = /OpenPanel\/(\d+\.\d+\.\d+)\sOpenPanel\/(\d+\.\d+\.\d+)/;
|
|
||||||
const match = ua.match(regex);
|
|
||||||
if (match) {
|
|
||||||
request.headers['user-agent'] = ua.replace(
|
|
||||||
regex,
|
|
||||||
`OpenPanel/${match[1]}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
liveness,
|
liveness,
|
||||||
readiness,
|
readiness,
|
||||||
} from './controllers/healthcheck.controller';
|
} from './controllers/healthcheck.controller';
|
||||||
import { fixHook } from './hooks/fix.hook';
|
|
||||||
import { ipHook } from './hooks/ip.hook';
|
import { ipHook } from './hooks/ip.hook';
|
||||||
import { requestIdHook } from './hooks/request-id.hook';
|
import { requestIdHook } from './hooks/request-id.hook';
|
||||||
import { requestLoggingHook } from './hooks/request-logging.hook';
|
import { requestLoggingHook } from './hooks/request-logging.hook';
|
||||||
@@ -125,7 +124,6 @@ const startServer = async () => {
|
|||||||
fastify.addHook('onRequest', requestIdHook);
|
fastify.addHook('onRequest', requestIdHook);
|
||||||
fastify.addHook('onRequest', timestampHook);
|
fastify.addHook('onRequest', timestampHook);
|
||||||
fastify.addHook('onRequest', ipHook);
|
fastify.addHook('onRequest', ipHook);
|
||||||
fastify.addHook('onRequest', fixHook);
|
|
||||||
fastify.addHook('onResponse', requestLoggingHook);
|
fastify.addHook('onResponse', requestLoggingHook);
|
||||||
|
|
||||||
fastify.register(compress, {
|
fastify.register(compress, {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import * as controller from '@/controllers/event.controller';
|
|||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
import { clientHook } from '@/hooks/client.hook';
|
import { clientHook } from '@/hooks/client.hook';
|
||||||
|
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||||
|
|
||||||
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
|
fastify.addHook('preValidation', duplicateHook);
|
||||||
fastify.addHook('preHandler', clientHook);
|
fastify.addHook('preHandler', clientHook);
|
||||||
fastify.addHook('preHandler', isBotHook);
|
fastify.addHook('preHandler', isBotHook);
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { handler } from '@/controllers/track.controller';
|
|||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
import { clientHook } from '@/hooks/client.hook';
|
import { clientHook } from '@/hooks/client.hook';
|
||||||
|
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||||
|
|
||||||
const trackRouter: FastifyPluginCallback = async (fastify) => {
|
const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
|
fastify.addHook('preValidation', duplicateHook);
|
||||||
fastify.addHook('preHandler', clientHook);
|
fastify.addHook('preHandler', clientHook);
|
||||||
fastify.addHook('preHandler', isBotHook);
|
fastify.addHook('preHandler', isBotHook);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
|||||||
import { verifyPassword } from '@openpanel/common/server';
|
import { verifyPassword } from '@openpanel/common/server';
|
||||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||||
|
import { getCache } from '@openpanel/redis';
|
||||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||||
import type {
|
import type {
|
||||||
IProjectFilterIp,
|
IProjectFilterIp,
|
||||||
@@ -135,7 +136,13 @@ export async function validateSdkRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (client.secret && clientSecret) {
|
if (client.secret && clientSecret) {
|
||||||
if (await verifyPassword(clientSecret, client.secret)) {
|
const isVerified = await getCache(
|
||||||
|
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
||||||
|
60 * 5,
|
||||||
|
async () => await verifyPassword(clientSecret, client.secret!),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (isVerified) {
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { getLock } from '@openpanel/redis';
|
import { getLock } from '@openpanel/redis';
|
||||||
import fastJsonStableHash from 'fast-json-stable-hash';
|
import fastJsonStableHash from 'fast-json-stable-hash';
|
||||||
import type { FastifyReply } from 'fastify';
|
|
||||||
|
|
||||||
export async function isDuplicatedEvent({
|
export async function isDuplicatedEvent({
|
||||||
|
ip,
|
||||||
|
origin,
|
||||||
payload,
|
payload,
|
||||||
projectId,
|
projectId,
|
||||||
}: {
|
}: {
|
||||||
|
ip: string;
|
||||||
|
origin: string;
|
||||||
payload: Record<string, any>;
|
payload: Record<string, any>;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -13,6 +16,8 @@ export async function isDuplicatedEvent({
|
|||||||
`fastify:deduplicate:${fastJsonStableHash.hash(
|
`fastify:deduplicate:${fastJsonStableHash.hash(
|
||||||
{
|
{
|
||||||
...payload,
|
...payload,
|
||||||
|
ip,
|
||||||
|
origin,
|
||||||
projectId,
|
projectId,
|
||||||
},
|
},
|
||||||
'md5',
|
'md5',
|
||||||
@@ -27,24 +32,3 @@ export async function isDuplicatedEvent({
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkDuplicatedEvent({
|
|
||||||
reply,
|
|
||||||
payload,
|
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
reply: FastifyReply;
|
|
||||||
payload: Record<string, any>;
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
if (await isDuplicatedEvent({ payload, projectId })) {
|
|
||||||
reply.log.info('duplicated event', {
|
|
||||||
payload,
|
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
reply.status(200).send('duplicated');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ch, db } from '@openpanel/db';
|
import { ch, db } from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
cronQueue,
|
cronQueue,
|
||||||
eventsGroupQueue,
|
eventsGroupQueues,
|
||||||
miscQueue,
|
miscQueue,
|
||||||
notificationQueue,
|
notificationQueue,
|
||||||
sessionsQueue,
|
sessionsQueue,
|
||||||
@@ -71,7 +71,7 @@ export async function shutdown(
|
|||||||
// Step 6: Close Bull queues (graceful shutdown of queue state)
|
// Step 6: Close Bull queues (graceful shutdown of queue state)
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
eventsGroupQueue.close(),
|
...eventsGroupQueues.map((queue) => queue.close()),
|
||||||
sessionsQueue.close(),
|
sessionsQueue.close(),
|
||||||
cronQueue.close(),
|
cronQueue.close(),
|
||||||
miscQueue.close(),
|
miscQueue.close(),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^1.2.5",
|
"@ai-sdk/react": "^1.2.5",
|
||||||
"@clickhouse/client": "^1.2.0",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Events',
|
name: 'Events',
|
||||||
width: '84px',
|
width: '60px',
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -86,6 +86,19 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Sessions',
|
||||||
|
width: '82px',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="row gap-2 justify-end">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{number.short(item.unique_sessions)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Events',
|
name: 'Events',
|
||||||
width: '84px',
|
width: '60px',
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -93,6 +93,19 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Sessions',
|
||||||
|
width: '82px',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="row gap-2 justify-end">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{number.short(item.unique_sessions)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Events',
|
name: 'Events',
|
||||||
width: '84px',
|
width: '60px',
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -76,6 +76,19 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Sessions',
|
||||||
|
width: '82px',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="row gap-2 justify-end">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{number.short(item.unique_sessions)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocalStorage } from 'usehooks-ts';
|
import { useLocalStorage, useReadLocalStorage } from 'usehooks-ts';
|
||||||
|
|
||||||
export const useDataTablePagination = (pageSize = 10) => {
|
export const useDataTablePagination = (pageSize = 10) => {
|
||||||
const [page, setPage] = useQueryState(
|
const [page, setPage] = useQueryState(
|
||||||
@@ -22,6 +22,12 @@ export const useDataTablePagination = (pageSize = 10) => {
|
|||||||
return { page, setPage, state };
|
return { page, setPage, state };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useReadColumnVisibility = (persistentKey: string) => {
|
||||||
|
return useReadLocalStorage<Record<string, boolean>>(
|
||||||
|
`@op:${persistentKey}-column-visibility`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const useDataTableColumnVisibility = <TData,>(
|
export const useDataTableColumnVisibility = <TData,>(
|
||||||
columns: ColumnDef<TData>[],
|
columns: ColumnDef<TData>[],
|
||||||
persistentKey: string,
|
persistentKey: string,
|
||||||
@@ -43,6 +49,13 @@ export const useDataTableColumnVisibility = <TData,>(
|
|||||||
}, {} as VisibilityState),
|
}, {} as VisibilityState),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// somewhat hack
|
||||||
|
// Set initial column visibility,
|
||||||
|
// otherwise will not useReadColumnVisibility be updated
|
||||||
|
useEffect(() => {
|
||||||
|
setColumnVisibility(columnVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
|
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
|
||||||
`@op:${persistentKey}-column-order`,
|
`@op:${persistentKey}-column-order`,
|
||||||
columns.map((column) => column.id!),
|
columns.map((column) => column.id!),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventsTable } from '@/components/events/table';
|
import { EventsTable } from '@/components/events/table';
|
||||||
|
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||||
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
|
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
@@ -20,6 +21,7 @@ function Component() {
|
|||||||
);
|
);
|
||||||
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||||
const [eventNames] = useEventQueryNamesFilter();
|
const [eventNames] = useEventQueryNamesFilter();
|
||||||
|
const columnVisibility = useReadColumnVisibility('events');
|
||||||
const query = useInfiniteQuery(
|
const query = useInfiniteQuery(
|
||||||
trpc.event.conversions.infiniteQueryOptions(
|
trpc.event.conversions.infiniteQueryOptions(
|
||||||
{
|
{
|
||||||
@@ -27,6 +29,7 @@ function Component() {
|
|||||||
startDate: startDate || undefined,
|
startDate: startDate || undefined,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
events: eventNames,
|
events: eventNames,
|
||||||
|
columnVisibility: columnVisibility ?? {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventsTable } from '@/components/events/table';
|
import { EventsTable } from '@/components/events/table';
|
||||||
|
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||||
import {
|
import {
|
||||||
useEventQueryFilters,
|
useEventQueryFilters,
|
||||||
useEventQueryNamesFilter,
|
useEventQueryNamesFilter,
|
||||||
@@ -21,6 +22,8 @@ function Component() {
|
|||||||
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
||||||
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||||
const [eventNames] = useEventQueryNamesFilter();
|
const [eventNames] = useEventQueryNamesFilter();
|
||||||
|
const columnVisibility = useReadColumnVisibility('events');
|
||||||
|
|
||||||
const query = useInfiniteQuery(
|
const query = useInfiniteQuery(
|
||||||
trpc.event.events.infiniteQueryOptions(
|
trpc.event.events.infiniteQueryOptions(
|
||||||
{
|
{
|
||||||
@@ -30,8 +33,10 @@ function Component() {
|
|||||||
profileId: '',
|
profileId: '',
|
||||||
startDate: startDate || undefined,
|
startDate: startDate || undefined,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
|
columnVisibility: columnVisibility ?? {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
enabled: columnVisibility !== null,
|
||||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventsTable } from '@/components/events/table';
|
import { EventsTable } from '@/components/events/table';
|
||||||
|
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||||
import {
|
import {
|
||||||
useEventQueryFilters,
|
useEventQueryFilters,
|
||||||
useEventQueryNamesFilter,
|
useEventQueryNamesFilter,
|
||||||
@@ -21,6 +22,7 @@ function Component() {
|
|||||||
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
||||||
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||||
const [eventNames] = useEventQueryNamesFilter();
|
const [eventNames] = useEventQueryNamesFilter();
|
||||||
|
const columnVisibility = useReadColumnVisibility('events');
|
||||||
const query = useInfiniteQuery(
|
const query = useInfiniteQuery(
|
||||||
trpc.event.events.infiniteQueryOptions(
|
trpc.event.events.infiniteQueryOptions(
|
||||||
{
|
{
|
||||||
@@ -30,8 +32,10 @@ function Component() {
|
|||||||
startDate: startDate || undefined,
|
startDate: startDate || undefined,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
events: eventNames,
|
events: eventNames,
|
||||||
|
columnVisibility: columnVisibility ?? {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
enabled: columnVisibility !== null,
|
||||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
|
|||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||||
import {
|
import {
|
||||||
useEventQueryFilters,
|
useEventQueryFilters,
|
||||||
useEventQueryNamesFilter,
|
useEventQueryNamesFilter,
|
||||||
} from '@/hooks/use-event-query-filters';
|
} from '@/hooks/use-event-query-filters';
|
||||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { createProjectTitle } from '@/utils/title';
|
import { createProjectTitle } from '@/utils/title';
|
||||||
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
@@ -46,8 +45,6 @@ function Component() {
|
|||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
const { page } = useDataTablePagination(LIMIT);
|
|
||||||
const { debouncedSearch } = useSearchQueryState();
|
|
||||||
|
|
||||||
const { data: session } = useSuspenseQuery(
|
const { data: session } = useSuspenseQuery(
|
||||||
trpc.session.byId.queryOptions({
|
trpc.session.byId.queryOptions({
|
||||||
@@ -60,7 +57,7 @@ function Component() {
|
|||||||
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
||||||
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||||
const [eventNames] = useEventQueryNamesFilter();
|
const [eventNames] = useEventQueryNamesFilter();
|
||||||
|
const columnVisibility = useReadColumnVisibility('events');
|
||||||
const query = useInfiniteQuery(
|
const query = useInfiniteQuery(
|
||||||
trpc.event.events.infiniteQueryOptions(
|
trpc.event.events.infiniteQueryOptions(
|
||||||
{
|
{
|
||||||
@@ -70,8 +67,10 @@ function Component() {
|
|||||||
events: eventNames,
|
events: eventNames,
|
||||||
startDate: startDate || undefined,
|
startDate: startDate || undefined,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
|
columnVisibility: columnVisibility ?? {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
enabled: columnVisibility !== null,
|
||||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "6.13.1",
|
"@bull-board/api": "6.14.0",
|
||||||
"@bull-board/express": "6.13.1",
|
"@bull-board/express": "6.14.0",
|
||||||
"@openpanel/common": "workspace:*",
|
"@openpanel/common": "workspace:*",
|
||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/email": "workspace:*",
|
"@openpanel/email": "workspace:*",
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
"@openpanel/importer": "workspace:*",
|
"@openpanel/importer": "workspace:*",
|
||||||
"@openpanel/queue": "workspace:*",
|
"@openpanel/queue": "workspace:*",
|
||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"bullmq": "^5.8.7",
|
"bullmq": "^5.63.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"groupmq": "1.0.0-next.19",
|
"groupmq": "1.1.0-next.6",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
|||||||
@@ -44,43 +44,30 @@ export async function bootCron() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add repeatable jobs
|
logger.info('Updating cron jobs');
|
||||||
for (const job of jobs) {
|
|
||||||
await cronQueue.add(
|
const jobSchedulers = await cronQueue.getJobSchedulers();
|
||||||
job.name,
|
for (const jobScheduler of jobSchedulers) {
|
||||||
{
|
await cronQueue.removeJobScheduler(jobScheduler.key);
|
||||||
type: job.type,
|
|
||||||
payload: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
jobId: job.type,
|
|
||||||
repeat:
|
|
||||||
typeof job.pattern === 'number'
|
|
||||||
? {
|
|
||||||
every: job.pattern,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
pattern: job.pattern,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove outdated repeatable jobs
|
// Add repeatable jobs
|
||||||
const repeatableJobs = await cronQueue.getRepeatableJobs();
|
for (const job of jobs) {
|
||||||
for (const repeatableJob of repeatableJobs) {
|
await cronQueue.upsertJobScheduler(
|
||||||
const match = jobs.find(
|
job.type,
|
||||||
(job) => `${job.name}:${job.type}:::${job.pattern}` === repeatableJob.key,
|
typeof job.pattern === 'number'
|
||||||
|
? {
|
||||||
|
every: job.pattern,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
pattern: job.pattern,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
type: job.type,
|
||||||
|
payload: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (match) {
|
|
||||||
logger.info('Repeatable job exists', {
|
|
||||||
key: repeatableJob.key,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.info('Removing repeatable job', {
|
|
||||||
key: repeatableJob.key,
|
|
||||||
});
|
|
||||||
cronQueue.removeRepeatableByKey(repeatableJob.key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { Queue, WorkerOptions } from 'bullmq';
|
|||||||
import { Worker } from 'bullmq';
|
import { Worker } from 'bullmq';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
EVENTS_GROUP_QUEUES_SHARDS,
|
||||||
type EventsQueuePayloadIncomingEvent,
|
type EventsQueuePayloadIncomingEvent,
|
||||||
cronQueue,
|
cronQueue,
|
||||||
eventsGroupQueue,
|
eventsGroupQueues,
|
||||||
importQueue,
|
importQueue,
|
||||||
miscQueue,
|
miscQueue,
|
||||||
notificationQueue,
|
notificationQueue,
|
||||||
@@ -18,59 +19,179 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||||||
import { Worker as GroupWorker } from 'groupmq';
|
import { Worker as GroupWorker } from 'groupmq';
|
||||||
|
|
||||||
import { cronJob } from './jobs/cron';
|
import { cronJob } from './jobs/cron';
|
||||||
import { eventsJob } from './jobs/events';
|
import { incomingEvent } from './jobs/events.incoming-event';
|
||||||
import { incomingEventPure } from './jobs/events.incoming-event';
|
|
||||||
import { importJob } from './jobs/import';
|
import { importJob } from './jobs/import';
|
||||||
import { miscJob } from './jobs/misc';
|
import { miscJob } from './jobs/misc';
|
||||||
import { notificationJob } from './jobs/notification';
|
import { notificationJob } from './jobs/notification';
|
||||||
import { sessionsJob } from './jobs/sessions';
|
import { sessionsJob } from './jobs/sessions';
|
||||||
|
import { eventsGroupJobDuration } from './metrics';
|
||||||
import { logger } from './utils/logger';
|
import { logger } from './utils/logger';
|
||||||
|
|
||||||
const workerOptions: WorkerOptions = {
|
const workerOptions: WorkerOptions = {
|
||||||
connection: getRedisQueue(),
|
connection: getRedisQueue(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function bootWorkers() {
|
type QueueName = string; // Can be: events, events_N (where N is 0 to shards-1), sessions, cron, notification, misc
|
||||||
const eventsGroupWorker = new GroupWorker<
|
|
||||||
EventsQueuePayloadIncomingEvent['payload']
|
|
||||||
>({
|
|
||||||
concurrency: Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '1', 10),
|
|
||||||
logger: queueLogger,
|
|
||||||
queue: eventsGroupQueue,
|
|
||||||
handler: async (job) => {
|
|
||||||
logger.info('processing event (group queue)', {
|
|
||||||
groupId: job.groupId,
|
|
||||||
timestamp: job.data.event.timestamp,
|
|
||||||
});
|
|
||||||
await incomingEventPure(job.data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
eventsGroupWorker.run();
|
|
||||||
const sessionsWorker = new Worker(
|
|
||||||
sessionsQueue.name,
|
|
||||||
sessionsJob,
|
|
||||||
workerOptions,
|
|
||||||
);
|
|
||||||
const cronWorker = new Worker(cronQueue.name, cronJob, workerOptions);
|
|
||||||
const notificationWorker = new Worker(
|
|
||||||
notificationQueue.name,
|
|
||||||
notificationJob,
|
|
||||||
workerOptions,
|
|
||||||
);
|
|
||||||
const miscWorker = new Worker(miscQueue.name, miscJob, workerOptions);
|
|
||||||
const importWorker = new Worker(importQueue.name, importJob, {
|
|
||||||
...workerOptions,
|
|
||||||
concurrency: Number.parseInt(process.env.IMPORT_JOB_CONCURRENCY || '1', 10),
|
|
||||||
});
|
|
||||||
|
|
||||||
const workers = [
|
/**
|
||||||
sessionsWorker,
|
* Parses the ENABLED_QUEUES environment variable and returns an array of queue names to start.
|
||||||
cronWorker,
|
* If no env var is provided, returns all queues.
|
||||||
notificationWorker,
|
*
|
||||||
miscWorker,
|
* Supported queue names:
|
||||||
importWorker,
|
* - events - All event shards (events_0, events_1, ..., events_N)
|
||||||
// eventsGroupWorker,
|
* - events_N - Individual event shard (where N is 0 to EVENTS_GROUP_QUEUES_SHARDS-1)
|
||||||
];
|
* - sessions, cron, notification, misc
|
||||||
|
*/
|
||||||
|
function getEnabledQueues(): QueueName[] {
|
||||||
|
const enabledQueuesEnv = process.env.ENABLED_QUEUES?.trim();
|
||||||
|
|
||||||
|
if (!enabledQueuesEnv) {
|
||||||
|
logger.info('No ENABLED_QUEUES specified, starting all queues', {
|
||||||
|
totalEventShards: EVENTS_GROUP_QUEUES_SHARDS,
|
||||||
|
});
|
||||||
|
return ['events', 'sessions', 'cron', 'notification', 'misc', 'import'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const queues = enabledQueuesEnv
|
||||||
|
.split(',')
|
||||||
|
.map((q) => q.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
logger.info('Starting queues from ENABLED_QUEUES', {
|
||||||
|
queues,
|
||||||
|
totalEventShards: EVENTS_GROUP_QUEUES_SHARDS,
|
||||||
|
});
|
||||||
|
return queues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the concurrency setting for a queue from environment variables.
|
||||||
|
* Env var format: {QUEUE_NAME}_CONCURRENCY (e.g., EVENTS_0_CONCURRENCY=32)
|
||||||
|
*/
|
||||||
|
function getConcurrencyFor(queueName: string, defaultValue = 1): number {
|
||||||
|
const envKey = `${queueName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_CONCURRENCY`;
|
||||||
|
const value = process.env[envKey];
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootWorkers() {
|
||||||
|
const enabledQueues = getEnabledQueues();
|
||||||
|
|
||||||
|
const workers: (Worker | GroupWorker<any>)[] = [];
|
||||||
|
|
||||||
|
// Start event workers based on enabled queues
|
||||||
|
const eventQueuesToStart: number[] = [];
|
||||||
|
|
||||||
|
if (enabledQueues.includes('events')) {
|
||||||
|
// Start all event shards
|
||||||
|
for (let i = 0; i < EVENTS_GROUP_QUEUES_SHARDS; i++) {
|
||||||
|
eventQueuesToStart.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Start specific event shards (events_0, events_1, etc.)
|
||||||
|
for (let i = 0; i < EVENTS_GROUP_QUEUES_SHARDS; i++) {
|
||||||
|
if (enabledQueues.includes(`events_${i}`)) {
|
||||||
|
eventQueuesToStart.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const index of eventQueuesToStart) {
|
||||||
|
const queue = eventsGroupQueues[index];
|
||||||
|
if (!queue) continue;
|
||||||
|
|
||||||
|
const queueName = `events_${index}`;
|
||||||
|
const concurrency = getConcurrencyFor(
|
||||||
|
queueName,
|
||||||
|
Number.parseInt(process.env.EVENT_JOB_CONCURRENCY || '10', 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
const worker = new GroupWorker<EventsQueuePayloadIncomingEvent['payload']>({
|
||||||
|
queue,
|
||||||
|
concurrency,
|
||||||
|
logger: queueLogger,
|
||||||
|
blockingTimeoutSec: Number.parseFloat(
|
||||||
|
process.env.EVENT_BLOCKING_TIMEOUT_SEC || '1',
|
||||||
|
),
|
||||||
|
handler: async (job) => {
|
||||||
|
return await incomingEvent(job.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.run();
|
||||||
|
workers.push(worker);
|
||||||
|
logger.info(`Started worker for ${queueName}`, { concurrency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start sessions worker
|
||||||
|
if (enabledQueues.includes('sessions')) {
|
||||||
|
const concurrency = getConcurrencyFor('sessions');
|
||||||
|
const sessionsWorker = new Worker(sessionsQueue.name, sessionsJob, {
|
||||||
|
...workerOptions,
|
||||||
|
concurrency,
|
||||||
|
});
|
||||||
|
workers.push(sessionsWorker);
|
||||||
|
logger.info('Started worker for sessions', { concurrency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cron worker
|
||||||
|
if (enabledQueues.includes('cron')) {
|
||||||
|
const concurrency = getConcurrencyFor('cron');
|
||||||
|
const cronWorker = new Worker(cronQueue.name, cronJob, {
|
||||||
|
...workerOptions,
|
||||||
|
concurrency,
|
||||||
|
});
|
||||||
|
workers.push(cronWorker);
|
||||||
|
logger.info('Started worker for cron', { concurrency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start notification worker
|
||||||
|
if (enabledQueues.includes('notification')) {
|
||||||
|
const concurrency = getConcurrencyFor('notification');
|
||||||
|
const notificationWorker = new Worker(
|
||||||
|
notificationQueue.name,
|
||||||
|
notificationJob,
|
||||||
|
{ ...workerOptions, concurrency },
|
||||||
|
);
|
||||||
|
workers.push(notificationWorker);
|
||||||
|
logger.info('Started worker for notification', { concurrency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start misc worker
|
||||||
|
if (enabledQueues.includes('misc')) {
|
||||||
|
const concurrency = getConcurrencyFor('misc');
|
||||||
|
const miscWorker = new Worker(miscQueue.name, miscJob, {
|
||||||
|
...workerOptions,
|
||||||
|
concurrency,
|
||||||
|
});
|
||||||
|
workers.push(miscWorker);
|
||||||
|
logger.info('Started worker for misc', { concurrency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start import worker
|
||||||
|
if (enabledQueues.includes('import')) {
|
||||||
|
const concurrency = getConcurrencyFor('import');
|
||||||
|
const importWorker = new Worker(importQueue.name, importJob, {
|
||||||
|
...workerOptions,
|
||||||
|
concurrency,
|
||||||
|
});
|
||||||
|
workers.push(importWorker);
|
||||||
|
logger.info('Started worker for import', { concurrency });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workers.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
'No workers started. Check ENABLED_QUEUES environment variable.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
workers.forEach((worker) => {
|
workers.forEach((worker) => {
|
||||||
(worker as Worker).on('error', (error) => {
|
(worker as Worker).on('error', (error) => {
|
||||||
@@ -94,6 +215,13 @@ export async function bootWorkers() {
|
|||||||
|
|
||||||
(worker as Worker).on('failed', (job) => {
|
(worker as Worker).on('failed', (job) => {
|
||||||
if (job) {
|
if (job) {
|
||||||
|
if (job.processedOn && job.finishedOn) {
|
||||||
|
const elapsed = job.finishedOn - job.processedOn;
|
||||||
|
eventsGroupJobDuration.observe(
|
||||||
|
{ name: worker.name, status: 'failed' },
|
||||||
|
elapsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
logger.error('job failed', {
|
logger.error('job failed', {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
worker: worker.name,
|
worker: worker.name,
|
||||||
@@ -106,15 +234,18 @@ export async function bootWorkers() {
|
|||||||
|
|
||||||
(worker as Worker).on('completed', (job) => {
|
(worker as Worker).on('completed', (job) => {
|
||||||
if (job) {
|
if (job) {
|
||||||
logger.info('job completed', {
|
if (job.processedOn && job.finishedOn) {
|
||||||
jobId: job.id,
|
const elapsed = job.finishedOn - job.processedOn;
|
||||||
worker: worker.name,
|
logger.info('job completed', {
|
||||||
data: job.data,
|
jobId: job.id,
|
||||||
elapsed:
|
worker: worker.name,
|
||||||
job.processedOn && job.finishedOn
|
elapsed,
|
||||||
? job.finishedOn - job.processedOn
|
});
|
||||||
: undefined,
|
eventsGroupJobDuration.observe(
|
||||||
});
|
{ name: worker.name, status: 'success' },
|
||||||
|
elapsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,8 +266,14 @@ export async function bootWorkers() {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const time = performance.now();
|
const time = performance.now();
|
||||||
await waitForQueueToEmpty(cronQueue);
|
|
||||||
|
// Wait for cron queue to empty if it's running
|
||||||
|
if (enabledQueues.includes('cron')) {
|
||||||
|
await waitForQueueToEmpty(cronQueue);
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(workers.map((worker) => worker.close()));
|
await Promise.all(workers.map((worker) => worker.close()));
|
||||||
|
|
||||||
logger.info('workers closed successfully', {
|
logger.info('workers closed successfully', {
|
||||||
elapsed: performance.now() - time,
|
elapsed: performance.now() - time,
|
||||||
});
|
});
|
||||||
@@ -155,15 +292,7 @@ export async function bootWorkers() {
|
|||||||
['uncaughtException', 'unhandledRejection', 'SIGTERM', 'SIGINT'].forEach(
|
['uncaughtException', 'unhandledRejection', 'SIGTERM', 'SIGINT'].forEach(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
process.on(evt, (code) => {
|
process.on(evt, (code) => {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
exitHandler(evt, code);
|
||||||
exitHandler(evt, code);
|
|
||||||
} else {
|
|
||||||
logger.info('Shutting down for development', {
|
|
||||||
event: evt,
|
|
||||||
code,
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ExpressAdapter } from '@bull-board/express';
|
|||||||
import { createInitialSalts } from '@openpanel/db';
|
import { createInitialSalts } from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
cronQueue,
|
cronQueue,
|
||||||
eventsGroupQueue,
|
eventsGroupQueues,
|
||||||
importQueue,
|
importQueue,
|
||||||
miscQueue,
|
miscQueue,
|
||||||
notificationQueue,
|
notificationQueue,
|
||||||
@@ -34,7 +34,9 @@ async function start() {
|
|||||||
serverAdapter.setBasePath('/');
|
serverAdapter.setBasePath('/');
|
||||||
createBullBoard({
|
createBullBoard({
|
||||||
queues: [
|
queues: [
|
||||||
new BullBoardGroupMQAdapter(eventsGroupQueue) as any,
|
...eventsGroupQueues.map(
|
||||||
|
(queue) => new BullBoardGroupMQAdapter(queue) as any,
|
||||||
|
),
|
||||||
new BullMQAdapter(sessionsQueue),
|
new BullMQAdapter(sessionsQueue),
|
||||||
new BullMQAdapter(cronQueue),
|
new BullMQAdapter(cronQueue),
|
||||||
new BullMQAdapter(notificationQueue),
|
new BullMQAdapter(notificationQueue),
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
|
||||||
import { logger as baseLogger } from '@/utils/logger';
|
import { logger as baseLogger } from '@/utils/logger';
|
||||||
import { getTime } from '@openpanel/common';
|
|
||||||
import {
|
import {
|
||||||
type IClickhouseSession,
|
type IClickhouseSession,
|
||||||
type IServiceCreateEventPayload,
|
type IServiceCreateEventPayload,
|
||||||
type IServiceEvent,
|
type IServiceEvent,
|
||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
checkNotificationRulesForSessionEnd,
|
checkNotificationRulesForSessionEnd,
|
||||||
|
convertClickhouseDateToJs,
|
||||||
createEvent,
|
createEvent,
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
@@ -65,10 +65,9 @@ export async function createSessionEnd(
|
|||||||
const logger = baseLogger.child({
|
const logger = baseLogger.child({
|
||||||
payload,
|
payload,
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
reqId: payload.properties?.__reqId ?? 'unknown',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Processing session end job');
|
logger.debug('Processing session end job');
|
||||||
|
|
||||||
const session = await sessionBuffer.getExistingSession(payload.sessionId);
|
const session = await sessionBuffer.getExistingSession(payload.sessionId);
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ export async function createSessionEnd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
handleSessionEndNotifications({
|
await handleSessionEndNotifications({
|
||||||
session,
|
session,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
@@ -103,7 +102,9 @@ export async function createSessionEnd(
|
|||||||
name: 'session_end',
|
name: 'session_end',
|
||||||
duration: session.duration ?? 0,
|
duration: session.duration ?? 0,
|
||||||
path: lastScreenView?.path ?? '',
|
path: lastScreenView?.path ?? '',
|
||||||
createdAt: new Date(getTime(session.ended_at) + 1000),
|
createdAt: new Date(
|
||||||
|
convertClickhouseDateToJs(session.ended_at).getTime() + 100,
|
||||||
|
),
|
||||||
profileId: lastScreenView?.profileId || payload.profileId,
|
profileId: lastScreenView?.profileId || payload.profileId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import {
|
|||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import type { ILogger } from '@openpanel/logger';
|
import type { ILogger } from '@openpanel/logger';
|
||||||
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
||||||
import type { Job } from 'bullmq';
|
|
||||||
import * as R from 'ramda';
|
import * as R from 'ramda';
|
||||||
import { omit } from 'ramda';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
const GLOBAL_PROPERTIES = ['__path', '__referrer'];
|
const GLOBAL_PROPERTIES = ['__path', '__referrer'];
|
||||||
@@ -33,10 +31,9 @@ const merge = <A, B>(a: Partial<A>, b: Partial<B>): A & B =>
|
|||||||
|
|
||||||
async function createEventAndNotify(
|
async function createEventAndNotify(
|
||||||
payload: IServiceCreateEventPayload,
|
payload: IServiceCreateEventPayload,
|
||||||
jobData: Job<EventsQueuePayloadIncomingEvent>['data']['payload'],
|
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
) {
|
) {
|
||||||
logger.info('Creating event', { event: payload, jobData });
|
logger.info('Creating event', { event: payload });
|
||||||
const [event] = await Promise.all([
|
const [event] = await Promise.all([
|
||||||
createEvent(payload),
|
createEvent(payload),
|
||||||
checkNotificationRulesForEvent(payload).catch(() => {}),
|
checkNotificationRulesForEvent(payload).catch(() => {}),
|
||||||
@@ -45,16 +42,7 @@ async function createEventAndNotify(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function incomingEvent(
|
export async function incomingEvent(
|
||||||
job: Job<EventsQueuePayloadIncomingEvent>,
|
|
||||||
token?: string,
|
|
||||||
) {
|
|
||||||
return incomingEventPure(job.data.payload, job, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function incomingEventPure(
|
|
||||||
jobPayload: EventsQueuePayloadIncomingEvent['payload'],
|
jobPayload: EventsQueuePayloadIncomingEvent['payload'],
|
||||||
job?: Job<EventsQueuePayloadIncomingEvent>,
|
|
||||||
token?: string,
|
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
geo,
|
geo,
|
||||||
@@ -63,6 +51,7 @@ export async function incomingEventPure(
|
|||||||
projectId,
|
projectId,
|
||||||
currentDeviceId,
|
currentDeviceId,
|
||||||
previousDeviceId,
|
previousDeviceId,
|
||||||
|
uaInfo: _uaInfo,
|
||||||
} = jobPayload;
|
} = jobPayload;
|
||||||
const properties = body.properties ?? {};
|
const properties = body.properties ?? {};
|
||||||
const reqId = headers['request-id'] ?? 'unknown';
|
const reqId = headers['request-id'] ?? 'unknown';
|
||||||
@@ -93,18 +82,17 @@ export async function incomingEventPure(
|
|||||||
const userAgent = headers['user-agent'];
|
const userAgent = headers['user-agent'];
|
||||||
const sdkName = headers['openpanel-sdk-name'];
|
const sdkName = headers['openpanel-sdk-name'];
|
||||||
const sdkVersion = headers['openpanel-sdk-version'];
|
const sdkVersion = headers['openpanel-sdk-version'];
|
||||||
const uaInfo = parseUserAgent(userAgent, properties);
|
// TODO: Remove both user-agent and parseUserAgent
|
||||||
|
const uaInfo = _uaInfo ?? parseUserAgent(userAgent, properties);
|
||||||
|
|
||||||
const baseEvent = {
|
const baseEvent = {
|
||||||
name: body.name,
|
name: body.name,
|
||||||
profileId,
|
profileId,
|
||||||
projectId,
|
projectId,
|
||||||
properties: omit(GLOBAL_PROPERTIES, {
|
properties: R.omit(GLOBAL_PROPERTIES, {
|
||||||
...properties,
|
...properties,
|
||||||
__user_agent: userAgent,
|
|
||||||
__hash: hash,
|
__hash: hash,
|
||||||
__query: query,
|
__query: query,
|
||||||
__reqId: reqId,
|
|
||||||
}),
|
}),
|
||||||
createdAt,
|
createdAt,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
@@ -161,7 +149,7 @@ export async function incomingEventPure(
|
|||||||
origin: screenView?.origin ?? baseEvent.origin,
|
origin: screenView?.origin ?? baseEvent.origin,
|
||||||
};
|
};
|
||||||
|
|
||||||
return createEventAndNotify(payload as IServiceEvent, jobPayload, logger);
|
return createEventAndNotify(payload as IServiceEvent, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionEnd = await getSessionEnd({
|
const sessionEnd = await getSessionEnd({
|
||||||
@@ -197,7 +185,7 @@ export async function incomingEventPure(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await createEventAndNotify(payload, jobPayload, logger);
|
const event = await createEventAndNotify(payload, logger);
|
||||||
|
|
||||||
if (!sessionEnd) {
|
if (!sessionEnd) {
|
||||||
logger.info('Creating session end job', { event: payload });
|
logger.info('Creating session end job', { event: payload });
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { type IServiceEvent, createEvent } from '@openpanel/db';
|
import { type IServiceEvent, createEvent } from '@openpanel/db';
|
||||||
import { eventBuffer } from '@openpanel/db';
|
import { eventBuffer } from '@openpanel/db';
|
||||||
import { sessionsQueue } from '@openpanel/queue';
|
import {
|
||||||
|
type EventsQueuePayloadIncomingEvent,
|
||||||
|
sessionsQueue,
|
||||||
|
} from '@openpanel/queue';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { incomingEvent } from './events.incoming-event';
|
import { incomingEvent } from './events.incoming-event';
|
||||||
@@ -32,6 +35,28 @@ const geo = {
|
|||||||
latitude: 0,
|
latitude: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uaInfo: EventsQueuePayloadIncomingEvent['payload']['uaInfo'] = {
|
||||||
|
isServer: false,
|
||||||
|
device: 'desktop',
|
||||||
|
os: 'Windows',
|
||||||
|
osVersion: '10',
|
||||||
|
browser: 'Chrome',
|
||||||
|
browserVersion: '91.0.4472.124',
|
||||||
|
brand: '',
|
||||||
|
model: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const uaInfoServer: EventsQueuePayloadIncomingEvent['payload']['uaInfo'] = {
|
||||||
|
isServer: true,
|
||||||
|
device: 'server',
|
||||||
|
os: '',
|
||||||
|
osVersion: '',
|
||||||
|
browser: '',
|
||||||
|
browserVersion: '',
|
||||||
|
brand: '',
|
||||||
|
model: '',
|
||||||
|
};
|
||||||
|
|
||||||
describe('incomingEvent', () => {
|
describe('incomingEvent', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -41,31 +66,29 @@ describe('incomingEvent', () => {
|
|||||||
const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add');
|
const spySessionsQueueAdd = vi.spyOn(sessionsQueue, 'add');
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
// Mock job data
|
// Mock job data
|
||||||
const jobData = {
|
const jobData: EventsQueuePayloadIncomingEvent['payload'] = {
|
||||||
payload: {
|
geo,
|
||||||
geo,
|
event: {
|
||||||
event: {
|
name: 'test_event',
|
||||||
name: 'test_event',
|
timestamp: timestamp.toISOString(),
|
||||||
timestamp: timestamp.toISOString(),
|
isTimestampFromThePast: false,
|
||||||
properties: { __path: 'https://example.com/test' },
|
properties: { __path: 'https://example.com/test' },
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'request-id': '123',
|
|
||||||
'user-agent':
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
||||||
'openpanel-sdk-name': 'web',
|
|
||||||
'openpanel-sdk-version': '1.0.0',
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
currentDeviceId,
|
|
||||||
previousDeviceId,
|
|
||||||
},
|
},
|
||||||
|
uaInfo,
|
||||||
|
headers: {
|
||||||
|
'request-id': '123',
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'openpanel-sdk-name': 'web',
|
||||||
|
'openpanel-sdk-version': '1.0.0',
|
||||||
|
},
|
||||||
|
projectId,
|
||||||
|
currentDeviceId,
|
||||||
|
previousDeviceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const job = { data: jobData } as Job;
|
|
||||||
|
|
||||||
// Execute the job
|
// Execute the job
|
||||||
await incomingEvent(job);
|
await incomingEvent(jobData);
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
name: 'test_event',
|
name: 'test_event',
|
||||||
@@ -78,8 +101,6 @@ describe('incomingEvent', () => {
|
|||||||
properties: {
|
properties: {
|
||||||
__hash: undefined,
|
__hash: undefined,
|
||||||
__query: undefined,
|
__query: undefined,
|
||||||
__user_agent: jobData.payload.headers['user-agent'],
|
|
||||||
__reqId: jobData.payload.headers['request-id'],
|
|
||||||
},
|
},
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
country: 'US',
|
country: 'US',
|
||||||
@@ -92,16 +113,16 @@ describe('incomingEvent', () => {
|
|||||||
browser: 'Chrome',
|
browser: 'Chrome',
|
||||||
browserVersion: '91.0.4472.124',
|
browserVersion: '91.0.4472.124',
|
||||||
device: 'desktop',
|
device: 'desktop',
|
||||||
brand: undefined,
|
brand: '',
|
||||||
model: undefined,
|
model: '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
path: '/test',
|
path: '/test',
|
||||||
origin: 'https://example.com',
|
origin: 'https://example.com',
|
||||||
referrer: '',
|
referrer: '',
|
||||||
referrerName: '',
|
referrerName: '',
|
||||||
referrerType: '',
|
referrerType: '',
|
||||||
sdkName: jobData.payload.headers['openpanel-sdk-name'],
|
sdkName: jobData.headers['openpanel-sdk-name'],
|
||||||
sdkVersion: jobData.payload.headers['openpanel-sdk-version'],
|
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(spySessionsQueueAdd).toHaveBeenCalledWith(
|
expect(spySessionsQueueAdd).toHaveBeenCalledWith(
|
||||||
@@ -135,29 +156,27 @@ describe('incomingEvent', () => {
|
|||||||
|
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
// Mock job data
|
// Mock job data
|
||||||
const jobData = {
|
const jobData: EventsQueuePayloadIncomingEvent['payload'] = {
|
||||||
payload: {
|
geo,
|
||||||
geo,
|
event: {
|
||||||
event: {
|
name: 'test_event',
|
||||||
name: 'test_event',
|
timestamp: timestamp.toISOString(),
|
||||||
timestamp: timestamp.toISOString(),
|
properties: { __path: 'https://example.com/test' },
|
||||||
properties: { __path: 'https://example.com/test' },
|
isTimestampFromThePast: false,
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'request-id': '123',
|
|
||||||
'user-agent':
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
||||||
'openpanel-sdk-name': 'web',
|
|
||||||
'openpanel-sdk-version': '1.0.0',
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
currentDeviceId,
|
|
||||||
previousDeviceId,
|
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
'request-id': '123',
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'openpanel-sdk-name': 'web',
|
||||||
|
'openpanel-sdk-version': '1.0.0',
|
||||||
|
},
|
||||||
|
uaInfo,
|
||||||
|
projectId,
|
||||||
|
currentDeviceId,
|
||||||
|
previousDeviceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const job = { data: jobData } as Job;
|
|
||||||
|
|
||||||
const changeDelay = vi.fn();
|
const changeDelay = vi.fn();
|
||||||
const updateData = vi.fn();
|
const updateData = vi.fn();
|
||||||
spySessionsQueueGetJob.mockResolvedValueOnce({
|
spySessionsQueueGetJob.mockResolvedValueOnce({
|
||||||
@@ -175,7 +194,7 @@ describe('incomingEvent', () => {
|
|||||||
},
|
},
|
||||||
} as Partial<Job> as Job);
|
} as Partial<Job> as Job);
|
||||||
// Execute the job
|
// Execute the job
|
||||||
await incomingEvent(job);
|
await incomingEvent(jobData);
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
name: 'test_event',
|
name: 'test_event',
|
||||||
@@ -186,8 +205,6 @@ describe('incomingEvent', () => {
|
|||||||
properties: {
|
properties: {
|
||||||
__hash: undefined,
|
__hash: undefined,
|
||||||
__query: undefined,
|
__query: undefined,
|
||||||
__user_agent: jobData.payload.headers['user-agent'],
|
|
||||||
__reqId: jobData.payload.headers['request-id'],
|
|
||||||
},
|
},
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
country: 'US',
|
country: 'US',
|
||||||
@@ -200,16 +217,16 @@ describe('incomingEvent', () => {
|
|||||||
browser: 'Chrome',
|
browser: 'Chrome',
|
||||||
browserVersion: '91.0.4472.124',
|
browserVersion: '91.0.4472.124',
|
||||||
device: 'desktop',
|
device: 'desktop',
|
||||||
brand: undefined,
|
brand: '',
|
||||||
model: undefined,
|
model: '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
path: '/test',
|
path: '/test',
|
||||||
origin: 'https://example.com',
|
origin: 'https://example.com',
|
||||||
referrer: '',
|
referrer: '',
|
||||||
referrerName: '',
|
referrerName: '',
|
||||||
referrerType: '',
|
referrerType: '',
|
||||||
sdkName: jobData.payload.headers['openpanel-sdk-name'],
|
sdkName: jobData.headers['openpanel-sdk-name'],
|
||||||
sdkVersion: jobData.payload.headers['openpanel-sdk-version'],
|
sdkVersion: jobData.headers['openpanel-sdk-version'],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(spySessionsQueueAdd).toHaveBeenCalledTimes(0);
|
expect(spySessionsQueueAdd).toHaveBeenCalledTimes(0);
|
||||||
@@ -220,29 +237,27 @@ describe('incomingEvent', () => {
|
|||||||
|
|
||||||
it('should handle server events (with existing screen view)', async () => {
|
it('should handle server events (with existing screen view)', async () => {
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
const jobData = {
|
const jobData: EventsQueuePayloadIncomingEvent['payload'] = {
|
||||||
payload: {
|
geo,
|
||||||
geo,
|
event: {
|
||||||
event: {
|
name: 'server_event',
|
||||||
name: 'server_event',
|
timestamp: timestamp.toISOString(),
|
||||||
timestamp: timestamp.toISOString(),
|
properties: { custom_property: 'test_value' },
|
||||||
properties: { custom_property: 'test_value' },
|
profileId: 'profile-123',
|
||||||
profileId: 'profile-123',
|
isTimestampFromThePast: false,
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'user-agent': 'OpenPanel Server/1.0',
|
|
||||||
'openpanel-sdk-name': 'server',
|
|
||||||
'openpanel-sdk-version': '1.0.0',
|
|
||||||
'request-id': '123',
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
currentDeviceId: '',
|
|
||||||
previousDeviceId: '',
|
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'OpenPanel Server/1.0',
|
||||||
|
'openpanel-sdk-name': 'server',
|
||||||
|
'openpanel-sdk-version': '1.0.0',
|
||||||
|
'request-id': '123',
|
||||||
|
},
|
||||||
|
projectId,
|
||||||
|
currentDeviceId: '',
|
||||||
|
previousDeviceId: '',
|
||||||
|
uaInfo: uaInfoServer,
|
||||||
};
|
};
|
||||||
|
|
||||||
const job = { data: jobData } as Job;
|
|
||||||
|
|
||||||
const mockLastScreenView = {
|
const mockLastScreenView = {
|
||||||
deviceId: 'last-device-123',
|
deviceId: 'last-device-123',
|
||||||
sessionId: 'last-session-456',
|
sessionId: 'last-session-456',
|
||||||
@@ -268,7 +283,7 @@ describe('incomingEvent', () => {
|
|||||||
mockLastScreenView as IServiceEvent,
|
mockLastScreenView as IServiceEvent,
|
||||||
);
|
);
|
||||||
|
|
||||||
await incomingEvent(job);
|
await incomingEvent(jobData);
|
||||||
|
|
||||||
expect((createEvent as Mock).mock.calls[0]![0]).toStrictEqual({
|
expect((createEvent as Mock).mock.calls[0]![0]).toStrictEqual({
|
||||||
name: 'server_event',
|
name: 'server_event',
|
||||||
@@ -278,8 +293,6 @@ describe('incomingEvent', () => {
|
|||||||
projectId,
|
projectId,
|
||||||
properties: {
|
properties: {
|
||||||
custom_property: 'test_value',
|
custom_property: 'test_value',
|
||||||
__user_agent: 'OpenPanel Server/1.0',
|
|
||||||
__reqId: '123',
|
|
||||||
__hash: undefined,
|
__hash: undefined,
|
||||||
__query: undefined,
|
__query: undefined,
|
||||||
},
|
},
|
||||||
@@ -311,33 +324,31 @@ describe('incomingEvent', () => {
|
|||||||
|
|
||||||
it('should handle server events (without existing screen view)', async () => {
|
it('should handle server events (without existing screen view)', async () => {
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
const jobData = {
|
const jobData: EventsQueuePayloadIncomingEvent['payload'] = {
|
||||||
payload: {
|
geo,
|
||||||
geo,
|
event: {
|
||||||
event: {
|
name: 'server_event',
|
||||||
name: 'server_event',
|
timestamp: timestamp.toISOString(),
|
||||||
timestamp: timestamp.toISOString(),
|
properties: { custom_property: 'test_value' },
|
||||||
properties: { custom_property: 'test_value' },
|
profileId: 'profile-123',
|
||||||
profileId: 'profile-123',
|
isTimestampFromThePast: false,
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'user-agent': 'OpenPanel Server/1.0',
|
|
||||||
'openpanel-sdk-name': 'server',
|
|
||||||
'openpanel-sdk-version': '1.0.0',
|
|
||||||
'request-id': '123',
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
currentDeviceId: '',
|
|
||||||
previousDeviceId: '',
|
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'OpenPanel Server/1.0',
|
||||||
|
'openpanel-sdk-name': 'server',
|
||||||
|
'openpanel-sdk-version': '1.0.0',
|
||||||
|
'request-id': '123',
|
||||||
|
},
|
||||||
|
projectId,
|
||||||
|
currentDeviceId: '',
|
||||||
|
previousDeviceId: '',
|
||||||
|
uaInfo: uaInfoServer,
|
||||||
};
|
};
|
||||||
|
|
||||||
const job = { data: jobData } as Job;
|
|
||||||
|
|
||||||
// Mock getLastScreenView to return null
|
// Mock getLastScreenView to return null
|
||||||
vi.mocked(eventBuffer.getLastScreenView).mockResolvedValueOnce(null);
|
vi.mocked(eventBuffer.getLastScreenView).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
await incomingEvent(job);
|
await incomingEvent(jobData);
|
||||||
|
|
||||||
expect((createEvent as Mock).mock.calls[0]![0]).toStrictEqual({
|
expect((createEvent as Mock).mock.calls[0]![0]).toStrictEqual({
|
||||||
name: 'server_event',
|
name: 'server_event',
|
||||||
@@ -347,8 +358,6 @@ describe('incomingEvent', () => {
|
|||||||
projectId,
|
projectId,
|
||||||
properties: {
|
properties: {
|
||||||
custom_property: 'test_value',
|
custom_property: 'test_value',
|
||||||
__user_agent: 'OpenPanel Server/1.0',
|
|
||||||
__reqId: '123',
|
|
||||||
__hash: undefined,
|
__hash: undefined,
|
||||||
__query: undefined,
|
__query: undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { Job } from 'bullmq';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
EventsQueuePayload,
|
|
||||||
EventsQueuePayloadIncomingEvent,
|
|
||||||
} from '@openpanel/queue';
|
|
||||||
|
|
||||||
import { incomingEvent } from './events.incoming-event';
|
|
||||||
|
|
||||||
export async function eventsJob(job: Job<EventsQueuePayload>, token?: string) {
|
|
||||||
return await incomingEvent(
|
|
||||||
job as Job<EventsQueuePayloadIncomingEvent>,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,32 @@ import client from 'prom-client';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
botBuffer,
|
botBuffer,
|
||||||
db,
|
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
profileBuffer,
|
profileBuffer,
|
||||||
sessionBuffer,
|
sessionBuffer,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { cronQueue, eventsGroupQueue, sessionsQueue } from '@openpanel/queue';
|
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
|
||||||
|
|
||||||
const Registry = client.Registry;
|
const Registry = client.Registry;
|
||||||
|
|
||||||
export const register = new Registry();
|
export const register = new Registry();
|
||||||
|
|
||||||
const queues = [sessionsQueue, cronQueue, eventsGroupQueue];
|
const queues = [sessionsQueue, cronQueue, ...eventsGroupQueues];
|
||||||
|
|
||||||
|
// Histogram to track job processing time for eventsGroupQueues
|
||||||
|
export const eventsGroupJobDuration = new client.Histogram({
|
||||||
|
name: 'job_duration_ms',
|
||||||
|
help: 'Duration of job processing (in ms)',
|
||||||
|
labelNames: ['name', 'status'],
|
||||||
|
buckets: [10, 25, 50, 100, 250, 500, 750, 1000, 2000, 5000, 10000, 30000], // 10ms to 30s
|
||||||
|
});
|
||||||
|
|
||||||
|
register.registerMetric(eventsGroupJobDuration);
|
||||||
|
|
||||||
queues.forEach((queue) => {
|
queues.forEach((queue) => {
|
||||||
register.registerMetric(
|
register.registerMetric(
|
||||||
new client.Gauge({
|
new client.Gauge({
|
||||||
name: `${queue.name}_active_count`,
|
name: `${queue.name.replace(/[\{\}]/g, '')}_active_count`,
|
||||||
help: 'Active count',
|
help: 'Active count',
|
||||||
async collect() {
|
async collect() {
|
||||||
const metric = await queue.getActiveCount();
|
const metric = await queue.getActiveCount();
|
||||||
@@ -29,7 +38,7 @@ queues.forEach((queue) => {
|
|||||||
|
|
||||||
register.registerMetric(
|
register.registerMetric(
|
||||||
new client.Gauge({
|
new client.Gauge({
|
||||||
name: `${queue.name}_delayed_count`,
|
name: `${queue.name.replace(/[\{\}]/g, '')}_delayed_count`,
|
||||||
help: 'Delayed count',
|
help: 'Delayed count',
|
||||||
async collect() {
|
async collect() {
|
||||||
const metric = await queue.getDelayedCount();
|
const metric = await queue.getDelayedCount();
|
||||||
@@ -40,7 +49,7 @@ queues.forEach((queue) => {
|
|||||||
|
|
||||||
register.registerMetric(
|
register.registerMetric(
|
||||||
new client.Gauge({
|
new client.Gauge({
|
||||||
name: `${queue.name}_failed_count`,
|
name: `${queue.name.replace(/[\{\}]/g, '')}_failed_count`,
|
||||||
help: 'Failed count',
|
help: 'Failed count',
|
||||||
async collect() {
|
async collect() {
|
||||||
const metric = await queue.getFailedCount();
|
const metric = await queue.getFailedCount();
|
||||||
@@ -51,7 +60,7 @@ queues.forEach((queue) => {
|
|||||||
|
|
||||||
register.registerMetric(
|
register.registerMetric(
|
||||||
new client.Gauge({
|
new client.Gauge({
|
||||||
name: `${queue.name}_completed_count`,
|
name: `${queue.name.replace(/[\{\}]/g, '')}_completed_count`,
|
||||||
help: 'Completed count',
|
help: 'Completed count',
|
||||||
async collect() {
|
async collect() {
|
||||||
const metric = await queue.getCompletedCount();
|
const metric = await queue.getCompletedCount();
|
||||||
@@ -62,7 +71,7 @@ queues.forEach((queue) => {
|
|||||||
|
|
||||||
register.registerMetric(
|
register.registerMetric(
|
||||||
new client.Gauge({
|
new client.Gauge({
|
||||||
name: `${queue.name}_waiting_count`,
|
name: `${queue.name.replace(/[\{\}]/g, '')}_waiting_count`,
|
||||||
help: 'Waiting count',
|
help: 'Waiting count',
|
||||||
async collect() {
|
async collect() {
|
||||||
const metric = await queue.getWaitingCount();
|
const metric = await queue.getWaitingCount();
|
||||||
|
|||||||
@@ -113,13 +113,12 @@ export async function getSessionEndJob(args: {
|
|||||||
} | null> {
|
} | null> {
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
if (state !== 'delayed') {
|
if (state !== 'delayed') {
|
||||||
logger.info(`[session-handler] Session end job is in "${state}" state`, {
|
logger.debug(`[session-handler] Session end job is in "${state}" state`, {
|
||||||
state,
|
state,
|
||||||
retryCount,
|
retryCount,
|
||||||
jobTimestamp: new Date(job.timestamp).toISOString(),
|
jobTimestamp: new Date(job.timestamp).toISOString(),
|
||||||
jobDelta: Date.now() - job.timestamp,
|
jobDelta: Date.now() - job.timestamp,
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
reqId: job.data.payload.properties?.__reqId ?? 'unknown',
|
|
||||||
payload: job.data.payload,
|
payload: job.data.payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: '3'
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
op-db:
|
op-db:
|
||||||
@@ -12,12 +12,25 @@ services:
|
|||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
- POSTGRES_PASSWORD=postgres
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
|
||||||
|
op-df:
|
||||||
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
||||||
|
container_name: op-df
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
ulimits:
|
||||||
|
memlock: -1
|
||||||
|
nofile: 65535
|
||||||
|
command:
|
||||||
|
- "--cluster_mode=emulated"
|
||||||
|
- "--lock_on_hashtags"
|
||||||
|
|
||||||
op-kv:
|
op-kv:
|
||||||
image: redis:7.2.5-alpine
|
image: redis:7.2.5-alpine
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/data/op-kv-data:/data
|
- ./docker/data/op-kv-data:/data
|
||||||
command: [ 'redis-server', '--maxmemory-policy', 'noeviction' ]
|
command: ["redis-server", "--maxmemory-policy", "noeviction"]
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openpanel/constants": "workspace:*",
|
"@openpanel/constants": "workspace:*",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
|
"lru-cache": "^11.2.2",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"mathjs": "^12.3.2",
|
"mathjs": "^12.3.2",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export const DEFAULT_HEADER_ORDER = [
|
|||||||
'forwarded',
|
'forwarded',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function isPublicIp(ip: string): boolean {
|
||||||
|
return (
|
||||||
|
!ip.startsWith('10.') &&
|
||||||
|
!ip.startsWith('172.16.') &&
|
||||||
|
!ip.startsWith('192.168.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getHeaderOrder(): string[] {
|
function getHeaderOrder(): string[] {
|
||||||
if (typeof process !== 'undefined' && process.env?.IP_HEADER_ORDER) {
|
if (typeof process !== 'undefined' && process.env?.IP_HEADER_ORDER) {
|
||||||
return process.env.IP_HEADER_ORDER.split(',').map((h) => h.trim());
|
return process.env.IP_HEADER_ORDER.split(',').map((h) => h.trim());
|
||||||
@@ -31,7 +39,7 @@ function isValidIp(ip: string): boolean {
|
|||||||
// Basic IP validation
|
// Basic IP validation
|
||||||
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
|
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}$/;
|
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(
|
export function getClientIpFromHeaders(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
const parsedServerUa = {
|
const parsedServerUa = {
|
||||||
@@ -11,8 +12,30 @@ const parsedServerUa = {
|
|||||||
model: '',
|
model: '',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Pre-compile all regex patterns for better performance
|
||||||
const IPHONE_MODEL_REGEX = /(iPhone|iPad)\s*([0-9,]+)/i;
|
const IPHONE_MODEL_REGEX = /(iPhone|iPad)\s*([0-9,]+)/i;
|
||||||
const IOS_MODEL_REGEX = /(iOS)\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 isIphone = (ua: string) => {
|
||||||
const model = ua.match(IPHONE_MODEL_REGEX);
|
const model = ua.match(IPHONE_MODEL_REGEX);
|
||||||
@@ -27,6 +50,12 @@ const isIphone = (ua: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parse = (ua: string): UAParser.IResult => {
|
const parse = (ua: string): UAParser.IResult => {
|
||||||
|
// Check cache first
|
||||||
|
const cached = parseCache.get(ua);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
const parser = new UAParser(ua);
|
const parser = new UAParser(ua);
|
||||||
const res = parser.getResult();
|
const res = parser.getResult();
|
||||||
|
|
||||||
@@ -35,7 +64,7 @@ const parse = (ua: string): UAParser.IResult => {
|
|||||||
if (!res.device.model && !res.os.name) {
|
if (!res.device.model && !res.os.name) {
|
||||||
const iphone = isIphone(ua);
|
const iphone = isIphone(ua);
|
||||||
if (iphone) {
|
if (iphone) {
|
||||||
return {
|
const result = {
|
||||||
...res,
|
...res,
|
||||||
device: {
|
device: {
|
||||||
...res.device,
|
...res.device,
|
||||||
@@ -48,27 +77,34 @@ const parse = (ua: string): UAParser.IResult => {
|
|||||||
version: iphone.osVersion,
|
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
|
// 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) {
|
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) {
|
if (osVersion) {
|
||||||
return {
|
const result = {
|
||||||
...res,
|
...res,
|
||||||
os: {
|
os: {
|
||||||
...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;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserAgentInfo = ReturnType<typeof parseUserAgent>;
|
export type UserAgentInfo = ReturnType<typeof parseUserAgent>;
|
||||||
|
export type UserAgentResult = ReturnType<typeof parseUserAgent>;
|
||||||
export function parseUserAgent(
|
export function parseUserAgent(
|
||||||
ua?: string | null,
|
ua?: string | null,
|
||||||
overrides?: Record<string, unknown>,
|
overrides?: Record<string, unknown>,
|
||||||
@@ -117,8 +153,7 @@ export function parseUserAgent(
|
|||||||
function isServer(res: UAParser.IResult) {
|
function isServer(res: UAParser.IResult) {
|
||||||
// Matches user agents like "Go-http-client/1.0" or "Go Http Client/1.0"
|
// 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
|
// It should just match the first name (with optional spaces) and version
|
||||||
const isSingleNameWithVersion = !!res.ua.match(/^[^\/]+\/[\d.]+$/);
|
if (SINGLE_NAME_VERSION_REGEX.test(res.ua)) {
|
||||||
if (isSingleNameWithVersion) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,39 +168,39 @@ function isServer(res: UAParser.IResult) {
|
|||||||
|
|
||||||
export function getDevice(ua: string) {
|
export function getDevice(ua: string) {
|
||||||
// Samsung mobile devices use SM-[A,G,N,etc]XXX pattern
|
// 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';
|
return 'mobile';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Samsung tablets use SM-TXXX pattern
|
// Samsung tablets use SM-TXXX pattern
|
||||||
if (/SM-T[0-9]+/i.test(ua)) {
|
if (SAMSUNG_TABLET_REGEX.test(ua)) {
|
||||||
return 'tablet';
|
return 'tablet';
|
||||||
}
|
}
|
||||||
|
|
||||||
// LG mobile devices use LG-XXXX pattern
|
// 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';
|
return 'mobile';
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobile1 =
|
// Check for mobile patterns
|
||||||
/(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(
|
const mobile1 = MOBILE_REGEX_1.test(ua);
|
||||||
ua,
|
const mobile2 = MOBILE_REGEX_2.test(ua.slice(0, 4));
|
||||||
);
|
|
||||||
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));
|
|
||||||
|
|
||||||
if (mobile1 || mobile2) {
|
if (mobile1 || mobile2) {
|
||||||
return 'mobile';
|
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) {
|
if (tablet) {
|
||||||
return 'tablet';
|
return 'tablet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,21 @@ export class BaseBuffer {
|
|||||||
lockKey: string;
|
lockKey: string;
|
||||||
lockTimeout = 60;
|
lockTimeout = 60;
|
||||||
onFlush: () => void;
|
onFlush: () => void;
|
||||||
|
enableParallelProcessing: boolean;
|
||||||
|
|
||||||
protected bufferCounterKey: string;
|
protected bufferCounterKey: string;
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
name: string;
|
name: string;
|
||||||
onFlush: () => Promise<void>;
|
onFlush: () => Promise<void>;
|
||||||
|
enableParallelProcessing?: boolean;
|
||||||
}) {
|
}) {
|
||||||
this.logger = createLogger({ name: options.name });
|
this.logger = createLogger({ name: options.name });
|
||||||
this.name = options.name;
|
this.name = options.name;
|
||||||
this.lockKey = `lock:${this.name}`;
|
this.lockKey = `lock:${this.name}`;
|
||||||
this.onFlush = options.onFlush;
|
this.onFlush = options.onFlush;
|
||||||
this.bufferCounterKey = `${this.name}:buffer:count`;
|
this.bufferCounterKey = `${this.name}:buffer:count`;
|
||||||
|
this.enableParallelProcessing = options.enableParallelProcessing ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected chunks<T>(items: T[], size: number) {
|
protected chunks<T>(items: T[], size: number) {
|
||||||
@@ -91,6 +94,26 @@ export class BaseBuffer {
|
|||||||
|
|
||||||
async tryFlush() {
|
async tryFlush() {
|
||||||
const now = performance.now();
|
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 lockId = generateSecureId('lock');
|
||||||
const acquired = await getRedisCache().set(
|
const acquired = await getRedisCache().set(
|
||||||
this.lockKey,
|
this.lockKey,
|
||||||
@@ -101,7 +124,7 @@ export class BaseBuffer {
|
|||||||
);
|
);
|
||||||
if (acquired === 'OK') {
|
if (acquired === 'OK') {
|
||||||
try {
|
try {
|
||||||
this.logger.info('Acquired lock. Processing buffer...', {
|
this.logger.debug('Acquired lock. Processing buffer...', {
|
||||||
lockId,
|
lockId,
|
||||||
});
|
});
|
||||||
await this.onFlush();
|
await this.onFlush();
|
||||||
@@ -117,7 +140,7 @@ export class BaseBuffer {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await this.releaseLock(lockId);
|
await this.releaseLock(lockId);
|
||||||
this.logger.info('Flush completed', {
|
this.logger.debug('Flush completed', {
|
||||||
elapsed: performance.now() - now,
|
elapsed: performance.now() - now,
|
||||||
lockId,
|
lockId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export class BotBuffer extends BaseBuffer {
|
|||||||
.decrby(this.bufferCounterKey, events.length)
|
.decrby(this.bufferCounterKey, events.length)
|
||||||
.exec();
|
.exec();
|
||||||
|
|
||||||
this.logger.info('Processed bot events', {
|
this.logger.debug('Processed bot events', {
|
||||||
count: events.length,
|
count: events.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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
|
private batchSize = process.env.PROFILE_BUFFER_BATCH_SIZE
|
||||||
? Number.parseInt(process.env.PROFILE_BUFFER_BATCH_SIZE, 10)
|
? Number.parseInt(process.env.PROFILE_BUFFER_BATCH_SIZE, 10)
|
||||||
: 200;
|
: 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
|
private chunkSize = process.env.PROFILE_BUFFER_CHUNK_SIZE
|
||||||
? Number.parseInt(process.env.PROFILE_BUFFER_CHUNK_SIZE, 10)
|
? Number.parseInt(process.env.PROFILE_BUFFER_CHUNK_SIZE, 10)
|
||||||
: 1000;
|
: 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 redisKey = 'profile-buffer';
|
||||||
private readonly redisProfilePrefix = 'profile-cache:';
|
private readonly redisProfilePrefix = 'profile-cache:';
|
||||||
@@ -49,7 +49,7 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
projectId: profile.project_id,
|
projectId: profile.project_id,
|
||||||
});
|
});
|
||||||
return (await getRedisCache().exists(cacheKey)) === 1;
|
return (await this.redis.exists(cacheKey)) === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(profile: IClickhouseProfile, isFromEvent = false) {
|
async add(profile: IClickhouseProfile, isFromEvent = false) {
|
||||||
@@ -90,9 +90,6 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
profile,
|
profile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cacheTtl = profile.is_external
|
|
||||||
? 60 * 60 * 24 * this.daysToKeep
|
|
||||||
: 60 * 60; // 1 hour for internal profiles
|
|
||||||
const cacheKey = this.getProfileCacheKey({
|
const cacheKey = this.getProfileCacheKey({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
projectId: profile.project_id,
|
projectId: profile.project_id,
|
||||||
@@ -100,7 +97,7 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
|
|
||||||
const result = await this.redis
|
const result = await this.redis
|
||||||
.multi()
|
.multi()
|
||||||
.set(cacheKey, JSON.stringify(mergedProfile), 'EX', cacheTtl)
|
.set(cacheKey, JSON.stringify(mergedProfile), 'EX', this.ttlInSeconds)
|
||||||
.rpush(this.redisKey, JSON.stringify(mergedProfile))
|
.rpush(this.redisKey, JSON.stringify(mergedProfile))
|
||||||
.incr(this.bufferCounterKey)
|
.incr(this.bufferCounterKey)
|
||||||
.llen(this.redisKey)
|
.llen(this.redisKey)
|
||||||
@@ -120,7 +117,6 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
batchSize: this.batchSize,
|
batchSize: this.batchSize,
|
||||||
});
|
});
|
||||||
if (bufferLength >= this.batchSize) {
|
if (bufferLength >= this.batchSize) {
|
||||||
this.logger.info('Buffer full, initiating flush');
|
|
||||||
await this.tryFlush();
|
await this.tryFlush();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -137,18 +133,33 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
projectId: profile.project_id,
|
projectId: profile.project_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingProfile = await getRedisCache().get(cacheKey);
|
const existingProfile = await this.fetchFromCache(
|
||||||
|
profile.id,
|
||||||
|
profile.project_id,
|
||||||
|
);
|
||||||
if (existingProfile) {
|
if (existingProfile) {
|
||||||
const parsedProfile = getSafeJson<IClickhouseProfile>(existingProfile);
|
logger.debug('Profile found in Redis');
|
||||||
if (parsedProfile) {
|
return existingProfile;
|
||||||
logger.debug('Profile found in Redis');
|
|
||||||
return parsedProfile;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fetchFromClickhouse(profile, logger);
|
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(
|
private async fetchFromClickhouse(
|
||||||
profile: IClickhouseProfile,
|
profile: IClickhouseProfile,
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
@@ -176,7 +187,7 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
|
|
||||||
async processBuffer() {
|
async processBuffer() {
|
||||||
try {
|
try {
|
||||||
this.logger.info('Starting profile buffer processing');
|
this.logger.debug('Starting profile buffer processing');
|
||||||
const profiles = await this.redis.lrange(
|
const profiles = await this.redis.lrange(
|
||||||
this.redisKey,
|
this.redisKey,
|
||||||
0,
|
0,
|
||||||
@@ -188,7 +199,7 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Processing ${profiles.length} profiles in buffer`);
|
this.logger.debug(`Processing ${profiles.length} profiles in buffer`);
|
||||||
const parsedProfiles = profiles.map((p) =>
|
const parsedProfiles = profiles.map((p) =>
|
||||||
getSafeJson<IClickhouseProfile>(p),
|
getSafeJson<IClickhouseProfile>(p),
|
||||||
);
|
);
|
||||||
@@ -208,7 +219,7 @@ export class ProfileBuffer extends BaseBuffer {
|
|||||||
.decrby(this.bufferCounterKey, profiles.length)
|
.decrby(this.bufferCounterKey, profiles.length)
|
||||||
.exec();
|
.exec();
|
||||||
|
|
||||||
this.logger.info('Successfully completed profile processing', {
|
this.logger.debug('Successfully completed profile processing', {
|
||||||
totalProfiles: profiles.length,
|
totalProfiles: profiles.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
private batchSize = process.env.SESSION_BUFFER_BATCH_SIZE
|
private batchSize = process.env.SESSION_BUFFER_BATCH_SIZE
|
||||||
? Number.parseInt(process.env.SESSION_BUFFER_BATCH_SIZE, 10)
|
? Number.parseInt(process.env.SESSION_BUFFER_BATCH_SIZE, 10)
|
||||||
: 1000;
|
: 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 readonly redisKey = 'session-buffer';
|
||||||
private redis: Redis;
|
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
|
// Insert to ClickHouse
|
||||||
await ch.insert({
|
await ch.insert({
|
||||||
table: TABLE_NAMES.sessions,
|
table: TABLE_NAMES.sessions,
|
||||||
@@ -225,7 +228,7 @@ export class SessionBuffer extends BaseBuffer {
|
|||||||
.decrby(this.bufferCounterKey, events.length);
|
.decrby(this.bufferCounterKey, events.length);
|
||||||
await multi.exec();
|
await multi.exec();
|
||||||
|
|
||||||
this.logger.info('Processed sessions', {
|
this.logger.debug('Processed sessions', {
|
||||||
count: events.length,
|
count: events.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ type WarnLogParams = LogParams & { err?: Error };
|
|||||||
|
|
||||||
class CustomLogger implements Logger {
|
class CustomLogger implements Logger {
|
||||||
trace({ message, args }: LogParams) {
|
trace({ message, args }: LogParams) {
|
||||||
logger.info(message, args);
|
logger.debug(message, args);
|
||||||
}
|
}
|
||||||
debug({ message, args }: LogParams) {
|
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) {
|
info({ message, args }: LogParams) {
|
||||||
logger.info(message, args);
|
logger.info(message, args);
|
||||||
@@ -157,8 +160,6 @@ export const ch = new Proxy(originalCh, {
|
|||||||
return (...args: any[]) =>
|
return (...args: any[]) =>
|
||||||
withRetry(() => {
|
withRetry(() => {
|
||||||
args[0].clickhouse_settings = {
|
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
|
// Increase insert timeouts and buffer sizes for large batches
|
||||||
max_execution_time: 300,
|
max_execution_time: 300,
|
||||||
max_insert_block_size: '500000',
|
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 type { Client, Prisma } from '../prisma-client';
|
||||||
import { db } 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 { db } from '../prisma-client';
|
||||||
import { type SqlBuilderObject, createSqlBuilder } from '../sql-builder';
|
import { type SqlBuilderObject, createSqlBuilder } from '../sql-builder';
|
||||||
import { getEventFiltersWhereClause } from './chart.service';
|
import { getEventFiltersWhereClause } from './chart.service';
|
||||||
import { getOrganizationByProjectIdCached } from './organization.service';
|
|
||||||
import type { IServiceProfile, IServiceUpsertProfile } from './profile.service';
|
import type { IServiceProfile, IServiceUpsertProfile } from './profile.service';
|
||||||
import {
|
import {
|
||||||
getProfileById,
|
getProfileById,
|
||||||
getProfileByIdCached,
|
|
||||||
getProfiles,
|
|
||||||
getProfilesCached,
|
getProfilesCached,
|
||||||
upsertProfile,
|
upsertProfile,
|
||||||
} from './profile.service';
|
} from './profile.service';
|
||||||
@@ -156,8 +153,6 @@ export interface IServiceEvent {
|
|||||||
properties: Record<string, unknown> & {
|
properties: Record<string, unknown> & {
|
||||||
hash?: string;
|
hash?: string;
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
__reqId?: string;
|
|
||||||
__user_agent?: string;
|
|
||||||
};
|
};
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
country?: string | undefined;
|
country?: string | undefined;
|
||||||
@@ -343,7 +338,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
|||||||
sdk_version: payload.sdkVersion ?? '',
|
sdk_version: payload.sdkVersion ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
await Promise.all([sessionBuffer.add(event), eventBuffer.add(event)]);
|
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];
|
||||||
|
|
||||||
if (payload.profileId) {
|
if (payload.profileId) {
|
||||||
const profile: IServiceUpsertProfile = {
|
const profile: IServiceUpsertProfile = {
|
||||||
@@ -374,10 +369,12 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
|||||||
profile.isExternal ||
|
profile.isExternal ||
|
||||||
(profile.isExternal === false && payload.name === 'session_start')
|
(profile.isExternal === false && payload.name === 'session_start')
|
||||||
) {
|
) {
|
||||||
await upsertProfile(profile, true);
|
promises.push(upsertProfile(profile, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: event,
|
document: event,
|
||||||
};
|
};
|
||||||
@@ -395,6 +392,7 @@ export interface GetEventListOptions {
|
|||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
select?: SelectHelper<IServiceEvent>;
|
select?: SelectHelper<IServiceEvent>;
|
||||||
custom?: (sb: SqlBuilderObject) => void;
|
custom?: (sb: SqlBuilderObject) => void;
|
||||||
|
dateIntervalInDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventList(options: GetEventListOptions) {
|
export async function getEventList(options: GetEventListOptions) {
|
||||||
@@ -408,28 +406,28 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
filters,
|
filters,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
select: incomingSelect,
|
|
||||||
custom,
|
custom,
|
||||||
|
select: incomingSelect,
|
||||||
|
dateIntervalInDays = 0.5,
|
||||||
} = options;
|
} = options;
|
||||||
const { sb, getSql, join } = createSqlBuilder();
|
const { sb, getSql, join } = createSqlBuilder();
|
||||||
|
|
||||||
const organization = await getOrganizationByProjectIdCached(projectId);
|
const MAX_DATE_INTERVAL_IN_DAYS = 365;
|
||||||
// This will speed up the query quite a lot for big organizations
|
// Cap the date interval to prevent infinity
|
||||||
const dateIntervalInDays =
|
const safeDateIntervalInDays = Math.min(
|
||||||
organization?.subscriptionPeriodEventsLimit &&
|
dateIntervalInDays,
|
||||||
organization?.subscriptionPeriodEventsLimit > 1_000_000
|
MAX_DATE_INTERVAL_IN_DAYS,
|
||||||
? 1
|
);
|
||||||
: 7;
|
|
||||||
|
|
||||||
if (typeof cursor === 'number') {
|
if (typeof cursor === 'number') {
|
||||||
sb.offset = Math.max(0, (cursor ?? 0) * take);
|
sb.offset = Math.max(0, (cursor ?? 0) * take);
|
||||||
} else if (cursor instanceof Date) {
|
} 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))}`;
|
sb.where.cursor = `created_at <= ${sqlstring.escape(formatClickhouseDate(cursor))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!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;
|
sb.limit = take;
|
||||||
@@ -453,6 +451,9 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
incomingSelect ?? {},
|
incomingSelect ?? {},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
sb.select.createdAt = 'created_at';
|
||||||
|
sb.select.projectId = 'project_id';
|
||||||
|
|
||||||
if (select.id) {
|
if (select.id) {
|
||||||
sb.select.id = 'id';
|
sb.select.id = 'id';
|
||||||
}
|
}
|
||||||
@@ -474,9 +475,6 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
if (select.properties) {
|
if (select.properties) {
|
||||||
sb.select.properties = 'properties';
|
sb.select.properties = 'properties';
|
||||||
}
|
}
|
||||||
if (select.createdAt) {
|
|
||||||
sb.select.createdAt = 'created_at';
|
|
||||||
}
|
|
||||||
if (select.country) {
|
if (select.country) {
|
||||||
sb.select.country = 'country';
|
sb.select.country = 'country';
|
||||||
}
|
}
|
||||||
@@ -583,21 +581,20 @@ export async function getEventList(options: GetEventListOptions) {
|
|||||||
custom(sb);
|
custom(sb);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('getSql()', getSql());
|
|
||||||
|
|
||||||
const data = await getEvents(getSql(), {
|
const data = await getEvents(getSql(), {
|
||||||
profile: select.profile ?? true,
|
profile: select.profile ?? true,
|
||||||
meta: select.meta ?? true,
|
meta: select.meta ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we dont get any events, try without the cursor window
|
// 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({
|
return getEventList({
|
||||||
...options,
|
...options,
|
||||||
custom(sb) {
|
dateIntervalInDays: dateIntervalInDays * 2,
|
||||||
options.custom?.(sb);
|
|
||||||
delete sb.where.cursorWindow;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,7 +942,7 @@ class EventService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (event?.profileId) {
|
if (event?.profileId) {
|
||||||
const profile = await getProfileByIdCached(event?.profileId, projectId);
|
const profile = await getProfileById(event?.profileId, projectId);
|
||||||
if (profile) {
|
if (profile) {
|
||||||
event.profile = profile;
|
event.profile = profile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
IServiceCreateEventPayload,
|
IServiceCreateEventPayload,
|
||||||
IServiceEvent,
|
IServiceEvent,
|
||||||
} from './event.service';
|
} from './event.service';
|
||||||
import { getProfileById, getProfileByIdCached } from './profile.service';
|
import { getProfileById } from './profile.service';
|
||||||
import { getProjectByIdCached } from './project.service';
|
import { getProjectByIdCached } from './project.service';
|
||||||
|
|
||||||
type ICreateNotification = Pick<
|
type ICreateNotification = Pick<
|
||||||
@@ -264,10 +264,7 @@ export async function checkNotificationRulesForEvent(
|
|||||||
payload.profileId &&
|
payload.profileId &&
|
||||||
rules.some((rule) => rule.template?.match(/{{profile\.[^}]*}}/))
|
rules.some((rule) => rule.template?.match(/{{profile\.[^}]*}}/))
|
||||||
) {
|
) {
|
||||||
const profile = await getProfileByIdCached(
|
const profile = await getProfileById(payload.profileId, payload.projectId);
|
||||||
payload.profileId,
|
|
||||||
payload.projectId,
|
|
||||||
);
|
|
||||||
if (profile) {
|
if (profile) {
|
||||||
(payload as any).profile = profile;
|
(payload as any).profile = profile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export async function getProfileById(id: string, projectId: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedProfile = await profileBuffer.fetchFromCache(id, projectId);
|
||||||
|
if (cachedProfile) {
|
||||||
|
return transformProfile(cachedProfile);
|
||||||
|
}
|
||||||
|
|
||||||
const [profile] = await chQuery<IClickhouseProfile>(
|
const [profile] = await chQuery<IClickhouseProfile>(
|
||||||
`SELECT
|
`SELECT
|
||||||
id,
|
id,
|
||||||
@@ -127,8 +132,6 @@ export async function getProfileById(id: string, projectId: string) {
|
|||||||
return transformProfile(profile);
|
return transformProfile(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProfileByIdCached = cacheable(getProfileById, 60 * 30);
|
|
||||||
|
|
||||||
interface GetProfileListOptions {
|
interface GetProfileListOptions {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
take: number;
|
take: number;
|
||||||
@@ -306,10 +309,5 @@ export async function upsertProfile(
|
|||||||
is_external: isExternal,
|
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);
|
return profileBuffer.add(profile, isFromEvent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { generateSalt } from '@openpanel/common/server';
|
import { generateSalt } from '@openpanel/common/server';
|
||||||
|
|
||||||
import { getRedisCache } from '@openpanel/redis';
|
import { cacheableLru } from '@openpanel/redis';
|
||||||
import { db } from '../prisma-client';
|
import { db } from '../prisma-client';
|
||||||
|
|
||||||
export async function getCurrentSalt() {
|
export async function getCurrentSalt() {
|
||||||
@@ -17,36 +17,36 @@ export async function getCurrentSalt() {
|
|||||||
return salt.salt;
|
return salt.salt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSalts() {
|
export const getSalts = cacheableLru(
|
||||||
const cache = await getRedisCache().get('op:salt');
|
'op:salt',
|
||||||
if (cache) {
|
async () => {
|
||||||
return JSON.parse(cache);
|
const [curr, prev] = await db.salt.findMany({
|
||||||
}
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
|
||||||
const [curr, prev] = await db.salt.findMany({
|
if (!curr) {
|
||||||
orderBy: {
|
throw new Error('No salt found');
|
||||||
createdAt: 'desc',
|
}
|
||||||
},
|
|
||||||
take: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!curr) {
|
if (!prev) {
|
||||||
throw new Error('No salt found');
|
throw new Error('No salt found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!prev) {
|
const salts = {
|
||||||
throw new Error('No salt found');
|
current: curr.salt,
|
||||||
}
|
previous: prev.salt,
|
||||||
|
};
|
||||||
|
|
||||||
const salts = {
|
return salts;
|
||||||
current: curr.salt,
|
},
|
||||||
previous: prev.salt,
|
{
|
||||||
};
|
maxSize: 2,
|
||||||
|
ttl: 60 * 5,
|
||||||
await getRedisCache().set('op:salt', JSON.stringify(salts), 'EX', 60 * 10);
|
},
|
||||||
|
);
|
||||||
return salts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createInitialSalts() {
|
export async function createInitialSalts() {
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
|
|||||||
@@ -7,14 +7,15 @@
|
|||||||
"codegen": "jiti scripts/download.ts"
|
"codegen": "jiti scripts/download.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@maxmind/geoip2-node": "^6.1.0"
|
"@maxmind/geoip2-node": "^6.1.0",
|
||||||
|
"lru-cache": "^11.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"fast-extract": "^1.4.3",
|
"fast-extract": "^1.4.3",
|
||||||
|
"jiti": "^2.4.1",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:"
|
||||||
"jiti": "^2.4.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
import type { ReaderModel } from '@maxmind/geoip2-node';
|
|
||||||
import { Reader } from '@maxmind/geoip2-node';
|
|
||||||
|
|
||||||
const filename = 'GeoLite2-City.mmdb';
|
const filename = 'GeoLite2-City.mmdb';
|
||||||
// From api or worker package
|
// From api or worker package
|
||||||
@@ -50,24 +51,37 @@ const DEFAULT_GEO: GeoLocation = {
|
|||||||
|
|
||||||
const ignore = ['127.0.0.1', '::1'];
|
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> {
|
export async function getGeoLocation(ip?: string): Promise<GeoLocation> {
|
||||||
if (!ip || ignore.includes(ip)) {
|
if (!ip || ignore.includes(ip)) {
|
||||||
return DEFAULT_GEO;
|
return DEFAULT_GEO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cached = cache.get(ip);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
await loadDatabase(dbPath);
|
await loadDatabase(dbPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await reader?.city(ip);
|
const response = await reader?.city(ip);
|
||||||
return {
|
const res = {
|
||||||
city: response?.city?.names.en,
|
city: response?.city?.names.en,
|
||||||
country: response?.country?.isoCode,
|
country: response?.country?.isoCode,
|
||||||
region: response?.subdivisions?.[0]?.names.en,
|
region: response?.subdivisions?.[0]?.names.en,
|
||||||
longitude: response?.location?.longitude,
|
longitude: response?.location?.longitude,
|
||||||
latitude: response?.location?.latitude,
|
latitude: response?.location?.latitude,
|
||||||
};
|
};
|
||||||
|
cache.set(ip, res);
|
||||||
|
return res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return DEFAULT_GEO;
|
return DEFAULT_GEO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ export { winston };
|
|||||||
export type ILogger = winston.Logger;
|
export type ILogger = winston.Logger;
|
||||||
|
|
||||||
const logLevel = process.env.LOG_LEVEL ?? 'info';
|
const logLevel = process.env.LOG_LEVEL ?? 'info';
|
||||||
|
const silent = process.env.LOG_SILENT === 'true';
|
||||||
|
|
||||||
export function createLogger({ name }: { name: string }): ILogger {
|
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) => ({
|
const prettyError = (error: Error) => ({
|
||||||
...error,
|
...error,
|
||||||
@@ -64,13 +67,9 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
return Object.assign({}, info, redactObject(info));
|
return Object.assign({}, info, redactObject(info));
|
||||||
});
|
});
|
||||||
|
|
||||||
const format = winston.format.combine(
|
const transports: winston.transport[] = [];
|
||||||
errorFormatter(),
|
let format: winston.Logform.Format;
|
||||||
redactSensitiveInfo(),
|
|
||||||
winston.format.json(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const transports: winston.transport[] = [new winston.transports.Console()];
|
|
||||||
if (process.env.HYPERDX_API_KEY) {
|
if (process.env.HYPERDX_API_KEY) {
|
||||||
transports.push(
|
transports.push(
|
||||||
HyperDX.getWinstonTransport(logLevel, {
|
HyperDX.getWinstonTransport(logLevel, {
|
||||||
@@ -78,6 +77,24 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
service,
|
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({
|
const logger = winston.createLogger({
|
||||||
@@ -85,7 +102,7 @@ export function createLogger({ name }: { name: string }): ILogger {
|
|||||||
level: logLevel,
|
level: logLevel,
|
||||||
format,
|
format,
|
||||||
transports,
|
transports,
|
||||||
silent: process.env.NODE_ENV === 'test',
|
silent,
|
||||||
// Add ISO levels of logging from PINO
|
// Add ISO levels of logging from PINO
|
||||||
levels: Object.assign(
|
levels: Object.assign(
|
||||||
{ fatal: 0, warn: 4, trace: 7 },
|
{ fatal: 0, warn: 4, trace: 7 },
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/logger": "workspace:*",
|
"@openpanel/logger": "workspace:*",
|
||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"bullmq": "^5.8.7",
|
"bullmq": "^5.63.0",
|
||||||
"groupmq": "1.0.0-next.19"
|
"groupmq": "1.1.0-next.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/sdk": "workspace:*",
|
"@openpanel/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Queue, QueueEvents } from 'bullmq';
|
import { Queue, QueueEvents } from 'bullmq';
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import type {
|
import type {
|
||||||
IServiceCreateEventPayload,
|
IServiceCreateEventPayload,
|
||||||
IServiceEvent,
|
IServiceEvent,
|
||||||
@@ -10,6 +11,21 @@ import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
|
|||||||
import type { TrackPayload } from '@openpanel/sdk';
|
import type { TrackPayload } from '@openpanel/sdk';
|
||||||
import { Queue as GroupQueue } from 'groupmq';
|
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 const queueLogger = createLogger({ name: 'queue' });
|
||||||
|
|
||||||
export interface EventsQueuePayloadIncomingEvent {
|
export interface EventsQueuePayloadIncomingEvent {
|
||||||
@@ -17,9 +33,30 @@ export interface EventsQueuePayloadIncomingEvent {
|
|||||||
payload: {
|
payload: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
event: TrackPayload & {
|
event: TrackPayload & {
|
||||||
timestamp: string;
|
timestamp: string | number;
|
||||||
isTimestampFromThePast: boolean;
|
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: {
|
geo: {
|
||||||
country: string | undefined;
|
country: string | undefined;
|
||||||
city: string | undefined;
|
city: string | undefined;
|
||||||
@@ -93,54 +130,70 @@ export type MiscQueuePayload = MiscQueuePayloadTrialEndingSoon;
|
|||||||
|
|
||||||
export type CronQueueType = CronQueuePayload['type'];
|
export type CronQueueType = CronQueuePayload['type'];
|
||||||
|
|
||||||
const orderingWindowMs = Number.parseInt(
|
const orderingDelayMs = Number.parseInt(
|
||||||
process.env.ORDERING_WINDOW_MS || '50',
|
process.env.ORDERING_DELAY_MS || '100',
|
||||||
10,
|
|
||||||
);
|
|
||||||
const orderingGracePeriodDecay = Number.parseFloat(
|
|
||||||
process.env.ORDERING_GRACE_PERIOD_DECAY || '0.9',
|
|
||||||
);
|
|
||||||
const orderingMaxWaitMultiplier = Number.parseInt(
|
|
||||||
process.env.ORDERING_MAX_WAIT_MULTIPLIER || '8',
|
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const eventsGroupQueue = new GroupQueue<
|
const autoBatchMaxWaitMs = Number.parseInt(
|
||||||
EventsQueuePayloadIncomingEvent['payload']
|
process.env.AUTO_BATCH_MAX_WAIT_MS || '0',
|
||||||
>({
|
10,
|
||||||
logger: queueLogger,
|
);
|
||||||
namespace: 'group_events',
|
const autoBatchSize = Number.parseInt(process.env.AUTO_BATCH_SIZE || '0', 10);
|
||||||
redis: getRedisGroupQueue(),
|
|
||||||
orderingMethod: 'in-memory',
|
|
||||||
orderingWindowMs,
|
|
||||||
orderingGracePeriodDecay,
|
|
||||||
orderingMaxWaitMultiplier,
|
|
||||||
keepCompleted: 10,
|
|
||||||
keepFailed: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sessionsQueue = new Queue<SessionsQueuePayload>('sessions', {
|
export const eventsGroupQueues = Array.from({
|
||||||
// @ts-ignore
|
length: EVENTS_GROUP_QUEUES_SHARDS,
|
||||||
connection: getRedisQueue(),
|
}).map(
|
||||||
defaultJobOptions: {
|
(_, index, list) =>
|
||||||
removeOnComplete: 10,
|
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', {
|
export const sessionsQueueEvents = new QueueEvents(getQueueName('sessions'), {
|
||||||
// @ts-ignore
|
|
||||||
connection: getRedisQueue(),
|
connection: getRedisQueue(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cronQueue = new Queue<CronQueuePayload>('cron', {
|
export const cronQueue = new Queue<CronQueuePayload>(getQueueName('cron'), {
|
||||||
// @ts-ignore
|
|
||||||
connection: getRedisQueue(),
|
connection: getRedisQueue(),
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
removeOnComplete: 10,
|
removeOnComplete: 10,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const miscQueue = new Queue<MiscQueuePayload>('misc', {
|
export const miscQueue = new Queue<MiscQueuePayload>(getQueueName('misc'), {
|
||||||
// @ts-ignore
|
|
||||||
connection: getRedisQueue(),
|
connection: getRedisQueue(),
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
removeOnComplete: 10,
|
removeOnComplete: 10,
|
||||||
@@ -155,9 +208,8 @@ export type NotificationQueuePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const notificationQueue = new Queue<NotificationQueuePayload>(
|
export const notificationQueue = new Queue<NotificationQueuePayload>(
|
||||||
'notification',
|
getQueueName('notification'),
|
||||||
{
|
{
|
||||||
// @ts-ignore
|
|
||||||
connection: getRedisQueue(),
|
connection: getRedisQueue(),
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
removeOnComplete: 10,
|
removeOnComplete: 10,
|
||||||
@@ -172,13 +224,16 @@ export type ImportQueuePayload = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const importQueue = new Queue<ImportQueuePayload>('import', {
|
export const importQueue = new Queue<ImportQueuePayload>(
|
||||||
connection: getRedisQueue(),
|
getQueueName('import'),
|
||||||
defaultJobOptions: {
|
{
|
||||||
removeOnComplete: 10,
|
connection: getRedisQueue(),
|
||||||
removeOnFail: 50,
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: 10,
|
||||||
|
removeOnFail: 50,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
|
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
|
||||||
return miscQueue.add(
|
return miscQueue.add(
|
||||||
|
|||||||
@@ -446,12 +446,6 @@ describe('cachable', () => {
|
|||||||
expect(cached).toBe(JSON.stringify(payload));
|
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', () => {
|
it('should throw error when expire time is not provided', () => {
|
||||||
const fn = async (arg1: string, arg2: string) => ({});
|
const fn = async (arg1: string, arg2: string) => ({});
|
||||||
expect(() => {
|
expect(() => {
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
import { getRedisCache } from './redis';
|
import { getRedisCache } from './redis';
|
||||||
|
|
||||||
export const deleteCache = async (key: string) => {
|
export const deleteCache = async (key: string) => {
|
||||||
return getRedisCache().del(key);
|
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>(
|
export async function getCache<T>(
|
||||||
key: string,
|
key: string,
|
||||||
expireInSec: number,
|
expireInSec: number,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
|
useLruCache?: boolean,
|
||||||
): Promise<T> {
|
): 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);
|
const hit = await getRedisCache().get(key);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
return JSON.parse(hit, (_, value) => {
|
const parsed = JSON.parse(hit, (_, value) => {
|
||||||
if (
|
if (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/.test(value)
|
/^\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;
|
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();
|
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;
|
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 {
|
function stringify(obj: unknown): string {
|
||||||
if (obj === null) return 'null';
|
if (obj === null) return 'null';
|
||||||
if (obj === undefined) return 'undefined';
|
if (obj === undefined) return 'undefined';
|
||||||
@@ -75,6 +128,39 @@ function hasResult(result: unknown): boolean {
|
|||||||
return true;
|
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>(
|
export function cacheable<T extends (...args: any) => any>(
|
||||||
fnOrName: T | string,
|
fnOrName: T | string,
|
||||||
fnOrExpireInSec: number | T,
|
fnOrExpireInSec: number | T,
|
||||||
@@ -87,12 +173,17 @@ export function cacheable<T extends (...args: any) => any>(
|
|||||||
: typeof fnOrExpireInSec === 'function'
|
: typeof fnOrExpireInSec === 'function'
|
||||||
? fnOrExpireInSec
|
? fnOrExpireInSec
|
||||||
: null;
|
: null;
|
||||||
const expireInSec =
|
|
||||||
typeof fnOrExpireInSec === 'number'
|
let expireInSec: number | null = null;
|
||||||
? fnOrExpireInSec
|
|
||||||
: typeof _expireInSec === 'number'
|
// Parse parameters based on function signature
|
||||||
? _expireInSec
|
if (typeof fnOrName === 'function') {
|
||||||
: null;
|
// 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') {
|
if (typeof fn !== 'function') {
|
||||||
throw new Error('fn is not a 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 cachePrefix = `cachable:${name}`;
|
||||||
const getKey = (...args: Parameters<T>) =>
|
const getKey = (...args: Parameters<T>) =>
|
||||||
`${cachePrefix}:${stringify(args)}`;
|
`${cachePrefix}:${stringify(args)}`;
|
||||||
|
|
||||||
|
// Redis-only mode: asynchronous implementation
|
||||||
const cachedFn = async (
|
const cachedFn = async (
|
||||||
...args: Parameters<T>
|
...args: Parameters<T>
|
||||||
): Promise<Awaited<ReturnType<T>>> => {
|
): Promise<Awaited<ReturnType<T>>> => {
|
||||||
// JSON.stringify here is not bullet proof since ordering of object keys matters etc
|
|
||||||
const key = getKey(...args);
|
const key = getKey(...args);
|
||||||
|
|
||||||
|
// Check Redis cache (shared across instances)
|
||||||
const cached = await getRedisCache().get(key);
|
const cached = await getRedisCache().get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
@@ -129,10 +223,15 @@ export function cacheable<T extends (...args: any) => any>(
|
|||||||
console.error('Failed to parse cache', e);
|
console.error('Failed to parse cache', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache miss: Execute function
|
||||||
const result = await fn(...(args as any));
|
const result = await fn(...(args as any));
|
||||||
|
|
||||||
if (hasResult(result)) {
|
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;
|
return result;
|
||||||
@@ -147,7 +246,134 @@ export function cacheable<T extends (...args: any) => any>(
|
|||||||
(...args: Parameters<T>) =>
|
(...args: Parameters<T>) =>
|
||||||
async (payload: Awaited<ReturnType<T>>) => {
|
async (payload: Awaited<ReturnType<T>>) => {
|
||||||
const key = getKey(...args);
|
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;
|
return cachedFn;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
"ioredis": "5.8.2"
|
"ioredis": "5.8.2",
|
||||||
|
"lru-cache": "^11.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ export const chartRouter = createTRPCRouter({
|
|||||||
'origin',
|
'origin',
|
||||||
'referrer',
|
'referrer',
|
||||||
'referrer_name',
|
'referrer_name',
|
||||||
'duration',
|
|
||||||
'created_at',
|
'created_at',
|
||||||
'country',
|
'country',
|
||||||
'city',
|
'city',
|
||||||
|
|||||||
@@ -127,23 +127,20 @@ export const eventRouter = createTRPCRouter({
|
|||||||
startDate: z.date().optional(),
|
startDate: z.date().optional(),
|
||||||
endDate: z.date().optional(),
|
endDate: z.date().optional(),
|
||||||
events: z.array(z.string()).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({
|
const items = await getEventList({
|
||||||
...input,
|
...input,
|
||||||
take: 50,
|
take: 50,
|
||||||
cursor: input.cursor ? new Date(input.cursor) : undefined,
|
cursor: input.cursor ? new Date(input.cursor) : undefined,
|
||||||
select: {
|
select: {
|
||||||
profile: true,
|
...columnVisibility,
|
||||||
properties: true,
|
city: columnVisibility?.country ?? true,
|
||||||
sessionId: true,
|
path: columnVisibility?.name ?? true,
|
||||||
deviceId: true,
|
duration: columnVisibility?.name ?? true,
|
||||||
profileId: true,
|
projectId: false,
|
||||||
referrerName: true,
|
|
||||||
referrerType: true,
|
|
||||||
referrer: true,
|
|
||||||
origin: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,9 +188,10 @@ export const eventRouter = createTRPCRouter({
|
|||||||
startDate: z.date().optional(),
|
startDate: z.date().optional(),
|
||||||
endDate: z.date().optional(),
|
endDate: z.date().optional(),
|
||||||
events: z.array(z.string()).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 conversions = await getConversionEventNames(input.projectId);
|
||||||
const filteredConversions = conversions.filter((event) => {
|
const filteredConversions = conversions.filter((event) => {
|
||||||
if (input.events && input.events.length > 0) {
|
if (input.events && input.events.length > 0) {
|
||||||
@@ -216,15 +214,11 @@ export const eventRouter = createTRPCRouter({
|
|||||||
take: 50,
|
take: 50,
|
||||||
cursor: input.cursor ? new Date(input.cursor) : undefined,
|
cursor: input.cursor ? new Date(input.cursor) : undefined,
|
||||||
select: {
|
select: {
|
||||||
profile: true,
|
...columnVisibility,
|
||||||
properties: true,
|
city: columnVisibility?.country ?? true,
|
||||||
sessionId: true,
|
path: columnVisibility?.name ?? true,
|
||||||
deviceId: true,
|
duration: columnVisibility?.name ?? true,
|
||||||
profileId: true,
|
projectId: false,
|
||||||
referrerName: true,
|
|
||||||
referrerType: true,
|
|
||||||
referrer: true,
|
|
||||||
origin: true,
|
|
||||||
},
|
},
|
||||||
custom: (sb) => {
|
custom: (sb) => {
|
||||||
sb.where.name = `name IN (${filteredConversions.map((event) => sqlstring.escape(event.name)).join(',')})`;
|
sb.where.name = `name IN (${filteredConversions.map((event) => sqlstring.escape(event.name)).join(',')})`;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
chQuery,
|
chQuery,
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
getProfileByIdCached,
|
getProfileById,
|
||||||
getProfileList,
|
getProfileList,
|
||||||
getProfileListCount,
|
getProfileListCount,
|
||||||
getProfileMetrics,
|
getProfileMetrics,
|
||||||
@@ -19,7 +19,7 @@ export const profileRouter = createTRPCRouter({
|
|||||||
byId: protectedProcedure
|
byId: protectedProcedure
|
||||||
.input(z.object({ profileId: z.string(), projectId: z.string() }))
|
.input(z.object({ profileId: z.string(), projectId: z.string() }))
|
||||||
.query(async ({ input: { profileId, projectId } }) => {
|
.query(async ({ input: { profileId, projectId } }) => {
|
||||||
return getProfileByIdCached(profileId, projectId);
|
return getProfileById(profileId, projectId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
metrics: protectedProcedure
|
metrics: protectedProcedure
|
||||||
|
|||||||
@@ -62,10 +62,12 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
path: string;
|
path: string;
|
||||||
count: number;
|
count: number;
|
||||||
avg_duration: number;
|
avg_duration: number;
|
||||||
|
unique_sessions: number;
|
||||||
}>([
|
}>([
|
||||||
'origin',
|
'origin',
|
||||||
'path',
|
'path',
|
||||||
'COUNT(*) as count',
|
'COUNT(*) as count',
|
||||||
|
'COUNT(DISTINCT session_id) as unique_sessions',
|
||||||
'round(avg(duration)/1000, 2) as avg_duration',
|
'round(avg(duration)/1000, 2) as avg_duration',
|
||||||
])
|
])
|
||||||
.from(TABLE_NAMES.events)
|
.from(TABLE_NAMES.events)
|
||||||
@@ -91,9 +93,11 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
referrer_name: string;
|
referrer_name: string;
|
||||||
count: number;
|
count: number;
|
||||||
avg_duration: number;
|
avg_duration: number;
|
||||||
|
unique_sessions: number;
|
||||||
}>([
|
}>([
|
||||||
'referrer_name',
|
'referrer_name',
|
||||||
'COUNT(*) as count',
|
'COUNT(*) as count',
|
||||||
|
'COUNT(DISTINCT session_id) as unique_sessions',
|
||||||
'round(avg(duration)/1000, 2) as avg_duration',
|
'round(avg(duration)/1000, 2) as avg_duration',
|
||||||
])
|
])
|
||||||
.from(TABLE_NAMES.events)
|
.from(TABLE_NAMES.events)
|
||||||
@@ -120,10 +124,12 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
city: string;
|
city: string;
|
||||||
count: number;
|
count: number;
|
||||||
avg_duration: number;
|
avg_duration: number;
|
||||||
|
unique_sessions: number;
|
||||||
}>([
|
}>([
|
||||||
'country',
|
'country',
|
||||||
'city',
|
'city',
|
||||||
'COUNT(*) as count',
|
'COUNT(*) as count',
|
||||||
|
'COUNT(DISTINCT session_id) as unique_sessions',
|
||||||
'round(avg(duration)/1000, 2) as avg_duration',
|
'round(avg(duration)/1000, 2) as avg_duration',
|
||||||
])
|
])
|
||||||
.from(TABLE_NAMES.events)
|
.from(TABLE_NAMES.events)
|
||||||
|
|||||||
1431
pnpm-lock.yaml
generated
1431
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user