fix(api): improve export api, properties to be a comma seperated list

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-03 11:37:05 +01:00
parent 2377f95b86
commit 74bcb7ead2
3 changed files with 61 additions and 30 deletions

View File

@@ -1,20 +1,18 @@
import { parseQueryString } from '@/utils/parse-zod-query-string';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { DateTime } from '@openpanel/common'; import { DateTime } from '@openpanel/common';
import type { GetEventListOptions } from '@openpanel/db'; import type { GetEventListOptions } from '@openpanel/db';
import { import {
ChartEngine,
ClientType, ClientType,
db, db,
getEventList, getEventList,
getEventsCountCached, getEventsCount,
getSettingsForProject, getSettingsForProject,
} from '@openpanel/db'; } from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zReport } from '@openpanel/validation'; import { zChartEvent, zReport } from '@openpanel/validation';
import { omit } from 'ramda'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { parseQueryString } from '@/utils/parse-zod-query-string';
async function getProjectId( async function getProjectId(
request: FastifyRequest<{ request: FastifyRequest<{
@@ -22,8 +20,7 @@ async function getProjectId(
project_id?: string; project_id?: string;
projectId?: string; projectId?: string;
}; };
}>, }>
reply: FastifyReply,
) { ) {
let projectId = request.query.projectId || request.query.project_id; let projectId = request.query.projectId || request.query.project_id;
@@ -75,8 +72,20 @@ const eventsScheme = z.object({
limit: z.coerce.number().optional().default(50), limit: z.coerce.number().optional().default(50),
includes: z includes: z
.preprocess( .preprocess(
(arg) => (typeof arg === 'string' ? [arg] : arg), (arg) => {
z.array(z.string()), if (arg == null) {
return undefined;
}
if (Array.isArray(arg)) {
return arg;
}
if (typeof arg === 'string') {
const parts = arg.split(',').map((s) => s.trim()).filter(Boolean);
return parts;
}
return arg;
},
z.array(z.string())
) )
.optional(), .optional(),
}); });
@@ -85,7 +94,7 @@ export async function events(
request: FastifyRequest<{ request: FastifyRequest<{
Querystring: z.infer<typeof eventsScheme>; Querystring: z.infer<typeof eventsScheme>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const query = eventsScheme.safeParse(request.query); const query = eventsScheme.safeParse(request.query);
@@ -97,7 +106,7 @@ export async function events(
}); });
} }
const projectId = await getProjectId(request, reply); const projectId = await getProjectId(request);
const limit = query.data.limit; const limit = query.data.limit;
const page = Math.max(query.data.page, 1); const page = Math.max(query.data.page, 1);
const take = Math.max(Math.min(limit, 1000), 1); const take = Math.max(Math.min(limit, 1000), 1);
@@ -118,20 +127,20 @@ export async function events(
meta: false, meta: false,
...query.data.includes?.reduce( ...query.data.includes?.reduce(
(acc, key) => ({ ...acc, [key]: true }), (acc, key) => ({ ...acc, [key]: true }),
{}, {}
), ),
}, },
}; };
const [data, totalCount] = await Promise.all([ const [data, totalCount] = await Promise.all([
getEventList(options), getEventList(options),
getEventsCountCached(omit(['cursor', 'take'], options)), getEventsCount(options),
]); ]);
reply.send({ reply.send({
meta: { meta: {
count: data.length, count: data.length,
totalCount: totalCount, totalCount,
pages: Math.ceil(totalCount / options.take), pages: Math.ceil(totalCount / options.take),
current: cursor + 1, current: cursor + 1,
}, },
@@ -158,7 +167,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(), filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(), segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(), property: zChartEvent.shape.property.optional(),
}), })
) )
.optional(), .optional(),
// Backward compatibility - events will be migrated to series via preprocessing // Backward compatibility - events will be migrated to series via preprocessing
@@ -169,7 +178,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(), filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(), segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(), property: zChartEvent.shape.property.optional(),
}), })
) )
.optional(), .optional(),
}); });
@@ -178,7 +187,7 @@ export async function charts(
request: FastifyRequest<{ request: FastifyRequest<{
Querystring: Record<string, string>; Querystring: Record<string, string>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const query = chartSchemeFull.safeParse(parseQueryString(request.query)); const query = chartSchemeFull.safeParse(parseQueryString(request.query));
@@ -190,7 +199,7 @@ export async function charts(
}); });
} }
const projectId = await getProjectId(request, reply); const projectId = await getProjectId(request);
const { timezone } = await getSettingsForProject(projectId); const { timezone } = await getSettingsForProject(projectId);
const { events, series, ...rest } = query.data; const { events, series, ...rest } = query.data;

View File

