fix: better validation of events + clean up (#267)

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-07 11:58:11 +01:00
parent 6d9e3ce8e5
commit 3c085e445d
22 changed files with 387 additions and 387 deletions

View File

@@ -38,11 +38,9 @@ COPY packages/redis/package.json packages/redis/
COPY packages/logger/package.json packages/logger/ COPY packages/logger/package.json packages/logger/
COPY packages/common/package.json packages/common/ COPY packages/common/package.json packages/common/
COPY packages/payments/package.json packages/payments/ COPY packages/payments/package.json packages/payments/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/constants/package.json packages/constants/ COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/ COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/ COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY patches ./patches COPY patches ./patches
# BUILD # BUILD
@@ -107,7 +105,6 @@ COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/payments ./packages/payments COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations COPY --from=build /app/packages/integrations ./packages/integrations

View File

@@ -52,7 +52,6 @@
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^9.0.1", "@faker-js/faker": "^9.0.1",
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*", "@openpanel/tsconfig": "workspace:*",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",

View File

@@ -3,15 +3,15 @@ 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 { getEventsGroupQueueShard } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk';
import { generateId, slug } from '@openpanel/common'; import { generateId, slug } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo'; import { getGeoLocation } from '@openpanel/geo';
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
import { getStringHeaders, getTimestamp } from './track.controller'; import { getStringHeaders, getTimestamp } from './track.controller';
export async function postEvent( export async function postEvent(
request: FastifyRequest<{ request: FastifyRequest<{
Body: PostEventPayload; Body: DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {

View File

@@ -5,13 +5,13 @@ 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';
import type { import type {
IncrementProfilePayload, DeprecatedIncrementProfilePayload,
UpdateProfilePayload, DeprecatedUpdateProfilePayload,
} from '@openpanel/sdk'; } from '@openpanel/validation';
export async function updateProfile( export async function updateProfile(
request: FastifyRequest<{ request: FastifyRequest<{
Body: UpdateProfilePayload; Body: DeprecatedUpdateProfilePayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {
@@ -52,7 +52,7 @@ export async function updateProfile(
export async function incrementProfileProperty( export async function incrementProfileProperty(
request: FastifyRequest<{ request: FastifyRequest<{
Body: IncrementProfilePayload; Body: DeprecatedIncrementProfilePayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {
@@ -94,7 +94,7 @@ export async function incrementProfileProperty(
export async function decrementProfileProperty( export async function decrementProfileProperty(
request: FastifyRequest<{ request: FastifyRequest<{
Body: IncrementProfilePayload; Body: DeprecatedIncrementProfilePayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {

View File

@@ -8,13 +8,15 @@ import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import type {
DecrementPayload, import {
IdentifyPayload, type IDecrementPayload,
IncrementPayload, type IIdentifyPayload,
TrackHandlerPayload, type IIncrementPayload,
TrackPayload, type ITrackHandlerPayload,
} from '@openpanel/sdk'; type ITrackPayload,
zTrackHandlerPayload,
} from '@openpanel/validation';
export function getStringHeaders(headers: FastifyRequest['headers']) { export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries( return Object.entries(
@@ -37,25 +39,28 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
); );
} }
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined { function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
const identity = if (body.type === 'track') {
'properties' in body.payload const identity = body.payload.properties?.__identify as
? (body.payload?.properties?.__identify as IdentifyPayload | undefined) | IIdentifyPayload
: undefined; | undefined;
return ( return (
identity || identity ||
(body?.payload?.profileId (body.payload.profileId
? { ? {
profileId: body.payload.profileId, profileId: body.payload.profileId,
} }
: undefined) : undefined)
); );
}
return undefined;
} }
export function getTimestamp( export function getTimestamp(
timestamp: FastifyRequest['timestamp'], timestamp: FastifyRequest['timestamp'],
payload: TrackHandlerPayload['payload'], payload: ITrackHandlerPayload['payload'],
) { ) {
const safeTimestamp = timestamp || Date.now(); const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp = const userDefinedTimestamp =
@@ -82,7 +87,7 @@ export function getTimestamp(
return { timestamp: safeTimestamp, isTimestampFromThePast: false }; return { timestamp: safeTimestamp, isTimestampFromThePast: false };
} }
// isTimestampFromThePast is true only if timestamp is older than 1 hour // isTimestampFromThePast is true only if timestamp is older than 15 minutes
const isTimestampFromThePast = const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS; clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
@@ -92,170 +97,113 @@ export function getTimestamp(
}; };
} }
export async function handler( interface TrackContext {
request: FastifyRequest<{ projectId: string;
Body: TrackHandlerPayload; ip: string;
}>, ua?: string;
reply: FastifyReply, headers: Record<string, string | undefined>;
) { timestamp: { value: number; isFromPast: boolean };
const timestamp = getTimestamp(request.timestamp, request.body.payload); identity?: IIdentifyPayload;
const ip = currentDeviceId?: string;
'properties' in request.body.payload && previousDeviceId?: string;
request.body.payload.properties?.__ip geo: GeoLocation;
? (request.body.payload.properties.__ip as string) }
: request.clientIp;
const ua = request.headers['user-agent'];
const projectId = request.client?.projectId;
async function buildContext(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
validatedBody: ITrackHandlerPayload,
): Promise<TrackContext> {
const projectId = request.client?.projectId;
if (!projectId) { if (!projectId) {
return reply.status(400).send({ throw new HttpError('Missing projectId', { status: 400 });
status: 400,
error: 'Bad Request',
message: 'Missing projectId',
});
} }
const identity = getIdentity(request.body); const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
const ip =
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
? (validatedBody.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'];
const headers = getStringHeaders(request.headers);
const identity = getIdentity(validatedBody);
const profileId = identity?.profileId; const profileId = identity?.profileId;
const overrideDeviceId = (() => {
const deviceId =
'properties' in request.body.payload
? request.body.payload.properties?.__deviceId
: undefined;
if (typeof deviceId === 'string') {
return deviceId;
}
return undefined;
})();
// We might get a profileId from the alias table // We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload // If we do, we should use that instead of the one from the payload
if (profileId) { if (profileId && validatedBody.type === 'track') {
request.body.payload.profileId = profileId; validatedBody.payload.profileId = profileId;
} }
switch (request.body.type) { // Get geo location (needed for track and identify)
case 'track': { const geo = await getGeoLocation(ip);
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = // Generate device IDs if needed (for track)
overrideDeviceId || let currentDeviceId: string | undefined;
(ua let previousDeviceId: string | undefined;
? generateDeviceId({
salt: salts.current, if (validatedBody.type === 'track') {
origin: projectId, const overrideDeviceId =
ip, typeof validatedBody.payload.properties?.__deviceId === 'string'
ua, ? validatedBody.payload.properties.__deviceId
}) : undefined;
: '');
const previousDeviceId = ua const [salts] = await Promise.all([getSalts()]);
currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({ ? generateDeviceId({
salt: salts.previous, salt: salts.current,
origin: projectId, origin: projectId,
ip, ip,
ua, ua,
}) })
: ''; : '');
previousDeviceId = ua
const promises = []; ? generateDeviceId({
salt: salts.previous,
// If we have more than one property in the identity object, we should identify the user origin: projectId,
// Otherwise its only a profileId and we should not identify the user ip,
if (identity && Object.keys(identity).length > 1) { ua,
promises.push( })
identify({ : '';
payload: identity,
projectId,
geo,
ua,
}),
);
}
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);
break;
}
case 'identify': {
const payload = request.body.payload;
const geo = await getGeoLocation(ip);
if (!payload.profileId) {
throw new HttpError('Missing profileId', {
status: 400,
});
}
await identify({
payload,
projectId,
geo,
ua,
});
break;
}
case 'alias': {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
case 'increment': {
await increment({
payload: request.body.payload,
projectId,
});
break;
}
case 'decrement': {
await decrement({
payload: request.body.payload,
projectId,
});
break;
}
default: {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
} }
reply.status(200).send(); return {
projectId,
ip,
ua,
headers,
timestamp: {
value: timestamp.timestamp,
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
currentDeviceId,
previousDeviceId,
geo,
};
} }
async function track({ async function handleTrack(
payload, payload: ITrackPayload,
currentDeviceId, context: TrackContext,
previousDeviceId, ): Promise<void> {
projectId, const {
geo, projectId,
headers, currentDeviceId,
timestamp, previousDeviceId,
isTimestampFromThePast, geo,
}: { headers,
payload: TrackPayload; timestamp,
currentDeviceId: string; } = context;
previousDeviceId: string;
projectId: string; if (!currentDeviceId || !previousDeviceId) {
geo: GeoLocation; throw new HttpError('Device ID generation failed', { status: 500 });
headers: Record<string, string | undefined>; }
timestamp: number;
isTimestampFromThePast: boolean;
}) {
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties); const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
? payload.profileId ? payload.profileId
@@ -264,44 +212,51 @@ async function track({
: currentDeviceId; : currentDeviceId;
const jobId = [ const jobId = [
slug(payload.name), slug(payload.name),
timestamp, timestamp.value,
projectId, projectId,
currentDeviceId, currentDeviceId,
groupId, groupId,
] ]
.filter(Boolean) .filter(Boolean)
.join('-'); .join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp, const promises = [];
data: {
projectId, // If we have more than one property in the identity object, we should identify the user
headers, // Otherwise its only a profileId and we should not identify the user
event: { if (context.identity && Object.keys(context.identity).length > 1) {
...payload, promises.push(handleIdentify(context.identity, context));
timestamp, }
isTimestampFromThePast,
promises.push(
getEventsGroupQueueShard(groupId).add({
orderMs: timestamp.value,
data: {
projectId,
headers,
event: {
...payload,
timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
}, },
uaInfo, groupId,
geo, jobId,
currentDeviceId, }),
previousDeviceId, );
},
groupId, await Promise.all(promises);
jobId,
});
} }
async function identify({ async function handleIdentify(
payload, payload: IIdentifyPayload,
projectId, context: TrackContext,
geo, ): Promise<void> {
ua, const { projectId, geo, ua } = context;
}: {
payload: IdentifyPayload;
projectId: string;
geo: GeoLocation;
ua?: string;
}) {
const uaInfo = parseUserAgent(ua, payload.properties); const uaInfo = parseUserAgent(ua, payload.properties);
await upsertProfile({ await upsertProfile({
...payload, ...payload,
@@ -326,17 +281,15 @@ async function identify({
}); });
} }
async function increment({ async function adjustProfileProperty(
payload, payload: IIncrementPayload | IDecrementPayload,
projectId, projectId: string,
}: { direction: 1 | -1,
payload: IncrementPayload; ): Promise<void> {
projectId: string;
}) {
const { profileId, property, value } = payload; const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId); const profile = await getProfileById(profileId, projectId);
if (!profile) { if (!profile) {
throw new Error('Not found'); throw new HttpError('Profile not found', { status: 404 });
} }
const parsed = Number.parseInt( const parsed = Number.parseInt(
@@ -345,12 +298,12 @@ async function increment({
); );
if (Number.isNaN(parsed)) { if (Number.isNaN(parsed)) {
throw new Error('Not number'); throw new HttpError('Property value is not a number', { status: 400 });
} }
profile.properties = assocPath( profile.properties = assocPath(
property.split('.'), property.split('.'),
parsed + (value || 1), parsed + direction * (value || 1),
profile.properties, profile.properties,
); );
@@ -362,40 +315,74 @@ async function increment({
}); });
} }
async function decrement({ async function handleIncrement(
payload, payload: IIncrementPayload,
projectId, context: TrackContext,
}: { ): Promise<void> {
payload: DecrementPayload; await adjustProfileProperty(payload, context.projectId, 1);
projectId: string; }
}) {
const { profileId, property, value } = payload; async function handleDecrement(
const profile = await getProfileById(profileId, projectId); payload: IDecrementPayload,
if (!profile) { context: TrackContext,
throw new Error('Not found'); ): Promise<void> {
await adjustProfileProperty(payload, context.projectId, -1);
}
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
reply: FastifyReply,
) {
// Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body);
if (!validationResult.success) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Validation failed',
errors: validationResult.error.errors,
});
} }
const parsed = Number.parseInt( const validatedBody = validationResult.data;
pathOr<string>('0', property.split('.'), profile.properties),
10,
);
if (Number.isNaN(parsed)) { // Handle alias (not supported)
throw new Error('Not number'); if (validatedBody.type === 'alias') {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
} }
profile.properties = assocPath( // Build request context
property.split('.'), const context = await buildContext(request, validatedBody);
parsed - (value || 1),
profile.properties,
);
await upsertProfile({ // Dispatch to appropriate handler
id: profile.id, switch (validatedBody.type) {
projectId, case 'track':
properties: profile.properties, await handleTrack(validatedBody.payload, context);
isExternal: true, break;
}); case 'identify':
await handleIdentify(validatedBody.payload, context);
break;
case 'increment':
await handleIncrement(validatedBody.payload, context);
break;
case 'decrement':
await handleDecrement(validatedBody.payload, context);
break;
default:
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
reply.status(200).send();
} }
export async function fetchDeviceId( export async function fetchDeviceId(

View File

@@ -1,10 +1,13 @@
import { SdkAuthError, validateSdkRequest } from '@/utils/auth'; import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
export async function clientHook( export async function clientHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {

View File

@@ -1,10 +1,13 @@
import { isDuplicatedEvent } from '@/utils/deduplicate'; import { isDuplicatedEvent } from '@/utils/deduplicate';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
export async function duplicateHook( export async function duplicateHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {

View File

@@ -1,17 +1,15 @@
import { isBot } from '@/bots'; import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db'; import { createBotEvent } from '@openpanel/db';
import type { TrackHandlerPayload } from '@openpanel/sdk'; import type {
import type { FastifyReply, FastifyRequest } from 'fastify'; DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
type DeprecatedEventPayload = { import type { FastifyReply, FastifyRequest } from 'fastify';
name: string;
properties: Record<string, unknown>;
timestamp: string;
};
export async function isBotHook( export async function isBotHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: TrackHandlerPayload | DeprecatedEventPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {

View File

@@ -14,22 +14,6 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
method: 'POST', method: 'POST',
url: '/', url: '/',
handler: handler, handler: handler,
schema: {
body: {
type: 'object',
required: ['type', 'payload'],
properties: {
type: {
type: 'string',
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
},
payload: {
type: 'object',
additionalProperties: true,
},
},
},
},
}); });
fastify.route({ fastify.route({

View File

@@ -4,10 +4,11 @@ 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 { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { import type {
DeprecatedPostEventPayload,
IProjectFilterIp, IProjectFilterIp,
IProjectFilterProfileId, IProjectFilterProfileId,
ITrackHandlerPayload,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { path } from 'ramda'; import { path } from 'ramda';
@@ -41,7 +42,7 @@ export class SdkAuthError extends Error {
export async function validateSdkRequest( export async function validateSdkRequest(
req: FastifyRequest<{ req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
): Promise<IServiceClientWithProject> { ): Promise<IServiceClientWithProject> {
const { headers, clientIp } = req; const { headers, clientIp } = req;

View File

@@ -34,7 +34,6 @@ COPY packages/payments/package.json packages/payments/
COPY packages/constants/package.json packages/constants/ COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/ COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/ COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/sdks/_info/package.json packages/sdks/_info/ COPY packages/sdks/_info/package.json packages/sdks/_info/
COPY patches ./patches COPY patches ./patches
# Copy tracking script to self-hosting dashboard # Copy tracking script to self-hosting dashboard
@@ -93,7 +92,6 @@ COPY --from=build /app/packages/payments/package.json ./packages/payments/
COPY --from=build /app/packages/constants/package.json ./packages/constants/ COPY --from=build /app/packages/constants/package.json ./packages/constants/
COPY --from=build /app/packages/validation/package.json ./packages/validation/ COPY --from=build /app/packages/validation/package.json ./packages/validation/
COPY --from=build /app/packages/integrations/package.json ./packages/integrations/ COPY --from=build /app/packages/integrations/package.json ./packages/integrations/
COPY --from=build /app/packages/sdks/sdk/package.json ./packages/sdks/sdk/
COPY --from=build /app/packages/sdks/_info/package.json ./packages/sdks/_info/ COPY --from=build /app/packages/sdks/_info/package.json ./packages/sdks/_info/
COPY --from=build /app/patches ./patches COPY --from=build /app/patches ./patches
@@ -132,7 +130,6 @@ COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/sdks/_info ./packages/sdks/_info COPY --from=build /app/packages/sdks/_info ./packages/sdks/_info
COPY --from=build /app/tooling/typescript ./tooling/typescript COPY --from=build /app/tooling/typescript ./tooling/typescript

View File

@@ -3,6 +3,11 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns';
export const DEFAULT_ASPECT_RATIO = 0.5625; export const DEFAULT_ASPECT_RATIO = 0.5625;
export const NOT_SET_VALUE = '(not set)'; export const NOT_SET_VALUE = '(not set)';
export const RESERVED_EVENT_NAMES = [
'session_start',
'session_end',
] as const;
export const timeWindows = { export const timeWindows = {
'30min': { '30min': {
key: '30min', key: '30min',

View File

@@ -8,6 +8,13 @@ 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'; const silent = process.env.LOG_SILENT === 'true';
// Add colors for custom levels (fatal, warn, trace) that aren't in default color schemes
winston.addColors({
fatal: 'red',
warn: 'yellow',
trace: 'gray',
});
export function createLogger({ name }: { name: string }): ILogger { export function createLogger({ name }: { name: string }): ILogger {
const service = [process.env.LOG_PREFIX, name, process.env.NODE_ENV ?? 'dev'] const service = [process.env.LOG_PREFIX, name, process.env.NODE_ENV ?? 'dev']
.filter(Boolean) .filter(Boolean)

View File

@@ -14,7 +14,6 @@
"groupmq": "catalog:" "groupmq": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*", "@openpanel/tsconfig": "workspace:*",
"@types/node": "catalog:", "@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"

View File

@@ -8,8 +8,8 @@ import type {
} from '@openpanel/db'; } from '@openpanel/db';
import { createLogger } from '@openpanel/logger'; import { createLogger } from '@openpanel/logger';
import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis'; import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
import type { TrackPayload } from '@openpanel/sdk';
import { Queue as GroupQueue } from 'groupmq'; import { Queue as GroupQueue } from 'groupmq';
import type { ITrackPayload } from '../../validation';
export const EVENTS_GROUP_QUEUES_SHARDS = Number.parseInt( export const EVENTS_GROUP_QUEUES_SHARDS = Number.parseInt(
process.env.EVENTS_GROUP_QUEUES_SHARDS || '1', process.env.EVENTS_GROUP_QUEUES_SHARDS || '1',
@@ -32,7 +32,7 @@ export interface EventsQueuePayloadIncomingEvent {
type: 'incomingEvent'; type: 'incomingEvent';
payload: { payload: {
projectId: string; projectId: string;
event: TrackPayload & { event: ITrackPayload & {
timestamp: string | number; timestamp: string | number;
isTimestampFromThePast: boolean; isTimestampFromThePast: boolean;
}; };

View File

@@ -1,36 +1 @@
export * from './src/index'; export * from './src/index';
// Deprecated types for beta version of the SDKs
// Still used in api/event.controller.ts and api/profile.controller.ts
export interface OpenpanelEventOptions {
profileId?: string;
}
export interface PostEventPayload {
name: string;
timestamp: string;
profileId?: string;
properties?: Record<string, unknown> & OpenpanelEventOptions;
}
export interface UpdateProfilePayload {
profileId: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
properties?: Record<string, unknown>;
}
export interface IncrementProfilePayload {
profileId: string;
property: string;
value: number;
}
export interface DecrementProfilePayload {
profileId?: string;
property: string;
value: number;
}

View File

@@ -9,8 +9,9 @@
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@openpanel/tsconfig": "workspace:*", "@openpanel/tsconfig": "workspace:*",
"@openpanel/validation": "workspace:*",
"@types/node": "catalog:", "@types/node": "catalog:",
"tsup": "^7.2.0", "tsup": "^7.2.0",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }

View File

@@ -1,31 +1,20 @@
import type {
IAliasPayload as AliasPayload,
IDecrementPayload as DecrementPayload,
IIdentifyPayload as IdentifyPayload,
IIncrementPayload as IncrementPayload,
ITrackHandlerPayload as TrackHandlerPayload,
ITrackPayload as TrackPayload,
} from '@openpanel/validation';
import { Api } from './api'; import { Api } from './api';
export type TrackHandlerPayload = export type {
| { AliasPayload,
type: 'track'; DecrementPayload,
payload: TrackPayload; IdentifyPayload,
} IncrementPayload,
| { TrackHandlerPayload,
type: 'increment'; TrackPayload,
payload: IncrementPayload;
}
| {
type: 'decrement';
payload: DecrementPayload;
}
| {
type: 'alias';
payload: AliasPayload;
}
| {
type: 'identify';
payload: IdentifyPayload;
};
export type TrackPayload = {
name: string;
properties?: Record<string, unknown>;
profileId?: string;
}; };
export type TrackProperties = { export type TrackProperties = {
@@ -33,32 +22,6 @@ export type TrackProperties = {
profileId?: string; profileId?: string;
}; };
export type IdentifyPayload = {
profileId: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
properties?: Record<string, unknown>;
};
export type AliasPayload = {
profileId: string;
alias: string;
};
export type IncrementPayload = {
profileId: string;
property: string;
value?: number;
};
export type DecrementPayload = {
profileId: string;
property: string;
value?: number;
};
export type OpenPanelOptions = { export type OpenPanelOptions = {
clientId: string; clientId: string;
clientSecret?: string; clientSecret?: string;

View File

@@ -555,3 +555,4 @@ export const zCreateImport = z.object({
export type ICreateImport = z.infer<typeof zCreateImport>; export type ICreateImport = z.infer<typeof zCreateImport>;
export * from './types.insights'; export * from './types.insights';
export * from './track.validation';

View File

@@ -0,0 +1,104 @@
import { z } from 'zod';
import { RESERVED_EVENT_NAMES } from '@openpanel/constants';
export const zTrackPayload = z
.object({
name: z.string().min(1),
properties: z.record(z.unknown()).optional(),
profileId: z.string().optional(),
})
.refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), {
message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`,
path: ['name'],
});
export const zIdentifyPayload = z.object({
profileId: z.string().min(1),
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email().optional(),
avatar: z.string().url().optional(),
properties: z.record(z.unknown()).optional(),
});
export const zIncrementPayload = z.object({
profileId: z.string().min(1),
property: z.string().min(1),
value: z.number().positive().optional(),
});
export const zDecrementPayload = z.object({
profileId: z.string().min(1),
property: z.string().min(1),
value: z.number().positive().optional(),
});
export const zAliasPayload = z.object({
profileId: z.string().min(1),
alias: z.string().min(1),
});
export const zTrackHandlerPayload = z.discriminatedUnion('type', [
z.object({
type: z.literal('track'),
payload: zTrackPayload,
}),
z.object({
type: z.literal('identify'),
payload: zIdentifyPayload,
}),
z.object({
type: z.literal('increment'),
payload: zIncrementPayload,
}),
z.object({
type: z.literal('decrement'),
payload: zDecrementPayload,
}),
z.object({
type: z.literal('alias'),
payload: zAliasPayload,
}),
]);
export type ITrackPayload = z.infer<typeof zTrackPayload>;
export type IIdentifyPayload = z.infer<typeof zIdentifyPayload>;
export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
export type IAliasPayload = z.infer<typeof zAliasPayload>;
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
// Deprecated types for beta version of the SDKs
export interface DeprecatedOpenpanelEventOptions {
profileId?: string;
}
export interface DeprecatedPostEventPayload {
name: string;
timestamp: string;
profileId?: string;
properties?: Record<string, unknown> & DeprecatedOpenpanelEventOptions;
}
export interface DeprecatedUpdateProfilePayload {
profileId: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
properties?: Record<string, unknown>;
}
export interface DeprecatedIncrementProfilePayload {
profileId: string;
property: string;
value: number;
}
export interface DeprecatedDecrementProfilePayload {
profileId?: string;
property: string;
value: number;
}

9
pnpm-lock.yaml generated
View File

@@ -229,9 +229,6 @@ importers:
'@faker-js/faker': '@faker-js/faker':
specifier: ^9.0.1 specifier: ^9.0.1
version: 9.0.1 version: 9.0.1
'@openpanel/sdk':
specifier: workspace:*
version: link:../../packages/sdks/sdk
'@openpanel/tsconfig': '@openpanel/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../tooling/typescript version: link:../../tooling/typescript
@@ -1362,9 +1359,6 @@ importers:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.1-next.2(ioredis@5.8.2) version: 1.1.1-next.2(ioredis@5.8.2)
devDependencies: devDependencies:
'@openpanel/sdk':
specifier: workspace:*
version: link:../sdks/sdk
'@openpanel/tsconfig': '@openpanel/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../tooling/typescript version: link:../../tooling/typescript
@@ -1558,6 +1552,9 @@ importers:
'@openpanel/tsconfig': '@openpanel/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../../tooling/typescript version: link:../../../tooling/typescript
'@openpanel/validation':
specifier: workspace:*
version: link:../../validation
'@types/node': '@types/node':
specifier: 'catalog:' specifier: 'catalog:'
version: 24.7.1 version: 24.7.1

View File

@@ -1,11 +0,0 @@
#!/bin/bash
# nextjs-13-pages
rm -rf ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/nextjs && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/nextjs
rm -rf ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/web && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/web
# nextjs-app-dir
rm -rf ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/nextjs && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/nextjs
rm -rf ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/web && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/web
# expo-app
rm -rf ../openpanel-examples/expo-app/node_modules/@openpanel/react-native && cp -R packages/sdks/react-native ../openpanel-examples/expo-app/node_modules/@openpanel/react-native
rm -rf ../openpanel-examples/expo-app/node_modules/@openpanel/sdk && cp -R packages/sdks/sdk ../openpanel-examples/expo-app/node_modules/@openpanel/sdk