Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
71 changes: 71 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Analytics Module

The Analytics Module provides event tracking and reporting capabilities for the StellAIverse platform.

## Event Schema

All events are stored in the `analytics_events` table and represented by the `AnalyticsEvent` entity.

### Standard Properties
- `eventType`: Enum specifying the type of event (`page_view`, `click`, `transaction`, etc.)
- `eventName`: String describing the specific event (e.g., "submit_order")
- `userId`: Identifier for the user who triggered the event
- `sessionId`: Session identifier
- `properties`: JSON object for arbitrary custom event data
- `idempotencyKey`: Unique string to prevent duplicate ingestion

### Context Properties
- `page`: Page path or URL
- `referrer`: Referrer URL
- `userAgent`: Raw user agent string
- `device`, `browser`, `os`: Parsed client metadata
- `ipAddress`, `country`: Location data

## Retention Policy

Events are stored in the primary transactional database (`analytics_events` table).
- Raw events are kept indefinitely by default, but a cleanup job could be introduced to prune events older than 90 days.
- Aggregated metrics (DAU, daily event counts) are precomputed daily and stored in `daily_metrics`. These are kept indefinitely for long-term trend analysis.
- The `analytics_events` table is optimized with a `BRIN` index on the `createdAt` column to support fast time-series queries.

## Adding New Events

To add a new event type:
1. Update the `EventType` enum in `src/analytics/entities/analytics-event.entity.ts`.
2. Fire the event from the client side or backend service using the ingestion API.

### Ingestion API

**Single Event Ingestion**
`POST /analytics/events`
```json
{
"eventType": "custom",
"eventName": "feature_unlocked",
"properties": { "feature": "advanced_trading" },
"idempotencyKey": "unique-uuid-1234"
}
```

**Batch Event Ingestion**
`POST /analytics/events/batch`
```json
{
"events": [
{
"eventType": "page_view",
"page": "/dashboard",
"idempotencyKey": "unique-uuid-1235"
}
]
}
```

## Reporting APIs

The module provides reporting APIs for rendering dashboards:
- `GET /analytics/metrics/dau`: Daily active users over time.
- `GET /analytics/metrics/events`: Count of events grouped by type.
- `GET /analytics/metrics/top-events`: Top most frequent custom events.
- `GET /analytics/metrics/retention`: Basic cohort retention analysis.
- `GET /analytics/metrics/funnel`: Conversion rate across a sequence of events.
37 changes: 37 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.2",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
Expand Down Expand Up @@ -144,4 +145,4 @@
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3"
}
}
}
25 changes: 25 additions & 0 deletions src/analytics/analytics.cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { AnalyticsService } from "./analytics.service";

@Injectable()
export class AnalyticsCronService {
private readonly logger = new Logger(AnalyticsCronService.name);

constructor(private readonly analyticsService: AnalyticsService) {}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handleDailyMetricsAggregation() {
this.logger.log("Starting daily analytics metrics aggregation...");
try {
// Aggregate for yesterday, as this runs right at midnight
const dateToAggregate = new Date();
dateToAggregate.setDate(dateToAggregate.getDate() - 1);

await this.analyticsService.aggregateDailyMetrics(dateToAggregate);
this.logger.log("Successfully completed daily metrics aggregation.");
} catch (error) {
this.logger.error("Failed to aggregate daily metrics", error);
}
}
}
3 changes: 2 additions & 1 deletion src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { AnalyticsService } from "./analytics.service";
import { AnalyticsController } from "./controllers/analytics.controller";
import { AnalyticsEvent } from "./entities/analytics-event.entity";
import { DailyMetric } from "./entities/daily-metric.entity";
import { AnalyticsCronService } from "./analytics.cron";

@Module({
imports: [TypeOrmModule.forFeature([AnalyticsEvent, DailyMetric])],
controllers: [AnalyticsController],
providers: [AnalyticsService],
providers: [AnalyticsService, AnalyticsCronService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
91 changes: 91 additions & 0 deletions src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Test, TestingModule } from "@nestjs/testing";
import { AnalyticsService } from "./analytics.service";
import { getRepositoryToken } from "@nestjs/typeorm";
import { AnalyticsEvent, EventType } from "./entities/analytics-event.entity";
import { DailyMetric } from "./entities/daily-metric.entity";
import { Repository } from "typeorm";

describe("AnalyticsService", () => {
let service: AnalyticsService;

const mockQueryBuilder = {
insert: jest.fn().mockReturnThis(),
into: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
orIgnore: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ identifiers: [{ id: "test-id" }] }),
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
getRawOne: jest.fn().mockResolvedValue({ count: "10" }),
getCount: jest.fn().mockResolvedValue(5),
};

const mockEventRepository = {
create: jest.fn().mockImplementation((dto) => dto),
save: jest.fn(),
find: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
query: jest.fn().mockResolvedValue([]),
};

const mockMetricRepository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AnalyticsService,
{
provide: getRepositoryToken(AnalyticsEvent),
useValue: mockEventRepository,
},
{
provide: getRepositoryToken(DailyMetric),
useValue: mockMetricRepository,
},
],
}).compile();

service = module.get<AnalyticsService>(AnalyticsService);
});

it("should be defined", () => {
expect(service).toBeDefined();
});

describe("ingestEvent", () => {
it("should gracefully handle deduplication via orIgnore", async () => {
const result = await service.ingestEvent(
{ eventType: EventType.CLICK, idempotencyKey: "123" },
{ userId: "user-1" },
);

expect(result.idempotencyKey).toBe("123");
expect(result.userId).toBe("user-1");
expect(mockQueryBuilder.orIgnore).toHaveBeenCalled();
});
});

describe("ingestBatch", () => {
it("should gracefully handle batch deduplication", async () => {
const result = await service.ingestBatch(
{ events: [{ eventType: EventType.CLICK, idempotencyKey: "123" }] },
{ userId: "user-1" },
);

expect(result.accepted).toBe(1);
expect(mockQueryBuilder.orIgnore).toHaveBeenCalled();
});
});
});
Loading
Loading