Skip to content

Commit 5341486

Browse files
authored
Merge branch 'main' into feature/analytics-events-track
2 parents 7948cb4 + a91819d commit 5341486

19 files changed

Lines changed: 1348 additions & 27 deletions
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Analytics Event Taxonomy
2+
3+
This document defines the naming convention and schema for every `AnalyticsEvent.eventName` used across the project.
4+
5+
## Naming Convention
6+
7+
All event names MUST follow the pattern:
8+
9+
```
10+
noun_pastTenseVerb
11+
```
12+
13+
- `noun` — the object or domain being acted upon (e.g. `puzzle`, `streak`, `onboarding`)
14+
- `pastTenseVerb` — the action in past tense (e.g. `attempted`, `completed`, `viewed`, `broken`)
15+
16+
### Examples
17+
18+
| ✓ Good | ✗ Bad |
19+
|-------------------------|------------------------|
20+
| `puzzle_attempted` | `PuzzleAttempted` |
21+
| `streak_broken` | `streak-broken` |
22+
| `onboarding_completed` | `onboardingComplete` |
23+
| `tutorial_viewed` | `tutorial_view` |
24+
| `profile_created` | `createProfile` |
25+
26+
A consistent naming convention ensures that all events can be queried and aggregated reliably across the entire platform.
27+
28+
## Registered Events
29+
30+
### `onboarding_started`
31+
32+
Emitted when a user begins the onboarding flow.
33+
34+
| Field | Type | Description |
35+
|------------|--------|--------------------------------------|
36+
| `userId` | string | Identifies the starting user |
37+
| `metadata` | object | (empty) |
38+
39+
---
40+
41+
### `profile_created`
42+
43+
Emitted when a user completes their profile during onboarding.
44+
45+
| Field | Type | Description |
46+
|------------|--------|--------------------------------------|
47+
| `userId` | string | Identifies the user |
48+
| `metadata` | object | `{ profileFieldsCompleted: number }` |
49+
50+
---
51+
52+
### `tutorial_viewed`
53+
54+
Emitted when a user views the tutorial.
55+
56+
| Field | Type | Description |
57+
|------------|--------|--------------------------------------|
58+
| `userId` | string | Identifies the user |
59+
| `metadata` | object | `{ tutorialStep: string }` |
60+
61+
---
62+
63+
### `first_puzzle_attempted`
64+
65+
Emitted when a user attempts their first puzzle.
66+
67+
| Field | Type | Description |
68+
|------------|--------|--------------------------------------|
69+
| `userId` | string | Identifies the user |
70+
| `metadata` | object | `{ puzzleId: string, difficulty: string }` |
71+
72+
---
73+
74+
### `onboarding_completed`
75+
76+
Emitted when a user finishes the entire onboarding flow.
77+
78+
| Field | Type | Description |
79+
|------------|--------|--------------------------------------|
80+
| `userId` | string | Identifies the user |
81+
| `metadata` | object | `{ timeToCompleteSeconds: number }` |
82+
83+
---
84+
85+
### `puzzle_attempted`
86+
87+
Emitted each time a user submits an answer to a puzzle.
88+
89+
| Field | Type | Description |
90+
|------------|--------|--------------------------------------|
91+
| `userId` | string | Identifies the user |
92+
| `metadata` | object | `{ puzzleId: string, difficulty: string, isCorrect: boolean, timeSpent: number }` |
93+
94+
---
95+
96+
### `streak_broken`
97+
98+
Emitted when a user's daily streak is broken after inactivity.
99+
100+
| Field | Type | Description |
101+
|------------|--------|--------------------------------------|
102+
| `userId` | string | Identifies the user |
103+
| `metadata` | object | `{ previousStreakLength: number, lastActiveDate: string }` |
104+
105+
---
106+
107+
### `streak_updated`
108+
109+
Emitted when a user's daily streak is updated (incremented or maintained).
110+
111+
| Field | Type | Description |
112+
|------------|--------|--------------------------------------|
113+
| `userId` | string | Identifies the user |
114+
| `metadata` | object | `{ currentStreak: number, longestStreak: number }` |
115+
116+
---
117+
118+
### `daily_quest_completed`
119+
120+
Emitted when a user completes all puzzles in their daily quest.
121+
122+
| Field | Type | Description |
123+
|------------|--------|--------------------------------------|
124+
| `userId` | string | Identifies the user |
125+
| `metadata` | object | `{ questDate: string, totalQuestions: number, bonusXpEarned: number }` |
126+
127+
---
128+
129+
### `login_occurred`
130+
131+
Emitted when a user logs in.
132+
133+
| Field | Type | Description |
134+
|------------|--------|--------------------------------------|
135+
| `userId` | string | Identifies the user |
136+
| `metadata` | object | `{ method: string }` |
137+
138+
---
139+
140+
### `wallet_connected`
141+
142+
Emitted when a user connects a Stellar wallet.
143+
144+
| Field | Type | Description |
145+
|------------|--------|--------------------------------------|
146+
| `userId` | string | Identifies the user |
147+
| `metadata` | object | `{ walletAddress: string }` |
148+
149+
## Adding New Events
150+
151+
1. Choose a `noun_pastTenseVerb` name that fits the convention.
152+
2. Add the event to the table in this document with its expected metadata shape.
153+
3. Emit the event from the relevant provider using `TrackEventProvider.track()`:
154+
155+
```typescript
156+
await this.trackEventProvider.track({
157+
eventName: 'your_new_event',
158+
userId: user.id,
159+
metadata: { /* ... */ },
160+
});
161+
```
162+
163+
4. Include the change in the same PR that introduces the event emission so the taxonomy stays in sync with the code.
Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import { Module } from '@nestjs/common';
2-
import { AnalyticsController } from './analytics.controller';
3-
import { AnalyticsService } from './analytics.service';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { AnalyticsEvent } from './entities/analytics-event.entity';
4+
import { RetentionCohort } from './entities/retention-cohort.entity';
5+
import { UsersAnalyticsListener } from './listeners/users-analytics.listener';
6+
import { AnalyticsController } from './controllers/analytics.controller';
47
import { TrackEventProvider } from './providers/track-event.provider';
5-
import { AnalyticsAdminGuard } from './guards/analytics-admin.guard';
8+
import { GetOnboardingFunnelProvider } from './providers/get-onboarding-funnel.provider';
9+
import { GetRetentionCurveProvider } from './providers/get-retention-curve.provider';
610

