294 lines
8.1 KiB
TypeScript
294 lines
8.1 KiB
TypeScript
import { isBot } from '@/bots';
|
|
import { getClientIp, parseIp } from '@/utils/parseIp';
|
|
import { getReferrerWithQuery, parseReferrer } from '@/utils/parseReferrer';
|
|
import { isUserAgentSet, parseUserAgent } from '@/utils/parseUserAgent';
|
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
import { omit } from 'ramda';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
import { generateDeviceId, getTime, toISOString } from '@mixan/common';
|
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
|
import { createBotEvent, createEvent, getEvents, getSalts } from '@mixan/db';
|
|
import type { JobsOptions } from '@mixan/queue';
|
|
import { eventsQueue, findJobByPrefix } from '@mixan/queue';
|
|
import type { PostEventPayload } from '@mixan/sdk';
|
|
|
|
const SESSION_TIMEOUT = 1000 * 60 * 30;
|
|
const SESSION_END_TIMEOUT = SESSION_TIMEOUT + 1000;
|
|
|
|
function parseSearchParams(
|
|
params: URLSearchParams
|
|
): Record<string, string> | undefined {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of params.entries()) {
|
|
result[key] = value;
|
|
}
|
|
return Object.keys(result).length ? result : undefined;
|
|
}
|
|
|
|
function parsePath(path?: string): {
|
|
query?: Record<string, string>;
|
|
path: string;
|
|
hash?: string;
|
|
} {
|
|
if (!path) {
|
|
return {
|
|
path: '',
|
|
};
|
|
}
|
|
|
|
try {
|
|
const url = new URL(path);
|
|
return {
|
|
query: parseSearchParams(url.searchParams),
|
|
path: url.pathname,
|
|
hash: url.hash || undefined,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
path,
|
|
};
|
|
}
|
|
}
|
|
|
|
function isSameDomain(url1: string | undefined, url2: string | undefined) {
|
|
if (!url1 || !url2) {
|
|
return false;
|
|
}
|
|
try {
|
|
return new URL(url1).hostname === new URL(url2).hostname;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function postEvent(
|
|
request: FastifyRequest<{
|
|
Body: PostEventPayload;
|
|
}>,
|
|
reply: FastifyReply
|
|
) {
|
|
let deviceId: string | null = null;
|
|
const { projectId, body } = request;
|
|
const properties = body.properties ?? {};
|
|
const getProperty = (name: string): string | undefined => {
|
|
return (properties[name] as string | null | undefined) ?? undefined;
|
|
};
|
|
const profileId = body.profileId ?? '';
|
|
const createdAt = new Date(body.timestamp);
|
|
const url = getProperty('__path');
|
|
const { path, hash, query } = parsePath(url);
|
|
const referrer = isSameDomain(getProperty('__referrer'), url)
|
|
? null
|
|
: parseReferrer(getProperty('__referrer'));
|
|
const utmReferrer = getReferrerWithQuery(query);
|
|
const ip = getClientIp(request)!;
|
|
const origin = request.headers.origin!;
|
|
const ua = request.headers['user-agent']!;
|
|
const uaInfo = parseUserAgent(ua);
|
|
const salts = await getSalts();
|
|
const currentDeviceId = generateDeviceId({
|
|
salt: salts.current,
|
|
origin,
|
|
ip,
|
|
ua,
|
|
});
|
|
const previousProfileId = generateDeviceId({
|
|
salt: salts.previous,
|
|
origin,
|
|
ip,
|
|
ua,
|
|
});
|
|
|
|
const isServerEvent = !ip && !origin && !isUserAgentSet(ua);
|
|
|
|
if (isServerEvent) {
|
|
const [event] = await getEvents(
|
|
`SELECT * FROM events WHERE name = 'screen_view' AND profile_id = '${profileId}' AND project_id = '${projectId}' ORDER BY created_at DESC LIMIT 1`
|
|
);
|
|
|
|
eventsQueue.add('event', {
|
|
type: 'createEvent',
|
|
payload: {
|
|
name: body.name,
|
|
deviceId: event?.deviceId || '',
|
|
sessionId: event?.sessionId || '',
|
|
profileId,
|
|
projectId,
|
|
properties: Object.assign(
|
|
{},
|
|
omit(['__path', '__referrer'], properties),
|
|
{
|
|
hash,
|
|
query,
|
|
}
|
|
),
|
|
createdAt,
|
|
country: event?.country ?? '',
|
|
city: event?.city ?? '',
|
|
region: event?.region ?? '',
|
|
continent: event?.continent ?? '',
|
|
os: event?.os ?? '',
|
|
osVersion: event?.osVersion ?? '',
|
|
browser: event?.browser ?? '',
|
|
browserVersion: event?.browserVersion ?? '',
|
|
device: event?.device ?? '',
|
|
brand: event?.brand ?? '',
|
|
model: event?.model ?? '',
|
|
duration: 0,
|
|
path: event?.path ?? '',
|
|
referrer: event?.referrer ?? '',
|
|
referrerName: event?.referrerName ?? '',
|
|
referrerType: event?.referrerType ?? '',
|
|
profile: undefined,
|
|
meta: undefined,
|
|
},
|
|
});
|
|
return reply.status(200).send('');
|
|
}
|
|
|
|
const bot = isBot(ua);
|
|
if (bot) {
|
|
await createBotEvent({
|
|
...bot,
|
|
projectId,
|
|
createdAt: new Date(body.timestamp),
|
|
});
|
|
return reply.status(200).send('');
|
|
}
|
|
|
|
const [geo, eventsJobs, events] = await Promise.all([
|
|
parseIp(ip),
|
|
eventsQueue.getJobs(['delayed']),
|
|
getEvents(
|
|
`SELECT * FROM events WHERE name = 'session_start' AND profile_id = '${profileId}' AND project_id = '${projectId}' ORDER BY created_at DESC LIMIT 1`
|
|
),
|
|
]);
|
|
|
|
const sessionStartEvent = events[0];
|
|
|
|
// find session_end job
|
|
const sessionEndJobCurrentDeviceId = findJobByPrefix(
|
|
eventsJobs,
|
|
`sessionEnd:${projectId}:${currentDeviceId}:`
|
|
);
|
|
const sessionEndJobPreviousDeviceId = findJobByPrefix(
|
|
eventsJobs,
|
|
`sessionEnd:${projectId}:${previousProfileId}:`
|
|
);
|
|
|
|
const createSessionStart =
|
|
!sessionEndJobCurrentDeviceId && !sessionEndJobPreviousDeviceId;
|
|
|
|
if (sessionEndJobCurrentDeviceId && !sessionEndJobPreviousDeviceId) {
|
|
console.log('found session current');
|
|
deviceId = currentDeviceId;
|
|
const diff = Date.now() - sessionEndJobCurrentDeviceId.timestamp;
|
|
sessionEndJobCurrentDeviceId.changeDelay(diff + SESSION_END_TIMEOUT);
|
|
} else if (!sessionEndJobCurrentDeviceId && sessionEndJobPreviousDeviceId) {
|
|
console.log('found session previous');
|
|
deviceId = previousProfileId;
|
|
const diff = Date.now() - sessionEndJobPreviousDeviceId.timestamp;
|
|
sessionEndJobPreviousDeviceId.changeDelay(diff + SESSION_END_TIMEOUT);
|
|
} else {
|
|
console.log('new session with current');
|
|
deviceId = currentDeviceId;
|
|
// Queue session end
|
|
eventsQueue.add(
|
|
'event',
|
|
{
|
|
type: 'createSessionEnd',
|
|
payload: {
|
|
deviceId,
|
|
},
|
|
},
|
|
{
|
|
delay: SESSION_END_TIMEOUT,
|
|
jobId: `sessionEnd:${projectId}:${deviceId}:${Date.now()}`,
|
|
}
|
|
);
|
|
}
|
|
|
|
const payload: Omit<IServiceCreateEventPayload, 'id'> = {
|
|
name: body.name,
|
|
deviceId,
|
|
profileId,
|
|
projectId,
|
|
sessionId: createSessionStart ? uuid() : sessionStartEvent?.sessionId ?? '',
|
|
properties: Object.assign({}, omit(['__path', '__referrer'], properties), {
|
|
hash,
|
|
query,
|
|
}),
|
|
createdAt,
|
|
country: geo.country,
|
|
city: geo.city,
|
|
region: geo.region,
|
|
continent: geo.continent,
|
|
os: uaInfo.os,
|
|
osVersion: uaInfo.osVersion,
|
|
browser: uaInfo.browser,
|
|
browserVersion: uaInfo.browserVersion,
|
|
device: uaInfo.device,
|
|
brand: uaInfo.brand,
|
|
model: uaInfo.model,
|
|
duration: 0,
|
|
path: path,
|
|
referrer: referrer?.url,
|
|
referrerName: referrer?.name ?? utmReferrer?.name ?? '',
|
|
referrerType: referrer?.type ?? utmReferrer?.type ?? '',
|
|
profile: undefined,
|
|
meta: undefined,
|
|
};
|
|
|
|
const job = findJobByPrefix(eventsJobs, `event:${projectId}:${deviceId}:`);
|
|
|
|
if (job?.isDelayed && job.data.type === 'createEvent') {
|
|
const prevEvent = job.data.payload;
|
|
const duration = getTime(payload.createdAt) - getTime(prevEvent.createdAt);
|
|
|
|
// Set path from prev screen_view event if current event is not a screen_view
|
|
if (payload.name != 'screen_view') {
|
|
payload.path = prevEvent.path;
|
|
}
|
|
|
|
if (payload.name === 'screen_view') {
|
|
await job.updateData({
|
|
type: 'createEvent',
|
|
payload: {
|
|
...prevEvent,
|
|
duration,
|
|
},
|
|
});
|
|
await job.promote();
|
|
}
|
|
}
|
|
|
|
if (createSessionStart) {
|
|
// We do not need to queue session_start
|
|
await createEvent({
|
|
...payload,
|
|
name: 'session_start',
|
|
// @ts-expect-error
|
|
createdAt: toISOString(getTime(payload.createdAt) - 10),
|
|
});
|
|
}
|
|
|
|
const options: JobsOptions = {};
|
|
if (payload.name === 'screen_view') {
|
|
options.delay = SESSION_TIMEOUT;
|
|
options.jobId = `event:${projectId}:${deviceId}:${Date.now()}`;
|
|
}
|
|
|
|
// Queue current event
|
|
eventsQueue.add(
|
|
'event',
|
|
{
|
|
type: 'createEvent',
|
|
payload,
|
|
},
|
|
options
|
|
);
|
|
|
|
reply.status(202).send(deviceId);
|
|
}
|