Files
stats/packages/db/src/services/chart.service.ts
Carl-Gerhard Lindesvärd 81a7e5d62e feat: dashboard v2, esm, upgrades (#211)
* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
2025-10-16 12:27:44 +02:00

708 lines
20 KiB
TypeScript

import sqlstring from 'sqlstring';
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
import type {
IChartEventFilter,
IChartInput,
IChartRange,
IGetChartDataInput,
} from '@openpanel/validation';
import { TABLE_NAMES, formatClickhouseDate } from '../clickhouse/client';
import { createSqlBuilder } from '../sql-builder';
export function transformPropertyKey(property: string) {
const propertyPatterns = ['properties', 'profile.properties'];
const match = propertyPatterns.find((pattern) =>
property.startsWith(`${pattern}.`),
);
if (!match) {
return property;
}
if (property.includes('*')) {
return property
.replace(/^properties\./, '')
.replace('.*.', '.%.')
.replace(/\[\*\]$/, '.%')
.replace(/\[\*\].?/, '.%.');
}
return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`;
}
export function getSelectPropertyKey(property: string) {
if (property === 'has_profile') {
return `if(profile_id != device_id, 'true', 'false')`;
}
const propertyPatterns = ['properties', 'profile.properties'];
const match = propertyPatterns.find((pattern) =>
property.startsWith(`${pattern}.`),
);
if (!match) return property;
if (property.includes('*')) {
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape(
transformPropertyKey(property),
)})))`;
}
return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`;
}
export function getChartSql({
event,
breakdowns,
interval,
startDate,
endDate,
projectId,
limit,
timezone,
}: IGetChartDataInput & { timezone: string }) {
const {
sb,
join,
getWhere,
getFrom,
getJoins,
getSelect,
getOrderBy,
getGroupBy,
getFill,
} = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
if (event.name !== '*') {
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
sb.where.eventName = `name = ${sqlstring.escape(event.name)}`;
} else {
sb.select.label_0 = `'*' as label_0`;
}
const anyFilterOnProfile = event.filters.some((filter) =>
filter.name.startsWith('profile.'),
);
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
breakdown.name.startsWith('profile.'),
);
if (anyFilterOnProfile || anyBreakdownOnProfile) {
sb.joins.profiles = `LEFT ANY JOIN (SELECT
id as "profile.id",
email as "profile.email",
first_name as "profile.first_name",
last_name as "profile.last_name",
properties as "profile.properties"
FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
}
sb.select.count = 'count(*) as count';
switch (interval) {
case 'minute': {
sb.fill = `FROM toStartOfMinute(toDateTime('${startDate}')) TO toStartOfMinute(toDateTime('${endDate}')) STEP toIntervalMinute(1)`;
sb.select.date = 'toStartOfMinute(created_at) as date';
break;
}
case 'hour': {
sb.fill = `FROM toStartOfHour(toDateTime('${startDate}')) TO toStartOfHour(toDateTime('${endDate}')) STEP toIntervalHour(1)`;
sb.select.date = 'toStartOfHour(created_at) as date';
break;
}
case 'day': {
sb.fill = `FROM toStartOfDay(toDateTime('${startDate}')) TO toStartOfDay(toDateTime('${endDate}')) STEP toIntervalDay(1)`;
sb.select.date = 'toStartOfDay(created_at) as date';
break;
}
case 'week': {
sb.fill = `FROM toStartOfWeek(toDateTime('${startDate}'), 1, '${timezone}') TO toStartOfWeek(toDateTime('${endDate}'), 1, '${timezone}') STEP toIntervalWeek(1)`;
sb.select.date = `toStartOfWeek(created_at, 1, '${timezone}') as date`;
break;
}
case 'month': {
sb.fill = `FROM toStartOfMonth(toDateTime('${startDate}'), '${timezone}') TO toStartOfMonth(toDateTime('${endDate}'), '${timezone}') STEP toIntervalMonth(1)`;
sb.select.date = `toStartOfMonth(created_at, '${timezone}') as date`;
break;
}
}
sb.groupBy.date = 'date';
sb.orderBy.date = 'date ASC';
if (startDate) {
sb.where.startDate = `created_at >= toDateTime('${formatClickhouseDate(startDate)}')`;
}
if (endDate) {
sb.where.endDate = `created_at <= toDateTime('${formatClickhouseDate(endDate)}')`;
}
if (breakdowns.length > 0 && limit) {
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (
SELECT ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
FROM ${TABLE_NAMES.events}
${getJoins()}
${getWhere()}
GROUP BY ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
ORDER BY count(*) DESC
LIMIT ${limit}
)`;
}
breakdowns.forEach((breakdown, index) => {
const key = `label_${index}`;
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
sb.groupBy[key] = `${key}`;
});
if (event.segment === 'user') {
sb.select.count = 'countDistinct(profile_id) as count';
}
if (event.segment === 'session') {
sb.select.count = 'countDistinct(session_id) as count';
}
if (event.segment === 'user_average') {
sb.select.count =
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
}
if (event.segment === 'property_sum' && event.property) {
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'property_average' && event.property) {
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'property_max' && event.property) {
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'property_min' && event.property) {
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'one_event_per_user') {
sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
sb.where,
' AND ',
)}
ORDER BY profile_id, created_at DESC
) as subQuery`;
sb.joins = {};
const sql = `${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
console.log('-- Report --');
console.log(sql.replaceAll(/[\n\r]/g, ' '));
console.log('-- End --');
return sql;
}
const sql = `${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
console.log('-- Report --');
console.log(sql.replaceAll(/[\n\r]/g, ' '));
console.log('-- End --');
return sql;
}
export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
const where: Record<string, string> = {};
filters.forEach((filter, index) => {
const id = `f${index}`;
const { name, value, operator } = filter;
if (
value.length === 0 &&
operator !== 'isNull' &&
operator !== 'isNotNull'
) {
return;
}
if (name === 'has_profile') {
if (value.includes('true')) {
where[id] = 'profile_id != device_id';
} else {
where[id] = 'profile_id = device_id';
}
return;
}
if (
name.startsWith('properties.') ||
name.startsWith('profile.properties.')
) {
const propertyKey = getSelectPropertyKey(name);
const isWildcard = propertyKey.includes('%');
const whereFrom = getSelectPropertyKey(name);
switch (operator) {
case 'is': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map((val) => `x = ${sqlstring.escape(String(val).trim())}`)
.join(' OR ')}, ${whereFrom})`;
} else {
if (value.length === 1) {
where[id] =
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] = `${whereFrom} IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
}
break;
}
case 'isNot': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map((val) => `x != ${sqlstring.escape(String(val).trim())}`)
.join(' OR ')}, ${whereFrom})`;
} else {
if (value.length === 1) {
where[id] =
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] = `${whereFrom} NOT IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
}
break;
}
case 'contains': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
)
.join(' OR ')})`;
}
break;
}
case 'doesNotContain': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
)
.join(' OR ')})`;
}
break;
}
case 'startsWith': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map(
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
)
.join(' OR ')})`;
}
break;
}
case 'endsWith': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map(
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
)
.join(' OR ')})`;
}
break;
}
case 'regex': {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map((val) => `match(x, ${sqlstring.escape(String(val).trim())})`)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`,
)
.join(' OR ')})`;
}
break;
}
case 'isNull': {
if (isWildcard) {
where[id] = `arrayExists(x -> x = '' OR x IS NULL, ${whereFrom})`;
} else {
where[id] = `(${whereFrom} = '' OR ${whereFrom} IS NULL)`;
}
break;
}
case 'isNotNull': {
if (isWildcard) {
where[id] =
`arrayExists(x -> x != '' AND x IS NOT NULL, ${whereFrom})`;
} else {
where[id] = `(${whereFrom} != '' AND ${whereFrom} IS NOT NULL)`;
}
break;
}
}
} else {
switch (operator) {
case 'is': {
if (value.length === 1) {
where[id] =
`${name} = ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] = `${name} IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
break;
}
case 'isNull': {
where[id] = `(${name} = '' OR ${name} IS NULL)`;
break;
}
case 'isNotNull': {
where[id] = `(${name} != '' AND ${name} IS NOT NULL)`;
break;
}
case 'isNot': {
if (value.length === 1) {
where[id] =
`${name} != ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] = `${name} NOT IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
break;
}
case 'contains': {
where[id] = `(${value
.map(
(val) =>
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
)
.join(' OR ')})`;
break;
}
case 'doesNotContain': {
where[id] = `(${value
.map(
(val) =>
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
)
.join(' OR ')})`;
break;
}
case 'startsWith': {
where[id] = `(${value
.map(
(val) =>
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
)
.join(' OR ')})`;
break;
}
case 'endsWith': {
where[id] = `(${value
.map(
(val) =>
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
)
.join(' OR ')})`;
break;
}
case 'regex': {
where[id] = `(${value
.map(
(val) =>
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`,
)
.join(' OR ')})`;
break;
}
}
}
});
return where;
}
export function getChartStartEndDate(
{
startDate,
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
timezone: string,
) {
const ranges = getDatesFromRange(range, timezone);
if (startDate && endDate) {
return { startDate: startDate, endDate: endDate };
}
if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate };
}
return ranges;
}
export function getDatesFromRange(range: IChartRange, timezone: string) {
if (range === '30min' || range === 'lastHour') {
const minutes = range === '30min' ? 30 : 60;
const startDate = DateTime.now()
.minus({ minute: minutes })
.startOf('minute')
.setZone(timezone)
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('minute')
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === 'today') {
const startDate = DateTime.now()
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === 'yesterday') {
const startDate = DateTime.now()
.minus({ day: 1 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.minus({ day: 1 })
.setZone(timezone)
.endOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === '7d') {
const startDate = DateTime.now()
.minus({ day: 7 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === '6m') {
const startDate = DateTime.now()
.minus({ month: 6 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === '12m') {
const startDate = DateTime.now()
.minus({ month: 12 })
.setZone(timezone)
.startOf('month')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('month')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === 'monthToDate') {
const startDate = DateTime.now()
.setZone(timezone)
.startOf('month')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === 'lastMonth') {
const month = DateTime.now()
.minus({ month: 1 })
.setZone(timezone)
.startOf('month');
const startDate = month.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = month
.endOf('month')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === 'yearToDate') {
const startDate = DateTime.now()
.setZone(timezone)
.startOf('year')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
if (range === 'lastYear') {
const year = DateTime.now().minus({ year: 1 }).setZone(timezone);
const startDate = year.startOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
// range === '30d'
const startDate = DateTime.now()
.minus({ day: 30 })
.setZone(timezone)
.startOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
const endDate = DateTime.now()
.setZone(timezone)
.endOf('day')
.plus({ millisecond: 1 })
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
};
}
export function getChartPrevStartEndDate({
startDate,
endDate,
}: {
startDate: string;
endDate: string;
}) {
let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff(
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss'),
);
// this will make sure our start and end date's are correct
// otherwise if a day ends with 23:59:59.999 and starts with 00:00:00.000
// the diff will be 23:59:59.999 and that will make the start date wrong
// so we add 1 millisecond to the diff
if ((diff.milliseconds / 1000) % 2 !== 0) {
diff = diff.plus({ millisecond: 1 });
}
return {
startDate: DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss')
.minus({ millisecond: diff.milliseconds })
.toFormat('yyyy-MM-dd HH:mm:ss'),
endDate: DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss')
.minus({ millisecond: diff.milliseconds })
.toFormat('yyyy-MM-dd HH:mm:ss'),
};
}