711
@Module({
12+
imports: [TypeOrmModule.forFeature([AnalyticsEvent, RetentionCohort])],
813
controllers: [AnalyticsController],
914
providers: [
10-
AnalyticsService,
15+
UsersAnalyticsListener,
1116
TrackEventProvider,
12-
AnalyticsAdminGuard,
17+
GetOnboardingFunnelProvider,
18+
GetRetentionCurveProvider,
1319
],
20+
exports: [TrackEventProvider, GetOnboardingFunnelProvider, GetRetentionCurveProvider, TypeOrmModule],
1421
})
1522
export class AnalyticsModule {}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Controller, Post, Body, Get, Query } from '@nestjs/common';
2+
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
3+
import { TrackEventProvider } from '../providers/track-event.provider';
4+
import { GetOnboardingFunnelProvider } from '../providers/get-onboarding-funnel.provider';
5+
import { TrackEventDto } from '../dtos/track-event.dto';
6+
import { DateRangeDto } from '../dtos/date-range.dto';
7+
8+
@ApiTags('Analytics')
9+
@Controller('analytics')
10+
export class AnalyticsController {
11+
constructor(
12+
private readonly trackEventProvider: TrackEventProvider,
13+
private readonly getOnboardingFunnelProvider: GetOnboardingFunnelProvider,
14+
) {}
15+
16+
@Post('track')
17+
@ApiOperation({ summary: 'Track an analytics event' })
18+
async track(@Body() dto: TrackEventDto) {
19+
await this.trackEventProvider.track(dto);
20+
return { success: true };
21+
}
22+
23+
@Get('funnel/onboarding')
24+
@ApiOperation({ summary: 'Get onboarding funnel data' })
25+
async getOnboardingFunnel(@Query() query: DateRangeDto) {
26+
return this.getOnboardingFunnelProvider.getFunnel(query.start, query.end);
27+
}
28+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class RetentionDataPoint {
4+
@ApiProperty({ example: '2024-01-15', description: 'Cohort date (YYYY-MM-DD)' })
5+
cohortDate: string;
6+
7+
@ApiProperty({ example: 200, description: 'Total users in this cohort' })
8+
cohortSize: number;
9+
10+
@ApiProperty({ example: 65.5, description: 'Day-1 retention %', nullable: true })
11+
day1RetentionPct: number | null;
12+
13+
@ApiProperty({ example: 42.0, description: 'Day-7 retention %', nullable: true })
14+
day7RetentionPct: number | null;
15+
16+
@ApiProperty({ example: 28.5, description: 'Day-30 retention %', nullable: true })
17+
day30RetentionPct: number | null;
18+
}
19+
20+
export class AnalyticsMetricResult {
21+
@ApiProperty({ example: '2024-01-01', description: 'Start of queried range' })
22+
startDate: string;
23+
24+
@ApiProperty({ example: '2024-01-31', description: 'End of queried range' })
25+
endDate: string;
26+
27+
@ApiProperty({ example: 'day', description: 'Granularity used' })
28+
granularity: string;
29+
30+
@ApiProperty({ type: [RetentionDataPoint] })
31+
data: RetentionDataPoint[];
32+
33+
@ApiProperty({ example: 15, description: 'Total cohort rows returned' })
34+
total: number;
35+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { IsDate, IsIn, IsOptional, Validate } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { ApiPropertyOptional } from '@nestjs/swagger';
4+
import { ValidDateRangeConstraint } from '../validators/date-range.validator';
5+
6+
export type RetentionGranularity = 'day' | 'week' | 'month';
7+
8+
export class DateRangeDto {
9+
@ApiPropertyOptional({
10+
description: 'Start date for analytics queries',
11+
example: '2026-01-01T00:00:00.000Z',
12+
})
13+
@IsDate()
14+
@IsOptional()
15+
@Type(() => Date)
16+
start?: Date;
17+
18+
@ApiPropertyOptional({
19+
description: 'End date for analytics queries',
20+
example: '2026-06-30T23:59:59.000Z',
21+
})
22+
@IsDate()
23+
@IsOptional()
24+
@Type(() => Date)
25+
end?: Date;
26+
27+
@Validate(ValidDateRangeConstraint)
28+
_dateRange: boolean;
29+
30+
@ApiPropertyOptional({
31+
example: 'day',
32+
description: 'Time granularity for grouping results: day | week | month',
33+
enum: ['day', 'week', 'month'],
34+
default: 'day',
35+
})
36+
@IsOptional()
37+
@IsIn(['day', 'week', 'month'])
38+
granularity?: RetentionGranularity;
39+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { IsString, IsObject, IsOptional, IsUUID } from 'class-validator';
2+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3+
4+
export class TrackEventDto {
5+
@ApiProperty({
6+
description: 'Event type following the noun_pastTenseVerb convention',
7+
example: 'puzzle_attempted',
8+
})
9+
@IsString()
10+
eventType: string;
11+
12+
@ApiPropertyOptional({
13+
description: 'Arbitrary payload for the event',
14+
example: { puzzleId: 'uuid', difficulty: 'hard', timeSpent: 45 },
15+
})
16+
@IsObject()
17+
@IsOptional()
18+
payload?: Record<string, any>;
19+
20+
@ApiPropertyOptional({
21+
description: 'User identifier if the event is tied to an authenticated user',
22+
example: '123e4567-e89b-12d3-a456-426614174000',
23+
})
24+
@IsUUID()
25+
@IsOptional()
26+
userId?: string;
27+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
Column,
3+
CreateDateColumn,
4+
Entity,
5+
Index,
6+
PrimaryGeneratedColumn,
7+
UpdateDateColumn,
8+
} from 'typeorm';
9+
10+
/**
11+
* Pre-aggregated retention cohort row.
12+
*
13+
* Each row represents a single acquisition cohort (users who first appeared on
14+
* `cohortDate`) and records how many of those users returned on day 1, day 7,
15+
* and day 30. Populated by a nightly aggregation job rather than computed
16+
* on-the-fly from raw `AnalyticsEvent` rows.
17+
*/
18+
@Entity('retention_cohorts')
19+
@Index(['cohortDate'])
20+
export class RetentionCohort {
21+
@PrimaryGeneratedColumn('uuid')
22+
id: string;
23+
24+
@Index()
25+
@Column({ type: 'date' })
26+
cohortDate: string;
27+
28+
@Column({ type: 'int', default: 0 })
29+
cohortSize: number;
30+
31+
@Column({ type: 'int', default: 0 })
32+
retainedDay1: number;
33+
34+
@Column({ type: 'int', default: 0 })
35+
retainedDay7: number;
36+
37+
@Column({ type: 'int', default: 0 })
38+
retainedDay30: number;
39+
40+
@CreateDateColumn({ type: 'timestamptz' })
41+
createdAt: Date;
42+
43+
@UpdateDateColumn({ type: 'timestamptz' })
44+
updatedAt: Date;
45+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface FunnelStage {
2+
name: string;
3+
eventType: string;
4+
count: number;
5+
}
6+
7+
export interface FunnelResult {
8+
startDate: Date;
9+
endDate: Date;
10+
totalUsers: number;
11+
stages: FunnelStage[];
12+
}

0 commit comments

Comments
 (0)