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
1 change: 1 addition & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This document describes the lightweight analytics and cost-tracking additions.
Endpoints:

- POST /analytics/event - record a feature event (body: { category, action, label?, value? })
- POST /metrics/cost - record an hourly infrastructure cost event (body: { amountUsd })
- GET /monitoring/cost/summary - returns last 24h estimated spend and avg hourly cost if enabled

Metrics added (Prometheus):
Expand Down
6 changes: 3 additions & 3 deletions docs/monitoring-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
This document describes the Grafana monitoring dashboard for the teachLink
backend, the panels it ships with, and the alerts that fire from it.

The backend exports metrics in Prometheus format from
`src/observability/observability.controller.ts` at:
The backend exports metrics in Prometheus format from the active backend
scrape endpoint at:

```
GET /observability/metrics/export/prometheus
GET /metrics
```

Prometheus scrapes that endpoint, Grafana visualizes the metrics, and
Expand Down
2 changes: 1 addition & 1 deletion infra/monitoring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Self-contained Prometheus + Alertmanager + Grafana stack for the teachLink
backend. Scrapes the Prometheus exporter served from
`/observability/metrics/export/prometheus`.
`/metrics`.

See [`docs/monitoring-dashboard.md`](../../docs/monitoring-dashboard.md) for
the full guide and runbook.
Expand Down
49 changes: 49 additions & 0 deletions src/analytics/analytics.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { CreateEventDto } from './dto/create-event.dto';
import { EventType } from './entities/event.entity';

const mockAnalyticsService = {
trackEvent: jest.fn(),
getEvents: jest.fn(),
getAnalyticsSummary: jest.fn(),
};

describe('AnalyticsController', () => {
let controller: AnalyticsController;

beforeEach(async () => {
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
controllers: [AnalyticsController],
providers: [
{ provide: AnalyticsService, useValue: mockAnalyticsService },
],
}).compile();

controller = module.get<AnalyticsController>(AnalyticsController);
});

it('should record a compatibility analytics event on POST /analytics/event', async () => {
const dto: CreateEventDto = {
category: 'feature',
action: 'launch_button_clicked',
};

const req = {
ip: '127.0.0.1',
get: jest.fn().mockReturnValue('super-agent'),
} as any;

await expect(controller.trackEventCompatibility(dto, req)).resolves.toEqual({ success: true });
expect(mockAnalyticsService.trackEvent).toHaveBeenCalledWith({
...dto,
eventType: EventType.CUSTOM,
userId: undefined,
ipAddress: '127.0.0.1',
userAgent: 'super-agent',
});
});
});
15 changes: 15 additions & 0 deletions src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ export class AnalyticsController {
return { success: true };
}

@Post('event')
@ApiOperation({ summary: 'Track a feature event (compatibility endpoint)' })
@ApiResponse({ status: 201, description: 'Feature event tracked successfully' })
async trackEventCompatibility(@Body() dto: CreateEventDto, @Request() req: any): Promise<{ success: boolean }> {
await this.analyticsService.trackEvent({
...dto,
eventType: EventType.CUSTOM,
userId: req.user?.id,
ipAddress: req.ip,
userAgent: req.get('user-agent'),
});

return { success: true };
}

/**
* Get analytics events with filtering
*/
Expand Down
198 changes: 69 additions & 129 deletions src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -1,133 +1,60 @@
import { Logger } from '@nestjs/common';
import { Injectable, Logger, BadRequestException, OnModuleInit } from '@nestjs/common';
import { Counter, Histogram } from 'prom-client';
import { Logger, BadRequestException } from '@nestjs/common';
import { Counter, Histogram } from 'prom-client';
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AnalyticsEvent, EventType } from './entities/event.entity';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service';
import { EventBatchingService, ITrackEventDto } from './services/event-batching.service';
import { EventValidationService } from './services/event-validation.service';

