Track Event
Authenticated event tracking — emits a FunnelEvent row tied to the current user. Killable via admin config. Validates JSON property payload size. Writes to the main Postgres database, not the Analytics DB.
POST /api/v1/analytics/track — 🔑 Bearer · Rate limit: 60 req / minute
Records a named event (signup step, action, button click, …) attributed to the current user. This endpoint coexists with the public session-tracking endpoints — but track writes to the main database (FunnelEvent row in @biore/prisma), not the separate Analytics DB. Use it for funnel events that must be reliably joined to the user table; use POST /analytics/session + POST /analytics/pageview for traffic instrumentation.
Killable from admin config. The service reads analytics.tracking_enabled (string, default 'true'). When set to anything other than 'true', the endpoint returns 200 { tracked: true } without writing — clients see success but no row lands. Useful for kill-switching analytics during incidents without redeploying.
properties payload is size-capped. Server reads analytics.max_event_data_bytes (default 10000) and runs validateEventData(properties, max). Anything over the cap throws 400 analytics.event.invalid_data. Keep payloads small; for large analytics blobs use a dedicated endpoint or batch.
Two databases, one module. This endpoint writes to prisma.funnelEvent (main @biore/prisma Postgres). The session/pageview endpoints write to analyticsDb (@biore/prisma-analytics — a separate Postgres on the Analytics DB service). Funnel events are joinable to the User table; session events are not. Plan your queries accordingly.
Request
Body — TrackEventDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
event | string | ✓ | IsString | Event name (e.g. signup_step_complete, bio_link_click). No enum — free-form. |
sessionId | string | optional | IsString | Optional join key — typically the analytics session id from POST /session so funnel events can be correlated with traffic state. Stored verbatim — server doesn't validate it exists. |
deviceType | string | optional | IsString | Free-form (mobile / desktop / tablet). Stored as-is on FunnelEvent.eventData? No — the column is dropped from persistence (only event, userId, sessionId, eventData are written). Pass it for API symmetry; it's ignored. |
locale | string | optional | IsString | Same as deviceType — accepted by the DTO but the service drops it from the write. |
properties | object | optional | IsObject | Arbitrary JSON. Persisted as FunnelEvent.eventData. Size-capped (see warn callout). |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | Global JwtAuthGuard. userId is forwarded from the JWT — clients cannot impersonate. |
Content-Type: application/json | ✓ | Standard. |
Response
200 OK — ApiResponseOf<TrackingResultDto>
{
"success": true,
"data": { "tracked": true }
}| Field | Type | Notes |
|---|---|---|
tracked | boolean | Always true when the request reaches the service. Does not signal that the row landed — both the kill switch (analytics.tracking_enabled !== 'true') and a swallowed-DB-write path can produce tracked: true with no DB effect. Treat as a "request accepted" ack only. |
The controller hard-codes @HttpCode(200) so you don't get the framework default 201.
Errors
| HTTP | Code / i18nKey | Reason |
|---|---|---|
400 | analytics.event.invalid_data | properties failed validateEventData (size cap or invalid shape). Payload may include the limit. |
400 | (DTO validation) | event missing / non-string. |
401 | (guard) | Missing / invalid bearer token. |
429 | THROTTLE_LIMIT_EXCEEDED | Over 60 req/min from this IP. retryAfter (seconds) included in the response. |
Side effects
- Decode bearer (global
JwtAuthGuard);userId = req.user.id. ThrottleGuardincrementsthrottle:<ip>:POST:/api/v1/analytics/trackin Redis cache (60-req / 60s window).- Read kill switch —
configService.getWithDefault('analytics.tracking_enabled', 'true'). If not'true'→ return{ tracked: true }with no further work. - Read size cap —
configService.getNumberWithDefault('analytics.max_event_data_bytes', 10000). RunvalidateEventData(properties, max). Failure → throw400. - Build event row:
{ id: randomUUID(), userId, sessionId, eventType: event, eventData: properties ?? {} }. prisma.funnelEvent.create({ data })— direct write to the main DB, no queue. (A historical Redis-queued path was removed because the module never importedRedisModule.)- Increment
analyticsEventTrackedTotalPrometheus counter. - Log
[analytics] Event tracked: <event> for user <userId>(debug level). - Return
{ tracked: true }(controller wraps as{ success: true, data: { tracked: true } }).
Code samples
curl -X POST https://api.bio.re/api/v1/analytics/track \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"event": "bio_link_click",
"sessionId": "session-uuid-from-create",
"properties": { "linkId": "abc-123", "position": 2 }
}'type TrackInput = {
event: string;
sessionId?: string;
properties?: Record<string, unknown>;
};
async function track(accessToken: string, input: TrackInput): Promise<void> {
const res = await fetch('https://api.bio.re/api/v1/analytics/track', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
if (!res.ok) {
const json = await res.json().catch(() => null);
throw Object.assign(new Error(json?.error?.message ?? 'Track failed'), {
code: json?.error?.code,
});
}
// Don't await success — fire-and-forget at the call site if you don't care.
}import { useMutation } from '@tanstack/react-query';
export function useTrackEvent() {
return useMutation({
mutationFn: async (input: TrackInput) => {
const res = await fetch('/api/v1/analytics/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) {
// Don't surface to the user — instrumentation failures shouldn't be UX errors.
return;
}
},
// Don't retry — duplicates are worse than missing one event.
retry: false,
});
}Try it
Authorization
bearer In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
curl -X POST "https://loading/api/v1/analytics/track" \ -H "Content-Type: application/json" \ -d '{ "event": "string" }'{
"success": true,
"data": {
"tracked": true
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/analytics/analytics.controller.ts | 52–59 (class), 68–78 (track) |
| DTO (request) | apps/api-core/src/modules/analytics/dto/index.ts | 9–15 (TrackEventDto) |
| DTO (response) | apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts | 5–8 (TrackingResultDto) |
| Service | apps/api-core/src/modules/analytics/analytics.service.ts | 23–46 (trackEvent — kill switch, size cap, direct DB write) |
| Validation | apps/api-core/src/common/json-validators.ts | validateEventData (size cap enforcement) |
| Config keys | (admin-managed) | analytics.tracking_enabled (string, kill switch), analytics.max_event_data_bytes (number, default 10000) |
| Prisma model | packages/prisma/prisma/schema.prisma | FunnelEvent lines 1681–1695 (main DB — NOT analytics DB) |
BIO.RE Developer Portal
Self-service API documentation for the BIO.RE platform — auth, content, payments, messaging, real-time, and more. Build frontends and mobile apps without asking the backend team.
Create Analytics Session
Public session-start endpoint. GDPR consent gate, server-side UA + GeoIP, hashed visitorId, HMAC tracking-token requirement when bioPageId is present. Returns null when bot / no consent / forged token. Writes to the separate Analytics DB.