Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7ac5e4c
fix: simplify event buffer
lindesvard Oct 22, 2025
c215ca1
fix: profile buffer reduce cache impact
lindesvard Oct 23, 2025
567a278
bump: clickhouse client
lindesvard Oct 23, 2025
1ccf85f
try: use csv instead of json for each
lindesvard Oct 23, 2025
945881c
fix: lint
lindesvard Oct 23, 2025
21d2c89
chore: fix so we only tag on main
lindesvard Oct 23, 2025
33d80e2
fix: use lru cache for auth and verify secret
lindesvard Oct 23, 2025
77d4c24
fix: clean pagination
lindesvard Oct 23, 2025
79c48db
fix: view event details without session
lindesvard Oct 23, 2025
274c424
improve: align profile properties, align profile cache etc
lindesvard Oct 23, 2025
cc124bc
try: parallell event buffer
lindesvard Oct 23, 2025
8d61e19
fix: able to silent logs
lindesvard Oct 23, 2025
01837a9
fix: test
lindesvard Oct 23, 2025
d67356c
perf: improve code after tracing
lindesvard Oct 24, 2025
06977ac
improve: deduplication hook
lindesvard Oct 24, 2025
75f2109
bump: fastify
lindesvard Oct 24, 2025
59ba06d
try: add jobId to avoid duplicates
lindesvard Oct 24, 2025
c20b1f9
chore: add prefix on logger
lindesvard Oct 24, 2025
99c2ed4
tmp: add counter
lindesvard Oct 24, 2025
c3e6ed8
tmp: more logs
lindesvard Oct 24, 2025
b8d6d8c
fix: update groupmq
lindesvard Oct 24, 2025
3f0817e
fix: update groupmq 2
lindesvard Oct 24, 2025
c192d45
fix: update groupmq 3
lindesvard Oct 24, 2025
4e901cc
fix: update groupmq 4
lindesvard Oct 25, 2025
c4e54b3
fix: update groupmq 5
lindesvard Oct 25, 2025
36f57d2
fix: update groupmq 6
lindesvard Oct 25, 2025
f617a13
fix: update groupmq 7
lindesvard Oct 26, 2025
d05ad80
fix: update groupmq 8
lindesvard Oct 26, 2025
ed5d8ac
fix: update groupmq 9
lindesvard Oct 27, 2025
83c6c2f
fix: add back orderingDelayMs
lindesvard Oct 27, 2025
b51692d
fix: try auto batching
lindesvard Oct 27, 2025
1e4b1a7
fix: update groupmq 10
lindesvard Oct 27, 2025
46d7978
fix: update groupmq 11
lindesvard Oct 27, 2025
dbb4657
fix: try out dragonfly
lindesvard Oct 27, 2025
9086fb5
fix: shard by groupid
lindesvard Oct 27, 2025
560df69
fix: better worker scaling
lindesvard Oct 28, 2025
a327b96
fix: add chunk size to sessiojn
lindesvard Oct 28, 2025
b2df2c2
fix: add prom stats for duration
lindesvard Oct 28, 2025
3363147
fix: change info logs to debug
lindesvard Oct 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ jobs:
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy

- name: Create/Update API tag
if: github.ref == 'refs/heads/main'
run: |
# Delete existing tag if it exists
if git tag -l "api" | grep -q "api"; then
Expand Down Expand Up @@ -206,6 +207,7 @@ jobs:
DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy

- name: Create/Update Worker tag
if: github.ref == 'refs/heads/main'
run: |
# Delete existing tag if it exists
if git tag -l "worker" | grep -q "worker"; then
Expand Down Expand Up @@ -266,6 +268,7 @@ jobs:
NO_CLOUDFLARE=1

