Skip to content

Commit c060e46

Browse files
perf(events): route event list to events_v2 + merge anonymous identit… (#424)
* perf(events): route event list to events_v2 + merge anonymous identity on profile page Two changes to the profile/events event list (getEventList + getEventsCount): 1. Route reads to events_v2 (name-first ORDER BY) via getEventsTableForRange — parity with events within the TTL window, but profile_id / name IN (...) lookups prune to a fraction of the rows (measured 13x fewer for name+profile on the profile page). Falls back to events for pre-EVENTS_V2_MIN_DATE ranges or when the flag is off. Also skip the 0.5d expanding-window retries when a specific event name is searched — the name+profile sort-key pruning makes a single full-range pass cheap, replacing up to ~10 doubling re-scans. 2. Profile page identity merge: show the full pre-login + post-login journey by listing events for the canonical profile_id AND its anonymous device aliases. getProfileIdCluster resolves canonical (dictGet, in-RAM) then reverse-looks-up its aliases from profile_aliases (cached 10min, so the scan runs once per profile). getEventList/getEventsCount take a profileIds[] -> profile_id IN (...) with literal ids (no per-query alias subquery scan). Wired via a new mergeIdentity flag on the event.events procedure, set by the profile events route. Anon-only events ($ae_first_open, loginPrompt, logInInitiate, ...) are now visible on the profile timeline instead of split across device ids. * fix(profile): resolve identity cluster via profile_aliases, not the alias dict The forward step of getProfileIdCluster used dictGet, but the alias RAM dict lags profile_aliases inserts (LIFETIME 30-60min). A freshly-aliased anon device dictGet-MISSes on every replica, so opening that anon profile resolved to itself, found no reverse aliases, and showed only the pre-login events — the post-login half silently dropped. Forward-resolve via a profile_aliases alias lookup instead — a cheap (project_id, alias) sort-key point lookup that is authoritative and near real-time. The reverse step already scans profile_aliases, so the resolver now reads a single consistent source. Verified: opening anon d6021004 now resolves to canonical X8JG and returns the full cluster. * feat(profile-events): default to last 15 days with a visible range picker Profile events feed now defaults to the last 15 days (like the profiles list, #371) so landing on a busy profile stays fast, while searching a specific event spans all days to find old occurrences. - getEventList: when an explicit [startDate, endDate] is passed, that BETWEEN drives the window and the rolling expanding-window is skipped (only the cursor upper-bound remains for pagination) — a real range bounds the scan cleanly. - Profile events route: default window = last 15 days; searching a specific event = all days; a user-picked range always wins. Memoized so the 'now' anchor is stable. - EventsTable: swapped the old DateRangerPicker modal for the #371 LastSeenPicker dual-month calendar, added an emptyRangeLabel prop (profile page shows 'Last 15 days' / 'All time' while searching) and a clear button. * fix(profile-events): address CodeRabbit — datetime bounds + reverse-alias dedup - getEventList/getEventsCount: compare created_at against toDateTime bounds instead of toDate, so the LastSeenPicker's hour-level ranges aren't widened to whole calendar days. - getProfileIdCluster: the reverse alias lookup kept any alias whose profile_id row equalled the canonical, but a reassigned device retains its old row (profile_id is in the sort key) — so it could resurrect a device now owned by another user. Restrict to candidate aliases that ever pointed here, then keep only those whose latest argMax(profile_id, created_at) is still this canonical.
1 parent f9dffb6 commit c060e46

5 files changed

Lines changed: 213 additions & 37 deletions

File tree

apps/start/src/components/events/table/index.tsx

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { EventsFilters } from '@/components/events/filters/events-filters';
22
import { FullPageEmptyState } from '@/components/full-page-empty-state';
3+
import { LastSeenPicker } from '@/components/profiles/last-seen-picker';
34
import { Skeleton } from '@/components/skeleton';
4-
import { Button } from '@/components/ui/button';
55
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
66
import { DataTableToolbarContainer } from '@/components/ui/data-table/data-table-toolbar';
77
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
8-
import { pushModal } from '@/modals';
98
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
109
import { cn } from '@/utils/cn';
1110
import type { IServiceEvent } from '@openpanel/db';
@@ -14,8 +13,8 @@ import type { Table } from '@tanstack/react-table';
1413
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
1514
import { useWindowVirtualizer } from '@tanstack/react-virtual';
1615
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
17-
import { format } from 'date-fns';
18-
import { CalendarIcon, Loader2Icon } from 'lucide-react';
16+
import { format, parse } from 'date-fns';
17+
import { CalendarIcon, Loader2Icon, XIcon } from 'lucide-react';
1918
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
2019
import { last } from 'ramda';
2120
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -32,10 +31,15 @@ type Props = {
3231
>,
3332
unknown
3433
>;
34+
// Label for the date button when no explicit range is picked. The profile page
35+
// passes "Last 15 days" (its default feed window) / "All time" (while searching).
36+
emptyRangeLabel?: string;
3537
};
3638

3739
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
3840
const ROW_HEIGHT = 40;
41+
// DB-format the LastSeenPicker reads/writes (naive, project timezone).
42+
const DB_FMT = 'yyyy-MM-dd HH:mm:ss';
3943

4044
interface VirtualizedEventsTableProps {
4145
table: Table<IServiceEvent>;
@@ -265,7 +269,7 @@ const VirtualizedEventsTable = ({
265269
);
266270
};
267271

268-
export const EventsTable = ({ query }: Props) => {
272+
export const EventsTable = ({ query, emptyRangeLabel }: Props) => {
269273
const { isLoading } = query;
270274
const columns = useColumns();
271275
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set());
@@ -333,7 +337,11 @@ export const EventsTable = ({ query }: Props) => {
333337

334338
return (
335339
<>
336-
<EventsTableToolbar query={query} table={table} />
340+
<EventsTableToolbar
341+
query={query}
342+
table={table}
343+
emptyRangeLabel={emptyRangeLabel}
344+
/>
337345
<VirtualizedEventsTable
338346
table={table}
339347
data={data}
@@ -358,40 +366,55 @@ export const EventsTable = ({ query }: Props) => {
358366
function EventsTableToolbar({
359367
query,
360368
table,
369+
emptyRangeLabel,
361370
}: {
362371
query: Props['query'];
363372
table: Table<IServiceEvent>;
373+
emptyRangeLabel?: string;
364374
}) {
365375
const [startDate, setStartDate] = useQueryState(
366376
'startDate',
367377
parseAsIsoDateTime,
368378
);
369379
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
380+
const hasRange = !!(startDate && endDate);
370381

371382
return (
372383
<div className="flex flex-col gap-2 mb-4">
373384
<DataTableToolbarContainer className="mb-0">
374385
<div className="flex flex-1 flex-wrap items-center gap-2">
375386
<EventListener onRefresh={() => query.refetch()} />
376-
<Button
377-
variant="outline"
378-
size="sm"
379-
icon={CalendarIcon}
380-
onClick={() => {
381-
pushModal('DateRangerPicker', {
382-
onChange: ({ startDate, endDate }) => {
383-
setStartDate(startDate);
384-
setEndDate(endDate);
385-
},
386-
startDate: startDate || undefined,
387-
endDate: endDate || undefined,
388-
});
387+
{/* Reuses the #371 dual-month "Last seen" calendar. DB-format strings
388+
in/out; the events feed stores the range as ISO dates. */}
389+
<LastSeenPicker
390+
startDate={startDate ? format(startDate, DB_FMT) : null}
391+
endDate={endDate ? format(endDate, DB_FMT) : null}
392+
onApply={(start, end) => {
393+
setStartDate(parse(start, DB_FMT, new Date()));
394+
// "Since" mode returns a null end — roll to now so both bounds are
395+
// set (the feed query treats [start, end] as an explicit window).
396+
setEndDate(end ? parse(end, DB_FMT, new Date()) : new Date());
389397
}}
398+
className="inline-flex h-8 items-center gap-2 rounded-md border bg-card px-3 text-sm font-medium transition-colors hover:bg-accent"
390399
>
391-
{startDate && endDate
392-
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
393-
: 'Date range'}
394-
</Button>
400+
<CalendarIcon className="size-4 text-muted-foreground" />
401+
{hasRange
402+
? `${format(startDate!, 'MMM d')} - ${format(endDate!, 'MMM d')}`
403+
: (emptyRangeLabel ?? 'Date range')}
404+
</LastSeenPicker>
405+
{hasRange && (
406+
<button
407+
type="button"
408+
title="Clear date range"
409+
onClick={() => {
410+
setStartDate(null);
411+
setEndDate(null);
412+
}}
413+
className="flex size-8 items-center justify-center rounded-md border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
414+
>
415+
<XIcon className="size-4" />
416+
</button>
417+
)}
395418
</div>
396419
<DataTableViewOptions table={table} />
397420
</DataTableToolbarContainer>

apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ import { useTRPC } from '@/integrations/trpc/react';
88
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
99
import { useInfiniteQuery } from '@tanstack/react-query';
1010
import { createFileRoute } from '@tanstack/react-router';
11+
import { subDays } from 'date-fns';
1112
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
13+
import { useMemo } from 'react';
14+
15+
// Default the profile events feed to the last 15 days (like the profiles list,
16+
// #371) so landing on a busy profile stays fast. A user-picked range wins, and
17+
// when searching a specific event we span all days (no default window) so old
18+
// events are found.
19+
const DEFAULT_WINDOW_DAYS = 15;
1220

1321
export const Route = createFileRoute(
1422
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events',
@@ -33,14 +41,34 @@ function Component() {
3341
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
3442
const [eventNames] = useEventQueryNamesFilter();
3543
const columnVisibility = useReadColumnVisibility('events');
44+
45+
const isSearching = (eventNames?.length ?? 0) > 0;
46+
// A user-picked range always wins (a lone start = "since" gets `now` as end).
47+
// Otherwise: searching a specific event spans all days; the default feed is the
48+
// last 15 days. Memoized so the "now" anchor is stable across renders.
49+
const { rangeStart, rangeEnd } = useMemo(() => {
50+
if (startDate) {
51+
return { rangeStart: startDate, rangeEnd: endDate ?? new Date() };
52+
}
53+
if (isSearching) {
54+
return { rangeStart: undefined, rangeEnd: undefined };
55+
}
56+
const now = new Date();
57+
return { rangeStart: subDays(now, DEFAULT_WINDOW_DAYS), rangeEnd: now };
58+
}, [startDate, endDate, isSearching]);
59+
3660
const query = useInfiniteQuery(
3761
trpc.event.events.infiniteQueryOptions(
3862
{
3963
projectId,
4064
profileId,
65+
// Show the full journey: identified profile + its anonymous device
66+
// aliases merged into one timeline (pre-login events live under the
67+
// anon device id, resolved via profile_aliases).
68+
mergeIdentity: true,
4169
filters,
42-
startDate: startDate || undefined,
43-
endDate: endDate || undefined,
70+
startDate: rangeStart,
71+
endDate: rangeEnd,
4472
events: eventNames,
4573
columnVisibility: columnVisibility ?? {},
4674
},
@@ -51,5 +79,10 @@ function Component() {
5179
),
5280
);
5381

54-
return <EventsTable query={query} />;
82+
return (
83+
<EventsTable
84+
query={query}
85+
emptyRangeLabel={isSearching ? 'All time' : 'Last 15 days'}
86+
/>
87+
);
5588
}

packages/db/src/services/event.service.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
chQuery,
1414
convertClickhouseDateToJs,
1515
formatClickhouseDate,
16+
getEventsTableForRange,
1617
} from '../clickhouse/client';
1718
import { type Query, clix } from '../clickhouse/query-builder';
1819
import type { EventMeta, Prisma } from '../prisma-client';
@@ -436,6 +437,10 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
436437
export interface GetEventListOptions {
437438
projectId: string;
438439
profileId?: string;
440+
// Identity-merge: when set, list events for ALL of these profile_ids (a
441+
// canonical id + its anonymous device aliases) so the profile page shows the
442+
// full pre-login + post-login journey. Takes precedence over `profileId`.
443+
profileIds?: string[];
439444
sessionId?: string;
440445
take: number;
441446
cursor?: number | Date;
@@ -454,6 +459,7 @@ export async function getEventList(options: GetEventListOptions) {
454459
take,
455460
projectId,
456461
profileId,
462+
profileIds,
457463
sessionId,
458464
events,
459465
filters,
@@ -465,21 +471,44 @@ export async function getEventList(options: GetEventListOptions) {
465471
} = options;
466472
const { sb, getSql, join } = createSqlBuilder();
467473

474+
// Route reads to events_v2 (name-first ORDER BY) — parity with `events` within
475+
// the retained TTL window, but a `profile_id` / `name IN (...)` lookup prunes to
476+
// a fraction of the rows (measured 13x fewer for name+profile). Explicit ranges
477+
// predating EVENTS_V2_MIN_DATE fall back to `events`; cursor-window reads anchor
478+
// at "now" so they always resolve to events_v2.
479+
const routeStartDate = startDate
480+
? formatClickhouseDate(startDate)
481+
: formatClickhouseDate(new Date());
482+
sb.from = `${getEventsTableForRange(routeStartDate)} e`;
483+
468484
const MAX_DATE_INTERVAL_IN_DAYS = 365;
485+
// When searching a *specific* event (name filter, not the "*" wildcard), the
486+
// name+profile pruning on events_v2 makes a single full-range scan cheap — so
487+
// skip the 0.5d expanding-window retries, which otherwise re-scan the whole
488+
// project once per doubling step to reach an event that fired weeks ago.
489+
const hasSpecificEventFilter =
490+
!!events && events.length > 0 && !events.includes('*');
469491
// Cap the date interval to prevent infinity
470-
const safeDateIntervalInDays = Math.min(
471-
dateIntervalInDays,
472-
MAX_DATE_INTERVAL_IN_DAYS,
473-
);
492+
const safeDateIntervalInDays = hasSpecificEventFilter
493+
? MAX_DATE_INTERVAL_IN_DAYS
494+
: Math.min(dateIntervalInDays, MAX_DATE_INTERVAL_IN_DAYS);
495+
496+
// When the caller passes an explicit [startDate, endDate] (e.g. the profile
497+
// page's default "last 15 days", or a user-picked range), that BETWEEN clause
498+
// below bounds the scan — so skip the rolling expanding-window entirely and let
499+
// the range drive it (only the cursor upper-bound is needed for pagination).
500+
const hasExplicitRange = !!(startDate && endDate);
474501

475502
if (typeof cursor === 'number') {
476503
sb.offset = Math.max(0, (cursor ?? 0) * take);
477504
} else if (cursor instanceof Date) {
478-
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(cursor))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
505+
if (!hasExplicitRange) {
506+
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(cursor))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
507+
}
479508
sb.where.cursor = `created_at <= ${sqlstring.escape(formatClickhouseDate(cursor))}`;
480509
}
481510

482-
if (!cursor) {
511+
if (!cursor && !hasExplicitRange) {
483512
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
484513
}
485514

@@ -595,7 +624,12 @@ export async function getEventList(options: GetEventListOptions) {
595624
sb.select.revenue = 'revenue';
596625
}
597626

598-
if (profileId) {
627+
if (profileIds && profileIds.length > 0) {
628+
sb.where.profileId = `profile_id IN (${join(
629+
profileIds.map((id) => sqlstring.escape(id)),
630+
',',
631+
)})`;
632+
} else if (profileId) {
599633
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
600634
}
601635

@@ -604,7 +638,7 @@ export async function getEventList(options: GetEventListOptions) {
604638
}
605639

606640
if (startDate && endDate) {
607-
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
641+
sb.where.created_at = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
608642
}
609643

610644
const selectedEventNames = events?.includes('*') ? [] : (events ?? []);
@@ -661,19 +695,30 @@ export const getEventsCountCached = cacheable(getEventsCount, 60 * 10);
661695
export async function getEventsCount({
662696
projectId,
663697
profileId,
698+
profileIds,
664699
events,
665700
filters,
666701
startDate,
667702
endDate,
668703
}: Omit<GetEventListOptions, 'cursor' | 'take'>) {
669704
const { sb, getSql, join } = createSqlBuilder();
705+
// Match getEventList: read from events_v2 for the same sort-key pruning.
706+
const routeStartDate = startDate
707+
? formatClickhouseDate(startDate)
708+
: formatClickhouseDate(new Date());
709+
sb.from = `${getEventsTableForRange(routeStartDate)} e`;
670710
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
671-
if (profileId) {
711+
if (profileIds && profileIds.length > 0) {
712+
sb.where.profileId = `profile_id IN (${join(
713+
profileIds.map((id) => sqlstring.escape(id)),
714+
',',
715+
)})`;
716+
} else if (profileId) {
672717
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
673718
}
674719

675720
if (startDate && endDate) {
676-
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
721+
sb.where.created_at = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
677722
}
678723

679724
const selectedEventNames = events?.includes('*') ? [] : (events ?? []);

0 commit comments

Comments
 (0)