Banners Analytics Architecture
Banners Analytics Architecture
The Banners module captures impression and click events asynchronously and aggregates them into daily stats rollups. All writes go through BullMQ — the tracking HTTP endpoints never write to a database directly. Impression/click events and the daily stats rollup are stored in MongoDB (packages/mongodb), not PostgreSQL — this reduces write/storage load on the primary Postgres database, which now holds only the banner/placement/campaign/targeting configuration entities (domain state, not analytics events).
1. Architecture Overview
Client browser/app
│
│ POST /api/mobile/banners/events/impression
│ POST /api/mobile/banners/events/click
▼
BannerTrackingController (@Public, 202 Accepted)
│
│ validate banner/placement (and, if provided, campaign) exist via Postgres
│ enqueue with jobId = correlationId, payload carries public_ids (not integer ids)
▼
BullMQ BANNERS queue (Redis)
│
│ async processing
▼
BannerEventsProcessor
├── RECORD_IMPRESSION → BannerImpression.create(...) [MongoDB]
├── RECORD_CLICK → BannerClick.create(...) [MongoDB]
├── SYNC_SCHEDULE_STATUS → UPDATE banners + campaigns + invalidate cache [PostgreSQL — unchanged]
└── ROLLUP_STATS → aggregate + BannerStat.bulkWrite(...) upsert [MongoDB]
(BannerSchedulerService registers cron jobs that enqueue SYNC_SCHEDULE_STATUS
and ROLLUP_STATS into the same BANNERS queue — unchanged from before.)
│
▼
Admin reads via BannerAnalyticsAdminController
├── GET /admin/banner-analytics/stats → BannerStat.find(...) [MongoDB, paginated]
└── GET /admin/banner-analytics/events → BannerImpression|Click.find(...) [MongoDB, cursor-paginated]The tracking controller returns 202 Accepted immediately after enqueuing the job. Database writes happen out of band and do not affect response latency for the client. SYNC_SCHEDULE_STATUS is the one handler that remains entirely Postgres-backed — it transitions banners/campaigns schedule state, which is domain configuration, not an analytics event, and was never in scope for this migration.
2. BullMQ Queue Configuration
Queue name: QueueName.BANNERS = "banners" — unchanged.
Registered in apps/api/src/services/bullmq/bull.module.ts inside the REGISTERED_QUEUES array. This registration enables Bull Board UI monitoring, global default job options (attempts, backoff), and graceful shutdown via BullMQService.
Two NestJS modules register this queue:
BannerTrackingModule— for enqueue-only access (@InjectQueue(QueueName.BANNERS))BannersWorkersModule— for processor + scheduler access (BannerEventsProcessor+BannerSchedulerService)
Nothing about queue registration, retry policy, or job option defaults changed as part of the Mongo migration — only what the processor does with each job's payload changed.
3. Job Contracts
All job contracts are defined in packages/jobs/src/index.ts:
| Job | Enum value | Producer | Consumer | Payload interface |
|---|---|---|---|---|
BannerJob.RECORD_IMPRESSION | "banner.record_impression" | BannerTrackingService.recordImpression | BannerEventsProcessor.processRecordImpression | RecordBannerImpressionPayload |
BannerJob.RECORD_CLICK | "banner.record_click" | BannerTrackingService.recordClick | BannerEventsProcessor.processRecordClick | RecordBannerClickPayload |
BannerJob.SYNC_SCHEDULE_STATUS | "banner.sync_schedule_status" | BannerSchedulerService.runScheduleSync (cron) | BannerEventsProcessor.processSyncScheduleStatus | SyncBannerScheduleStatusPayload |
BannerJob.ROLLUP_STATS | "banner.rollup_stats" | BannerSchedulerService.runStatsRollup (cron) | BannerEventsProcessor.processRollupStats | RollupBannerStatsPayload |
RecordBannerImpressionPayload and RecordBannerClickPayload changed shape as part of this migration — they now carry bannerPublicId/placementPublicId/campaignPublicId? (all string, uuid7) instead of the old bannerId/placementId/campaignId? (integer). This is because the processor now writes directly to MongoDB documents keyed on public_id — there is no longer an internal Postgres integer id in the loop for these two job types. SyncBannerScheduleStatusPayload/RollupBannerStatsPayload are unchanged.
export interface RecordBannerImpressionPayload {
bannerPublicId: string;
placementPublicId: string;
campaignPublicId?: string;
sessionId?: string;
userId?: string;
deviceType?: string;
countryCode?: string;
pageUrl?: string;
referrerUrl?: string;
occurredAt: string; // ISO 8601
correlationId: string;
}
export interface RecordBannerClickPayload {
bannerPublicId: string;
placementPublicId: string;
campaignPublicId?: string;
sessionId?: string;
userId?: string;
deviceType?: string;
countryCode?: string;
destinationUrl?: string;
pageUrl?: string;
occurredAt: string;
correlationId: string;
}4. Impression Flow (End-to-End)
4.1 Client Submission
POST /api/mobile/banners/events/impression
Content-Type: application/json
{
"bannerPublicId": "019b0b5f-...",
"placementPublicId": "019b0b5f-...",
"campaignPublicId": "019b0b5f-...",
"sessionId": "sess_abc123",
"deviceType": "mobile",
"countryCode": "NP",
"pageUrl": "https://shop.example.com/p/123",
"occurredAt": "2026-06-19T10:15:30.000Z",
"correlationId": "imp_019b0b5f-..._1750334400000"
}Unchanged — the public HTTP request/response contract was never touched by this migration.
4.2 Controller Handling
@Post("impression")
@HttpCode(HttpStatus.ACCEPTED)
@UseGuards(IpThrottlerGuard)
@IpThrottle({ limit: 600, windowSeconds: 60 })
async recordImpression(@Body() dto: RecordImpressionDto) {
await this.trackingService.recordImpression(dto);
return new ResponseDto("Impression recorded.", null);
}Unchanged.
4.3 Service: Existence Validation (Postgres, unchanged) + Payload Construction (new)
BannerTrackingService.recordImpression() still validates that the banner and placement exist (and, if a campaignPublicId is supplied, whether that campaign exists) via the same Postgres lookups as before:
const [[banner], [placement], [campaign]] = await Promise.all([
this.db.select({ id: banners.id }).from(banners)
.where(eq(banners.publicId, bannerPublicId)).limit(1),
this.db.select({ id: placements.id }).from(placements)
.where(eq(placements.publicId, placementPublicId)).limit(1),
campaignPublicId
? this.db.select({ id: campaigns.id }).from(campaigns)
.where(eq(campaigns.publicId, campaignPublicId)).limit(1)
: Promise.resolve([]),
]);- If banner not found: throws
NotFoundException(BANNER_NOT_FOUND, 404) — unchanged. - If placement not found: throws
NotFoundException(PLACEMENT_NOT_FOUND, 404) — unchanged. - If
campaignPublicIdprovided but not found: no throw (campaign may have been deleted after the impression) — but the behavior downstream changed: previously the resolved (now-undefined) integercampaignIdflowed into the job payload; today the same existence check gates whetherdto.campaignPublicId(the original string) is forwarded into the payload at all —payload.campaignPublicIdisdto.campaignPublicIdwhen the campaign was found, andundefinedwhen it wasn't. Net effect for callers is identical: a bogus/deleted campaign reference is silently dropped, not persisted.
What changed: the resolved integer ids (banner.id, placement.id) are no longer used to build the job payload at all — they exist only to prove existence. The payload is built directly from the original dto.bannerPublicId/dto.placementPublicId (already known-valid strings) plus the gated campaignPublicId above.
4.4 Enqueue with Deduplication
await this.bannersQueue.add(
BannerJob.RECORD_IMPRESSION,
payload,
{ jobId: correlationId },
).catch((err) => {
if (isDuplicateJobError(err)) return; // idempotent
throw err;
});Unchanged — jobId-based BullMQ deduplication works exactly as before; only what's inside payload changed (see ## 3).
4.5 Processor: MongoDB Write
BannerEventsProcessor.processRecordImpression(payload):
await BannerImpression.create({
bannerPublicId: payload.bannerPublicId,
placementPublicId: payload.placementPublicId,
campaignPublicId: payload.campaignPublicId ?? null,
sessionId: payload.sessionId ?? null,
userId: payload.userId ?? null,
deviceType: payload.deviceType ?? null,
countryCode: payload.countryCode ?? null,
pageUrl: payload.pageUrl ?? null,
referrerUrl: payload.referrerUrl ?? null,
occurredAt: new Date(payload.occurredAt), // string → Date
});BannerImpression is a raw Mongoose model exported from @happy-shop/mongodb (packages/mongodb/src/schemas/banners/banner-impression.schema.ts) — no NestJS DI wrapper, called directly as a static method, mirroring how this repo's pre-existing AuditLog model is used in activity.service.ts. occurredAt is still converted to a Date before the write. Nullable payload fields still default to null.
5. Click Flow
Identical to impression flow with two differences:
- Job name:
BannerJob.RECORD_CLICK destinationUrlfield is required in payload- No
referrerUrlfield on click events - Writes via
BannerClick.create(...)(MongoDB) instead ofbanner_clicks(Postgres)
6. Schedule Status Sync Flow
Unchanged in every respect — still 100% PostgreSQL. This handler transitions banners/campaigns schedule status (domain configuration), which was never analytics data and was never in scope for the Mongo migration.
6.1 Cron Trigger
BannerSchedulerService.runScheduleSync() runs each minute (BANNER_SCHEDULE_SYNC_CRON, default * * * * *) and enqueues BannerJob.SYNC_SCHEDULE_STATUS with a deterministic per-minute jobId (banner-sync-${YYYY-MM-DDTHH:MM}), exactly as before.
6.2 Processor: 4-Parallel Postgres Updates
BannerEventsProcessor.processSyncScheduleStatus(payload) still runs 4 concurrent Drizzle update(...) calls against banners/campaigns (scheduled→active, active/scheduled→expired for banners; scheduled→active, active/scheduled→ended for campaigns), exactly as before this migration — byte-for-byte unchanged code.
6.3 Cache Invalidation
Unchanged — redisCacheService.invalidatePattern("banners:serve:*") fires only when totalChanged > 0; failure is warn-only.
7. Stats Rollup Flow
7.1 Cron Trigger
BannerSchedulerService.runStatsRollup() runs nightly (BANNER_STATS_ROLLUP_CRON, default 0 1 * * *) and enqueues BannerJob.ROLLUP_STATS for the previous day (UTC) with jobId = banner-rollup-${statDate} — unchanged.
7.2 Processor: MongoDB Aggregation
BannerEventsProcessor.processRollupStats(payload) replaces the old Drizzle select().groupBy() queries with two Mongo aggregation pipelines (one per collection), sharing the same pipeline stages:
const matchStage = {
$match: {
occurredAt: { $gte: dayStart, $lte: dayEnd },
campaignPublicId: { $ne: null },
},
};
const groupStage = {
$group: {
_id: {
bannerPublicId: "$bannerPublicId",
placementPublicId: "$placementPublicId",
campaignPublicId: "$campaignPublicId",
},
eventCount: { $sum: 1 },
},
};
const [impressionRows, clickRows] = await Promise.all([
BannerImpression.aggregate([matchStage, groupStage]),
BannerClick.aggregate([matchStage, groupStage]),
]);Why filter campaignPublicId: { $ne: null }: identical reasoning to the old Postgres version — the BannerStat unique compound index on (bannerPublicId, placementPublicId, campaignPublicId, statDate) is the upsert key, and a rollup row only makes sense when tied to a specific campaign. Since the serving endpoint always supplies a campaign context, production analytics events always have a non-null campaignPublicId in practice.
7.3 Merge and Upsert
The in-memory merge (statsMap, keyed on ${bannerPublicId}:${placementPublicId}:${campaignPublicId}) is structurally identical to the old version — only the key's components changed from integer ids to public_id strings. The final write batches through BannerStat.bulkWrite([...]) using updateOne/upsert: true operations (in batches of ROLLUP_BATCH_SIZE = 100, unchanged), which is Mongo's direct equivalent of the old insert(...).onConflictDoUpdate(...).
7.4 Overwrite Semantics (Idempotency) — unchanged guarantee
Each updateOne's $set: { impressions, clicks, updatedAt } overwrites the prior value for that (banner, placement, campaign, date) tuple — not an accumulate. Running the rollup for the same statDate twice produces the same result; a retried job corrects rather than double-counts. This is the exact same idempotency guarantee the old ON CONFLICT DO UPDATE SET impressions = EXCLUDED.impressions provided — just expressed via Mongo's upsert: true instead of Postgres's onConflictDoUpdate.
8. Schema Details
All 3 collections live in packages/mongodb/src/schemas/banners/, following the same raw-Mongoose pattern as this repo's pre-existing AuditLog model — no @nestjs/mongoose, no NestJS DI wrapper.
BannerImpression / banner_impressions collection, and BannerClick / banner_clicks collection (append-only)
- Fields:
bannerPublicId,placementPublicId,campaignPublicId?(all string, indexed),sessionId?,userId?,deviceType?,countryCode?,pageUrl?,referrerUrl?(impression only) /destinationUrl?(click only),occurredAt(indexedDate),createdAt(append-only — noupdatedAt). - Indexes:
{ occurredAt: -1 },{ bannerPublicId: 1, occurredAt: -1 },{ campaignPublicId: 1, occurredAt: -1 }. - TTL: 180 days on
occurredAt— raw events auto-expire after 180 days;BannerStat's daily rollup is the durable, long-term-queryable record. This is a genuine improvement over the prior Postgres tables, which had no automatic retention/purge at all (see## 10). - No internal integer id is ever stored — every reference is the entity's
public_id, consistent with this codebase's Public ID Rule.
BannerStat / banner_stats collection (daily rollup, upserted)
- Fields:
bannerPublicId,placementPublicId,campaignPublicId?,statDate(YYYY-MM-DDstring),impressions,clicks,spendPaisa?(reserved, alwaysnulltoday — no spend-computation logic exists anywhere in this codebase),createdAt/updatedAt(mutable — re-upserted daily). - Unique compound index:
{ bannerPublicId: 1, placementPublicId: 1, campaignPublicId: 1, statDate: 1 }— the upsert key; mirrors the old Postgresbanner_stats_unique_day_idx. - No TTL — this is the durable rollup the admin analytics API queries.
9. DLQ and Job Failure Handling
Unchanged from before this migration — failed jobs still follow the global BullMQ configuration from bull.module.ts, still visible/retryable via BullBoard (/admin/bullboard).
SYNC_SCHEDULE_STATUS and ROLLUP_STATS remain safe to retry (both idempotent, as before). RECORD_IMPRESSION/RECORD_CLICK remain intentionally non-deduplicated at the persistence layer — a retry after a successful-but-marked-failed write can still produce a duplicate document, exactly the same accepted tradeoff as the old Postgres version (best-effort analytics, not billing-critical).
10. Data Retention
This changed materially as part of the migration — the old Postgres tables had no automatic purge at all (indefinite retention, growing forever). The new MongoDB collections have an explicit, built-in retention policy:
| Collection | Retention | Mechanism |
|---|---|---|
banner_impressions | 180 days | MongoDB TTL index on occurredAt (expireAfterSeconds) — MongoDB automatically deletes expired documents in a background process; no cron job or manual cleanup needed |
banner_clicks | 180 days | Same |
banner_stats | Indefinite | No TTL — small row count regardless of raw event volume (one row per banner × placement × campaign × day); this is the long-term-queryable rollup |
The 180-day window was chosen because raw events only need to support recent drill-down (the admin /events endpoint); the daily rollup (/stats) is the durable record for anything longer-range.
11. Observability
| Signal | What to Watch |
|---|---|
BullBoard /admin/bullboard (BANNERS queue) | Failed jobs, queue depth, processing rate — unchanged |
SYNC_SCHEDULE_STATUS job logs | totalChanged > 0 means banners/campaigns transitioned — unchanged |
ROLLUP_STATS job logs | Logs rows=<N> upserted per run |
| Redis memory | Watch for banners:serve:* key growth if TTL invalidation isn't working — unchanged |
MongoDB banner_stats document count | Should grow by roughly (active banner × placement × campaign combinations) per day; flat growth = rollup not running |
MongoDB banner_impressions/banner_clicks document count | Should self-bound around a 180-day rolling window once the TTL index has been active that long; unbounded growth before then is expected and not a signal of a problem |
| Duplicate impressions | If correlationId dedup breaks at the BullMQ layer, document count per banner spikes — same signal as before, now observed via a Mongo query instead of a Postgres one |
12. Admin Analytics API
Two read-only endpoints under apps/api/src/modules/banners/admin/analytics/, added specifically so admins can inspect banner/campaign performance without direct database access.
GET /admin/banner-analytics/stats
Paginated daily rollup, backed by BannerStat. Requires JwtAuthGuard/RoleGuard/BannerAnalytics_READ.
Query parameters: bannerPublicId?, placementPublicId?, campaignPublicId? (all optional uuid filters), from/to (required, YYYY-MM-DD, inclusive — the window must not exceed 90 days, or the endpoint returns 400 with BANNER_ANALYTICS_TIME_WINDOW_TOO_LARGE), plus the standard page/size/pagination fields inherited from QueryDto.
Response: ResponseDto<BannerStatResponseDto[]> with the standard offset-pagination metadata (count/currentPage/totalPage, per this codebase's ResponseDto convention).
GET /admin/banner-analytics/events
Cursor-paginated raw event drill-down, backed by BannerImpression or BannerClick depending on the required type query parameter ("impression" | "click"). Requires the same guards/permission as /stats.
Query parameters: type (required), bannerPublicId?/placementPublicId?/campaignPublicId? (optional filters), from?/to? (optional — defaults to the last 7 days when omitted; same 90-day cap applies), cursor? (opaque, from a previous page's nextCursor), limit? (default 20, max 100).
Response: ResponseDto<BannerEventResponseDto[]> with nextCursor (string or null on the last page) carried in ResponseDto's pagination argument, following the cursor-pagination convention used across admin list endpoints.
Both endpoints are documented in Swagger under the "Banner Analytics (Admin)" tag at /api/api-docs.