@@ -53,14 +53,32 @@ GET /export/events
| `end` | string | End date for the event range (ISO format) | `2024-04-18` | | `end` | string | End date for the event range (ISO format) | `2024-04-18` |
| `page` | number | Page number for pagination (default: 1) | `2` | | `page` | number | Page number for pagination (default: 1) | `2` |
| `limit` | number | Number of events per page (default: 50, max: 1000) | `100` | | `limit` | number | Number of events per page (default: 50, max: 1000) | `100` |
| `includes` | string or string[] | Additional fields to include in the response | `profile` or `["profile","meta"]` | | `includes` | string or string[] | Additional fields to include in the response. Pass multiple as comma-separated (`profile,meta`) or repeated params (`includes=profile&includes=meta`). | `profile` or `profile,meta` |
#### Include Options #### Include Options
The `includes` parameter allows you to fetch additional related data: The `includes` parameter allows you to fetch additional related data. When using query parameters, you can pass multiple values in either of these ways:
- `profile`: Include user profile information - **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response)
- `meta`: Include event metadata and configuration - **Repeated parameter**: `?includes=profile&includes=meta` (same result; useful when building URLs programmatically)
Supported values (any of these can be combined; names match the response keys):
**Related data** (adds nested objects or extra lookups):
- `profile` — User profile for the event (id, email, firstName, lastName, etc.)
- `meta` — Event metadata from project config (name, description, conversion flag)
**Event fields** (optional columns; these are in addition to the default fields):
- `properties` — Custom event properties
- `region`, `longitude`, `latitude` — Extra geo (default already has `city`, `country`)
- `osVersion`, `browserVersion`, `device`, `brand`, `model` — Extra device (default already has `os`, `browser`)
- `origin`, `referrer`, `referrerName`, `referrerType` — Referrer/navigation
- `revenue` — Revenue amount
- `importedAt`, `sdkName`, `sdkVersion` — Import/SDK info
The response always includes: `id`, `name`, `deviceId`, `profileId`, `sessionId`, `projectId`, `createdAt`, `path`, `duration`, `city`, `country`, `os`, `browser`. Use `includes` to add any of the values above.
#### Example Request #### Example Request
@@ -129,12 +147,15 @@ Retrieve aggregated chart data for analytics and visualization. This endpoint pr
GET /export/charts GET /export/charts
``` ```
**Note:** The endpoint accepts either `series` or `events` for the event configuration; `series` is the preferred parameter name. Both use the same structure.
#### Query Parameters #### Query Parameters
| Parameter | Type | Description | Example | | Parameter | Type | Description | Example |
|-----------|------|-------------|---------| |-----------|------|-------------|---------|
| `projectId` | string | The ID of the project to fetch chart data from | `abc123` | | `projectId` | string | The ID of the project to fetch chart data from | `abc123` |
| `events` | object[] | Array of event configurations to analyze | `[{"name":"screen_view","filters":[]}]` | | `series` | object[] | Array of event/series configurations to analyze (preferred over `events`) | `[{"name":"screen_view","filters":[]}]` |
| `events` | object[] | Array of event configurations (deprecated in favor of `series`) | `[{"name":"screen_view","filters":[]}]` |
| `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` | | `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` |
| `interval` | string | Time interval for data points | `day` | | `interval` | string | Time interval for data points | `day` |
| `range` | string | Predefined date range | `7d` | | `range` | string | Predefined date range | `7d` |
@@ -144,7 +165,7 @@ GET /export/charts
#### Event Configuration #### Event Configuration
Each event in the `events` array supports the following properties: Each item in the `series` or `events` array supports the following properties:
| Property | Type | Description | Required | Default | | Property | Type | Description | Required | Default |
|----------|------|-------------|----------|---------| |----------|------|-------------|----------|---------|
@@ -228,11 +249,13 @@ Common breakdown dimensions include:
#### Example Request #### Example Request
```bash ```bash
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&events=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \ curl 'https://api.openpanel.dev/export/charts?projectId=abc123&series=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \
-H 'openpanel-client-id: YOUR_CLIENT_ID' \ -H 'openpanel-client-id: YOUR_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
``` ```
You can use `events` instead of `series` in the query for backward compatibility; both accept the same structure.
#### Example Advanced Request #### Example Advanced Request
```bash ```bash
@@ -241,7 +264,7 @@ curl 'https://api.openpanel.dev/export/charts' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \ -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \
-G \ -G \
--data-urlencode 'projectId=abc123' \ --data-urlencode 'projectId=abc123' \
--data-urlencode 'events=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \ --data-urlencode 'series=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \
--data-urlencode 'breakdowns=[{"name":"country"}]' \ --data-urlencode 'breakdowns=[{"name":"country"}]' \
--data-urlencode 'interval=day' \ --data-urlencode 'interval=day' \
--data-urlencode 'range=30d' --data-urlencode 'range=30d'

View File

@@ -654,7 +654,6 @@ export async function getEventList(options: GetEventListOptions) {
return data; return data;
} }
export const getEventsCountCached = cacheable(getEventsCount, 60 * 10);
export async function getEventsCount({ export async function getEventsCount({
projectId, projectId,
profileId, profileId,