@Injectable()
export class AnalyticsService implements OnModuleInit {
private readonly logger = new Logger(AnalyticsService.name);
private featureEventsCounter: Counter<'category' | 'action' | 'eventType'> | null = null;
private assessmentDuration: Histogram<'status'> | null = null;

private readonly featureEvents: Counter<'category' | 'action' | 'label'> | null;
private readonly assessmentDuration: Histogram<'status'> | null;

constructor(private readonly metrics: MetricsCollectionService) {
const registry = this.metrics.getRegistry();

this.featureEvents = this.registerMetric(() =>
(registry.getSingleMetric('feature_events_total') as Counter<'category' | 'action' | 'label'>) ??
new Counter({
name: 'feature_events_total',
help: 'Feature analytics events',
labelNames: ['category', 'action', 'label'] as const,
registers: [registry],
}),
);

this.assessmentDuration = this.registerMetric(() =>
(registry.getSingleMetric('assessment_duration_seconds') as Histogram<'status'>) ??
new Histogram({
name: 'assessment_duration_seconds',
help: 'Time from attempt start to submission or timeout, in seconds',
labelNames: ['status'] as const,
buckets: [30, 60, 120, 300, 600, 1200, 1800],
registers: [registry],
}),
);
}

// ── Generic event recording ────────────────────────────────────────────────

recordEvent(category: string, action: string, label = '', value = 1): void {
try {
this.featureEvents?.inc({ category, action, label }, value);
} catch (err) {
this.logger.error(
`Failed to record analytics event: ${category}.${action}`,
err as Error,
);
}
}

// ── Assessment-domain events ───────────────────────────────────────────────

recordAssessmentStarted(assessmentId: string): void {
this.recordEvent('assessment', 'started', assessmentId);
}

recordAssessmentSubmitted(assessmentId: string, startedAt: Date): void {
this.recordEvent('assessment', 'submitted', assessmentId);
this.observeDuration(startedAt, 'submitted');
}

recordAssessmentTimedOut(assessmentId: string, startedAt: Date): void {
this.recordEvent('assessment', 'timed_out', assessmentId);
this.observeDuration(startedAt, 'timed_out');
}

recordAssessmentScore(score: number, maxScore: number): void {
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
this.recordEvent('assessment', 'score_recorded', '', pct);
}

// ── Private helpers ────────────────────────────────────────────────────────

private observeDuration(startedAt: Date, status: string): void {
try {
const seconds = (Date.now() - startedAt.getTime()) / 1000;
this.assessmentDuration?.observe({ status }, seconds);
constructor(
@InjectRepository(AnalyticsEvent)
private eventRepository: Repository<AnalyticsEvent>,
private readonly eventRepository: Repository<AnalyticsEvent>,
private readonly metrics: MetricsCollectionService,
private readonly batchingService: EventBatchingService,
private readonly validationService: EventValidationService,
) {
constructor(private readonly metrics: MetricsCollectionService) {}
) {}

async onModuleInit() {
async onModuleInit(): Promise<void> {
try {
const registry = this.metrics.getRegistry();
// Lazy import prom-client to avoid import cycles
const prom = await import('prom-client');

// Create a shared counter for feature events with labels
this.featureEventsCounter =
registry.getSingleMetric('feature_events_total') ||
(registry.getSingleMetric('feature_events_total') as Counter<'category' | 'action' | 'eventType'>) ??
new prom.Counter({
name: 'feature_events_total',
help: 'Feature analytics events',
labelNames: ['category', 'action', 'eventType'],
labelNames: ['category', 'action', 'eventType'] as const,
registers: [registry],
});

this.assessmentDuration =
(registry.getSingleMetric('assessment_duration_seconds') as Histogram<'status'>) ??
new prom.Histogram({
name: 'assessment_duration_seconds',
help: 'Time from attempt start to submission or timeout, in seconds',
labelNames: ['status'] as const,
buckets: [30, 60, 120, 300, 600, 1200, 1800],
registers: [registry],
});
} catch (err) {
this.logger.error('Failed to observe assessment duration', err as Error);
this.logger.error('Failed to initialize analytics metrics', err as Error);
this.featureEventsCounter = null;
this.assessmentDuration = null;
}
}

/**
* Wraps metric construction in a try/catch so a misconfigured registry
* (e.g. duplicate registration in tests) degrades to a null metric rather
* than crashing the service on startup.
*/
private registerMetric<T>(factory: () => T): T | null {
try {
return factory();
* Track an event with full validation and batching
*/
async trackEvent(dto: ITrackEventDto): Promise<void> {
try {
// Validate event
this.validationService.validateEventOrThrow(dto);

// Create event entity
const event = new AnalyticsEvent();
event.eventType = dto.eventType;
event.category = dto.category;
Expand All @@ -142,16 +69,17 @@ export class AnalyticsService implements OnModuleInit {
event.userAgent = dto.userAgent;
event.timestamp = new Date();

// Add to batch for processing
this.batchingService.addEvent(event);

// Record Prometheus metrics
if (this.featureEventsCounter) {
this.featureEventsCounter.inc({
category: dto.category,
action: dto.action,
eventType: dto.eventType,
});
this.featureEventsCounter.inc(
{
category: dto.category,
action: dto.action,
eventType: dto.eventType,
},
dto.value ?? 1,
);
}

this.logger.debug(`Event tracked: ${dto.eventType} - ${dto.category}.${dto.action}`);
Expand All @@ -164,29 +92,41 @@ export class AnalyticsService implements OnModuleInit {
}
}

/**
* Legacy method for backward compatibility with Prometheus metrics only
*/
recordEvent(category: string, action: string, label?: string, value?: number): void {
recordEvent(category: string, action: string, label = '', value = 1): void {
try {
if (this.featureEventsCounter) {
this.featureEventsCounter.inc(
{ category, action, eventType: EventType.CUSTOM },
value ?? 1,
value,
);
} else {
this.logger.debug(`Analytics event (log only): ${category}.${action} value=${value}`);
}
} catch (err) {
this.logger.warn('Could not register metric; proceeding without it', err as Error);
return null;
this.logger.error(
`Failed to record analytics event: ${category}.${action}`,
err as Error,
);
}
}
}

/**
* Query events with filters
*/
recordAssessmentStarted(assessmentId: string): void {
this.recordEvent('assessment', 'started', assessmentId);
}

recordAssessmentSubmitted(assessmentId: string, startedAt: Date): void {
this.recordEvent('assessment', 'submitted', assessmentId);
this.observeDuration(startedAt, 'submitted');
}

recordAssessmentTimedOut(assessmentId: string, startedAt: Date): void {
this.recordEvent('assessment', 'timed_out', assessmentId);
this.observeDuration(startedAt, 'timed_out');
}

recordAssessmentScore(score: number, maxScore: number): void {
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
this.recordEvent('assessment', 'score_recorded', '', pct);
}

async getEvents(filters: {
eventType?: EventType;
userId?: string;
Expand Down Expand Up @@ -219,20 +159,12 @@ export class AnalyticsService implements OnModuleInit {
}

query.orderBy('event.timestamp', 'DESC');

const limit = filters.limit || 100;
const offset = filters.offset || 0;

query.take(limit).skip(offset);
query.take(filters.limit ?? 100).skip(filters.offset ?? 0);

const [events, total] = await query.getManyAndCount();

return { events, total };
}

/**
* Get event analytics summary
*/
async getAnalyticsSummary(
startDate: Date,
endDate: Date,
Expand All @@ -242,12 +174,11 @@ export class AnalyticsService implements OnModuleInit {
eventsByCategory: Record<string, number>;
topActions: Array<{ action: string; count: number }>;
}> {
const query = this.eventRepository.createQueryBuilder('event');

query.where('event.timestamp >= :startDate', { startDate });
query.andWhere('event.timestamp <= :endDate', { endDate });

const totalEvents = await query.getCount();
const totalEvents = await this.eventRepository
.createQueryBuilder('event')
.where('event.timestamp >= :startDate', { startDate })
.andWhere('event.timestamp <= :endDate', { endDate })
.getCount();

const eventsByType = await this.eventRepository
.createQueryBuilder('event')
Expand Down Expand Up @@ -285,4 +216,13 @@ export class AnalyticsService implements OnModuleInit {
topActions: topActions.map((e) => ({ action: e.action, count: e.count })),
};
}

private observeDuration(startedAt: Date, status: string): void {
try {
const seconds = (Date.now() - startedAt.getTime()) / 1000;
this.assessmentDuration?.observe({ status }, seconds);
} catch (err) {
this.logger.error('Failed to observe assessment duration', err as Error);
}
}
}
Loading
Loading