- name: Create/Update Dashboard tag
if: github.ref == 'refs/heads/main'
run: |
# Delete existing tag if it exists
if git tag -l "dashboard" | grep -q "dashboard"; then
Expand Down
7 changes: 2 additions & 5 deletions apps/api/src/controllers/live.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket';
import {
eventBuffer,
getProfileByIdCached,
getProfileById,
transformMinimalEvent,
} from '@openpanel/db';
import { setSuperJson } from '@openpanel/json';
Expand Down Expand Up @@ -92,10 +92,7 @@ export async function wsProjectEvents(
type,
async (event) => {
if (event.projectId === params.projectId) {
const profile = await getProfileByIdCached(
event.profileId,
event.projectId,
);
const profile = await getProfileById(event.profileId, event.projectId);
socket.send(
superjson.stringify(
access
Expand Down
26 changes: 18 additions & 8 deletions apps/api/src/controllers/profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ export async function updateProfile(
}>,
reply: FastifyReply,
) {
const { profileId, properties, ...rest } = request.body;
const payload = request.body;
const projectId = request.client!.projectId;
if (!projectId) {
return reply.status(400).send('No projectId');
}
const ip = getClientIp(request)!;
const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua, properties);
const uaInfo = parseUserAgent(ua, payload.properties);
const geo = await getGeoLocation(ip);

if (
Expand All @@ -40,18 +40,28 @@ export async function updateProfile(
}

await upsertProfile({
id: profileId,
...payload,
id: payload.profileId,
isExternal: true,
projectId,
properties: {
...(properties ?? {}),
...(ip ? geo : {}),
...uaInfo,
...(payload.properties ?? {}),
country: geo.country,
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,
},
Comment on lines +30 to 48
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize profile.properties: drop “__*” keys before upsert.

Same concern as track.controller: don’t persist internal/transient keys from payload.properties.

   properties: {
-    ...(payload.properties ?? {}),
+    ...Object.fromEntries(
+      Object.entries(payload.properties ?? {}).filter(([k]) => !k.startsWith('__')),
+    ),
     country: geo.country,
     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,
   },
🤖 Prompt for AI Agents
In apps/api/src/controllers/profile.controller.ts around lines 43 to 61, the
controller currently spreads payload.properties directly into the upsert payload
which can persist internal/transient keys; sanitize payload.properties by
removing any keys that start with "__" before building the final properties
object. Concretely: create a sanitizedProperties object from payload.properties
(or empty object) that copies only entries whose keys do not startWith("__"),
then use that sanitizedProperties when merging in geo and ua fields (country,
city, region, longitude, latitude, os, os_version, browser, browser_version,
device, brand, model) for the upsert; ensure the rest of the payload (id,
isExternal, projectId, etc.) remains unchanged and keep types/null-coalescing
behavior.

...rest,
});

reply.status(202).send(profileId);
reply.status(202).send(payload.profileId);
}

export async function incrementProfileProperty(
Expand Down
14 changes: 12 additions & 2 deletions apps/api/src/controllers/track.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,18 @@ async function identify({
projectId,
properties: {
...(payload.properties ?? {}),
...(geo ?? {}),
...uaInfo,
country: geo.country,
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,
},
Comment on lines +303 to 315
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid persisting internal (“__”) properties into profile.properties.

You spread payload.properties before UA/geo. This may store transient/private keys like __ip, __timestamp, os, etc. Strip keys starting with "" before persisting.

Apply:

   properties: {
-      ...(payload.properties ?? {}),
+      ...Object.fromEntries(
+        Object.entries(payload.properties ?? {}).filter(([k]) => !k.startsWith('__')),
+      ),
       country: geo.country,
       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,
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
country: geo.country,
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,
},
properties: {
...Object.fromEntries(
Object.entries(payload.properties ?? {}).filter(([k]) => !k.startsWith('__')),
),
country: geo.country,
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,
},
🤖 Prompt for AI Agents
In apps/api/src/controllers/track.controller.ts around lines 326 to 338, the
code spreads payload.properties before adding UA/geo and may persist internal
keys prefixed with "__"; remove any keys starting with "__" from
payload.properties first (e.g., build a cleanedProperties object by filtering
Object.entries(payload.properties) to exclude keys that begin with "__"), then
merge cleanedProperties with the UA/geo/device fields so only sanitized keys get
persisted; ensure this filtering runs before the spread/merge and handles
undefined/null payload.properties safely.

});
}
Expand Down
6 changes: 1 addition & 5 deletions apps/api/src/hooks/ip.hook.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { getClientIp } from '@/utils/get-client-ip';
import type {
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from 'fastify';
import type { FastifyRequest } from 'fastify';

export async function ipHook(request: FastifyRequest) {
const ip = getClientIp(request);
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
import { verifyPassword } from '@openpanel/common/server';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
IProjectFilterIp,
Expand Down Expand Up @@ -131,7 +132,13 @@ export async function validateSdkRequest(
}

if (client.secret && clientSecret) {
if (await verifyPassword(clientSecret, client.secret)) {
const isVerified = await getCache(
`client:auth:${clientId}:${clientSecret.slice(0, 5)}`,
60 * 5,
async () => await verifyPassword(clientSecret, client.secret!),
true,
);
if (isVerified) {
Comment on lines +135 to +141
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Security: auth bypass risk — cache key uses only secret prefix.

Keying by clientId + clientSecret.slice(0, 5) can return a cached true for a different secret with the same 5-char prefix. Use a non-reversible fingerprint of the full secret instead (e.g., HMAC or SHA‑256).

Apply this diff:

@@
-import { getCache } from '@openpanel/redis';
+import { getCache } from '@openpanel/redis';
+import { createHash } from 'node:crypto';
@@
-  const isVerified = await getCache(
-    `client:auth:${clientId}:${clientSecret.slice(0, 5)}`,
-    60 * 5,
-    async () => await verifyPassword(clientSecret, client.secret!),
-    true,
-  );
+  const secretFp = createHash('sha256')
+    .update(clientSecret)
+    .digest('hex')
+    .slice(0, 32); // short, non-reversible fingerprint
+  const isVerified = await getCache(
+    `client:auth:${clientId}:${secretFp}`,
+    60 * 5,
+    async () => verifyPassword(clientSecret, client.secret!),
+    true,
+  );

Optionally prefer HMAC with a server-side key for stronger preimage resistance:

  • secretFp = createHmac('sha256', process.env.CACHE_KEY_SALT!).update(clientSecret).digest('hex').slice(0, 32)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isVerified = await getCache(
`client:auth:${clientId}:${clientSecret.slice(0, 5)}`,
60 * 5,
async () => await verifyPassword(clientSecret, client.secret!),
true,
);
if (isVerified) {
const secretFp = createHash('sha256')
.update(clientSecret)
.digest('hex')
.slice(0, 32); // short, non-reversible fingerprint
const isVerified = await getCache(
`client:auth:${clientId}:${secretFp}`,
60 * 5,
async () => verifyPassword(clientSecret, client.secret!),
true,
);
if (isVerified) {
🤖 Prompt for AI Agents
In apps/api/src/utils/auth.ts around lines 135-141, the cache key currently uses
clientSecret.slice(0, 5) which allows collisions and an auth bypass risk;
replace that prefix with a non-reversible fingerprint of the full secret
(preferably an HMAC using a server-side key or at minimum a SHA-256 hash), e.g.
compute secretFp = HMAC-SHA256(process.env.CACHE_KEY_SALT, clientSecret) and use
a truncated hex digest (e.g., first 32 chars) in the cache key instead of the
5-char prefix, ensure process.env.CACHE_KEY_SALT is validated and throw or
fallback securely if missing, and keep the rest of the getCache call unchanged
so cached verification is keyed to the full-secret fingerprint.

return client;
}
}
Expand Down
1 change: 0 additions & 1 deletion apps/start/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
},
"dependencies": {
"@ai-sdk/react": "^1.2.5",
"@clickhouse/client": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
Expand Down
13 changes: 0 additions & 13 deletions apps/start/src/components/events/table/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,6 @@ export function useColumns() {
return name.replace(/_/g, ' ');
};

const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}

return null;
};

return (
<div className="flex items-center gap-2">
<button
Expand Down Expand Up @@ -97,7 +85,6 @@ export function useColumns() {
>
{renderName()}
</button>
{renderDuration()}
</span>
</div>
);
Expand Down
17 changes: 15 additions & 2 deletions apps/start/src/components/ui/data-table/data-table-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type {
VisibilityState,
} from '@tanstack/react-table';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useState } from 'react';
import { useLocalStorage } from 'usehooks-ts';
import { useEffect, useState } from 'react';
import { useLocalStorage, useReadLocalStorage } from 'usehooks-ts';

export const useDataTablePagination = (pageSize = 10) => {
const [page, setPage] = useQueryState(
Expand All @@ -22,6 +22,12 @@ export const useDataTablePagination = (pageSize = 10) => {
return { page, setPage, state };
};

export const useReadColumnVisibility = (persistentKey: string) => {
return useReadLocalStorage<Record<string, boolean>>(
`@op:${persistentKey}-column-visibility`,
);
};

export const useDataTableColumnVisibility = <TData,>(
columns: ColumnDef<TData>[],
persistentKey: string,
Expand All @@ -43,6 +49,13 @@ export const useDataTableColumnVisibility = <TData,>(
}, {} as VisibilityState),
);

// somewhat hack
// Set initial column visibility,
// otherwise will not useReadColumnVisibility be updated
useEffect(() => {
setColumnVisibility(columnVisibility);
}, []);

const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
`@op:${persistentKey}-column-order`,
columns.map((column) => column.id!),
Expand Down
10 changes: 2 additions & 8 deletions apps/start/src/modals/event-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,8 @@ import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import {
ArrowLeftIcon,
ArrowRightIcon,
FilterIcon,
Loader2Icon,
XIcon,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { FilterIcon, XIcon } from 'lucide-react';
import { omit } from 'ramda';
import { useState } from 'react';
import { popModal } from '.';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
Expand All @@ -18,12 +19,14 @@ function Component() {
parseAsIsoDateTime,
);
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.conversions.infiniteQueryOptions(
{
projectId,
startDate: startDate || undefined,
endDate: endDate || undefined,
columnVisibility: columnVisibility ?? {},
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
Expand All @@ -21,6 +22,8 @@ function Component() {
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');

const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
Expand All @@ -30,8 +33,10 @@ function Component() {
profileId: '',
startDate: startDate || undefined,
endDate: endDate || undefined,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
Expand All @@ -21,6 +22,7 @@ function Component() {
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
Expand All @@ -30,8 +32,10 @@ function Component() {
startDate: startDate || undefined,
endDate: endDate || undefined,
events: eventNames,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
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 {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
Expand Down Expand Up @@ -46,8 +45,6 @@ function Component() {
const trpc = useTRPC();

const LIMIT = 50;
const { page } = useDataTablePagination(LIMIT);
const { debouncedSearch } = useSearchQueryState();

const { data: session } = useSuspenseQuery(
trpc.session.byId.queryOptions({
Expand All @@ -60,7 +57,7 @@ function Component() {
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();

const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
Expand All @@ -70,8 +67,10 @@ function Component() {
events: eventNames,
startDate: startDate || undefined,
endDate: endDate || undefined,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/jobs/cron.delete-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export async function deleteProjects(job: Job<CronQueuePayload>) {
await ch.command({
query,
clickhouse_settings: {
lightweight_deletes_sync: 0,
lightweight_deletes_sync: '0',
},
});
}
Expand Down
Loading