From daaa9380cf8320f3d53b26ad8b2ab246926451d2 Mon Sep 17 00:00:00 2001
From: Polliog
Date: Mon, 13 Jul 2026 18:00:01 +0200
Subject: [PATCH 1/6] replace per-org digest crons with hourly dispatch sweep
---
.../migrations/053_digest_last_sent.sql | 7 +
packages/backend/src/database/types.ts | 1 +
.../backend/src/modules/digests/scheduler.ts | 80 +++-------
.../backend/src/queue/jobs/digest-dispatch.ts | 111 ++++++++++++++
.../tests/modules/digests/scheduler.test.ts | 100 +++---------
.../tests/queue/jobs/digest-dispatch.test.ts | 142 ++++++++++++++++++
packages/backend/src/worker.ts | 27 +++-
7 files changed, 325 insertions(+), 143 deletions(-)
create mode 100644 packages/backend/migrations/053_digest_last_sent.sql
create mode 100644 packages/backend/src/queue/jobs/digest-dispatch.ts
create mode 100644 packages/backend/src/tests/queue/jobs/digest-dispatch.test.ts
diff --git a/packages/backend/migrations/053_digest_last_sent.sql b/packages/backend/migrations/053_digest_last_sent.sql
new file mode 100644
index 00000000..89c81cb2
--- /dev/null
+++ b/packages/backend/migrations/053_digest_last_sent.sql
@@ -0,0 +1,7 @@
+-- ============================================================================
+-- Migration 053: Digest dispatch bookkeeping
+-- Adds last_sent_at to digest_configs. The hourly digest-dispatch cron uses it
+-- as a double-fire guard and operators can see when a digest last went out.
+-- ============================================================================
+
+ALTER TABLE digest_configs ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMPTZ;
diff --git a/packages/backend/src/database/types.ts b/packages/backend/src/database/types.ts
index 72c5e03a..d6c65c65 100644
--- a/packages/backend/src/database/types.ts
+++ b/packages/backend/src/database/types.ts
@@ -1138,6 +1138,7 @@ export interface DigestConfigsTable {
delivery_hour: number;
delivery_day_of_week: number | null;
enabled: Generated;
+ last_sent_at: Timestamp | null;
created_at: Generated;
updated_at: Generated;
}
diff --git a/packages/backend/src/modules/digests/scheduler.ts b/packages/backend/src/modules/digests/scheduler.ts
index 2dc163ac..540e3ba7 100644
--- a/packages/backend/src/modules/digests/scheduler.ts
+++ b/packages/backend/src/modules/digests/scheduler.ts
@@ -1,17 +1,21 @@
/**
* Digest Scheduler
*
- * Reads all active digest configs from the database and registers them as
- * repeating cron jobs via the queue abstraction. Called once at worker startup.
+ * Registers a single static hourly cron job ("digest-dispatch") at worker
+ * startup. The dispatch job (src/queue/jobs/digest-dispatch.ts) scans the
+ * active digest configs each hour and enqueues digest-generation jobs for the
+ * ones due at that UTC hour.
*
- * Both BullMQ (Redis) and graphile-worker (PostgreSQL) backends are supported
- * through the ICronRegistry interface — this service never knows which is active.
+ * Earlier versions registered one cron job per organization at boot, but
+ * graphile-worker cron items cannot change while the runner is up, so config
+ * CRUD would have required a worker restart. A static dispatch cron plus a
+ * due-check keeps schedule changes live on both queue backends (BullMQ and
+ * graphile-worker) through the ICronRegistry interface - this service never
+ * knows which is active.
*/
-import { db } from '../../database/connection.js';
import { getCronRegistry } from '../../queue/queue-factory.js';
import { hub } from '@logtide/core';
-import type { CronJobDefinition } from '../../queue/abstractions/types.js';
export interface DigestJobPayload {
organizationId: string;
@@ -19,58 +23,24 @@ export interface DigestJobPayload {
frequency: 'daily' | 'weekly';
}
-export class DigestScheduler {
- /**
- * Register all active digest configs as repeating cron jobs.
- */
- async registerAllDigests(): Promise {
- const configs = await db
- .selectFrom('digest_configs')
- .select(['id', 'organization_id', 'frequency', 'delivery_hour', 'delivery_day_of_week'])
- .where('enabled', '=', true)
- .execute();
-
- if (configs.length === 0) {
- hub.captureLog('info', '[DigestScheduler] No active digest configs found');
- return;
- }
-
- const items: CronJobDefinition[] = configs.map((config) => ({
- task: 'digest-generation',
- cronExpression: this.buildCronExpression(
- config.frequency as 'daily' | 'weekly',
- config.delivery_hour,
- config.delivery_day_of_week
- ),
- payload: {
- organizationId: config.organization_id,
- digestConfigId: config.id,
- frequency: config.frequency,
- } satisfies DigestJobPayload,
- // Stable identifier per org — prevents duplicate schedules on restart
- identifier: `digest:${config.organization_id}`,
- }));
-
- await getCronRegistry('digest-generation').registerCronJobs(items);
- hub.captureLog('info', `[DigestScheduler] Registered ${items.length} digest schedule(s)`);
- }
+export const DIGEST_DISPATCH_CRON = '0 * * * *'; // every hour on the hour
+export class DigestScheduler {
/**
- * Build a standard 5-field cron expression from a digest config.
- *
- * Daily: "0 8 * * *" — every day at delivery_hour
- * Weekly: "0 8 * * 1" — every week on delivery_day_of_week
+ * Register the hourly digest-dispatch cron job. Called once at worker boot.
*/
- private buildCronExpression(
- frequency: 'daily' | 'weekly',
- deliveryHour: number,
- deliveryDayOfWeek: number | null
- ): string {
- if (frequency === 'daily') {
- return `0 ${deliveryHour} * * *`;
- }
- // Weekly — delivery_day_of_week is guaranteed non-null by the DB constraint
- return `0 ${deliveryHour} * * ${deliveryDayOfWeek}`;
+ async registerDispatchCron(): Promise {
+ await getCronRegistry('digest-dispatch').registerCronJobs([
+ {
+ task: 'digest-dispatch',
+ cronExpression: DIGEST_DISPATCH_CRON,
+ payload: {},
+ // Stable identifier - prevents duplicate schedules on restart
+ identifier: 'digest-dispatch',
+ },
+ ]);
+
+ hub.captureLog('info', '[DigestScheduler] Registered hourly digest dispatch cron');
}
}
diff --git a/packages/backend/src/queue/jobs/digest-dispatch.ts b/packages/backend/src/queue/jobs/digest-dispatch.ts
new file mode 100644
index 00000000..9adb8a4b
--- /dev/null
+++ b/packages/backend/src/queue/jobs/digest-dispatch.ts
@@ -0,0 +1,111 @@
+/**
+ * Digest Dispatch Job Processor
+ *
+ * Runs hourly (single static cron registered at worker boot). Scans the active
+ * digest configs, decides which ones are due at the current UTC hour and
+ * enqueues one digest-generation job per due config.
+ *
+ * This indirection exists because graphile-worker cron items are fixed at
+ * runner start: per-org cron jobs cannot be added or removed while the worker
+ * is running, so config CRUD would need a worker restart to take effect. A
+ * single static dispatch cron plus a due-check keeps schedule changes live on
+ * both queue backends.
+ */
+
+import { db } from '../../database/connection.js';
+import { createQueue } from '../connection.js';
+import { hub } from '@logtide/core';
+import type { IJob } from '../abstractions/types.js';
+import type { DigestJobPayload } from '../../modules/digests/scheduler.js';
+
+interface DispatchableConfig {
+ frequency: 'daily' | 'weekly';
+ delivery_hour: number;
+ delivery_day_of_week: number | null;
+ last_sent_at: Date | null;
+}
+
+// A digest fires at most once per delivery slot; anything sent less than two
+// hours ago is a duplicate trigger (worker restart, overlapping cron tick).
+const DOUBLE_FIRE_GUARD_MS = 2 * 60 * 60 * 1000;
+
+export function isConfigDue(config: DispatchableConfig, now: Date): boolean {
+ if (now.getUTCHours() !== config.delivery_hour) {
+ return false;
+ }
+
+ if (config.frequency === 'weekly' && now.getUTCDay() !== config.delivery_day_of_week) {
+ return false;
+ }
+
+ if (
+ config.last_sent_at &&
+ now.getTime() - new Date(config.last_sent_at).getTime() < DOUBLE_FIRE_GUARD_MS
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+export async function processDigestDispatch(_job: IJob>): Promise {
+ const now = new Date();
+
+ // tenant-scope-ok: system dispatch cron sweeps every org's digest config by design
+ const configs = await db
+ .selectFrom('digest_configs')
+ .select([
+ 'id',
+ 'organization_id',
+ 'frequency',
+ 'delivery_hour',
+ 'delivery_day_of_week',
+ 'last_sent_at',
+ ])
+ .where('enabled', '=', true)
+ .execute();
+
+ const due = configs.filter((config) =>
+ isConfigDue(
+ {
+ frequency: config.frequency,
+ delivery_hour: config.delivery_hour,
+ delivery_day_of_week: config.delivery_day_of_week,
+ last_sent_at: config.last_sent_at ? new Date(config.last_sent_at as unknown as string) : null,
+ },
+ now
+ )
+ );
+
+ if (due.length === 0) {
+ return;
+ }
+
+ hub.captureLog('info', `[DigestDispatch] ${due.length} digest(s) due at ${now.toISOString()}`);
+
+ const queue = createQueue('digest-generation');
+ const hourKey = now.toISOString().slice(0, 13); // YYYY-MM-DDTHH (UTC)
+
+ for (const config of due) {
+ await queue.add(
+ 'digest-generation',
+ {
+ organizationId: config.organization_id,
+ digestConfigId: config.id,
+ frequency: config.frequency,
+ },
+ {
+ // Hour-keyed so a duplicate dispatch within the same slot dedupes
+ jobKey: `digest:${config.id}:${hourKey}`,
+ removeOnComplete: true,
+ }
+ );
+
+ // tenant-scope-ok: id comes from the enabled-configs sweep above; system cron has no org context
+ await db
+ .updateTable('digest_configs')
+ .set({ last_sent_at: now })
+ .where('id', '=', config.id)
+ .execute();
+ }
+}
diff --git a/packages/backend/src/tests/modules/digests/scheduler.test.ts b/packages/backend/src/tests/modules/digests/scheduler.test.ts
index e237dbaf..86891e09 100644
--- a/packages/backend/src/tests/modules/digests/scheduler.test.ts
+++ b/packages/backend/src/tests/modules/digests/scheduler.test.ts
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { DigestScheduler, digestScheduler } from '../../../modules/digests/scheduler.js';
-import { db } from '../../../database/connection.js';
+import { DigestScheduler, DIGEST_DISPATCH_CRON } from '../../../modules/digests/scheduler.js';
import { getCronRegistry } from '../../../queue/queue-factory.js';
vi.mock('@logtide/core', () => ({
@@ -9,23 +8,11 @@ vi.mock('@logtide/core', () => ({
},
}));
-
-vi.mock('../../../database/connection.js', () => {
- return {
- db: {
- selectFrom: vi.fn().mockReturnThis(),
- select: vi.fn().mockReturnThis(),
- where: vi.fn().mockReturnThis(),
- execute: vi.fn(),
- },
- };
-});
-
-
+const mockRegisterCronJobs = vi.fn().mockResolvedValue(undefined);
vi.mock('../../../queue/queue-factory.js', () => {
return {
getCronRegistry: vi.fn().mockReturnValue({
- registerCronJobs: vi.fn().mockResolvedValue(undefined),
+ registerCronJobs: (...args: unknown[]) => mockRegisterCronJobs(...args),
}),
};
});
@@ -35,73 +22,26 @@ describe('DigestScheduler', () => {
vi.clearAllMocks();
});
- describe('registerAllDigests', () => {
- it('should log and return early if no active configs are found', async () => {
- const { hub } = await import('@logtide/core');
- ((db as any).execute as ReturnType).mockResolvedValue([]);
-
- const scheduler = new DigestScheduler();
- await scheduler.registerAllDigests();
-
- expect(hub.captureLog).toHaveBeenCalledWith('info', '[DigestScheduler] No active digest configs found');
- expect(getCronRegistry('digest-generation').registerCronJobs).not.toHaveBeenCalled();
- });
-
- it('should correctly register a daily and a weekly cron job', async () => {
- const mockConfigs = [
- {
- id: 'conf_1',
- organization_id: 'org_1',
- frequency: 'daily',
- delivery_hour: 8,
- delivery_day_of_week: null
- },
- {
- id: 'conf_2',
- organization_id: 'org_2',
- frequency: 'weekly',
- delivery_hour: 14,
- delivery_day_of_week: 1
- }
- ];
-
- ((db as any).execute as ReturnType).mockResolvedValue(mockConfigs);
- const { hub } = await import('@logtide/core');
-
+ describe('registerDispatchCron', () => {
+ it('registers a single hourly dispatch cron job', async () => {
const scheduler = new DigestScheduler();
- await scheduler.registerAllDigests();
-
- const expectedItems = [
- {
- task: 'digest-generation',
- cronExpression: '0 8 * * *',
- payload: {
- organizationId: 'org_1',
- digestConfigId: 'conf_1',
- frequency: 'daily'
- },
- identifier: 'digest:org_1'
- },
- {
- task: 'digest-generation',
- cronExpression: '0 14 * * 1',
- payload: {
- organizationId: 'org_2',
- digestConfigId: 'conf_2',
- frequency: 'weekly'
- },
- identifier: 'digest:org_2'
- }
- ];
-
- expect(getCronRegistry('digest-generation').registerCronJobs).toHaveBeenCalledWith(expectedItems);
- expect(hub.captureLog).toHaveBeenCalledWith('info', '[DigestScheduler] Registered 2 digest schedule(s)');
+ await scheduler.registerDispatchCron();
+
+ expect(getCronRegistry).toHaveBeenCalledWith('digest-dispatch');
+ expect(mockRegisterCronJobs).toHaveBeenCalledTimes(1);
+
+ const items = mockRegisterCronJobs.mock.calls[0][0];
+ expect(items).toHaveLength(1);
+ expect(items[0]).toEqual({
+ task: 'digest-dispatch',
+ cronExpression: DIGEST_DISPATCH_CRON,
+ payload: {},
+ identifier: 'digest-dispatch',
+ });
});
- });
- describe('export instance', () => {
- it('should export a singleton instance', () => {
- expect(digestScheduler).toBeInstanceOf(DigestScheduler);
+ it('uses an hourly cron expression', () => {
+ expect(DIGEST_DISPATCH_CRON).toBe('0 * * * *');
});
});
});
diff --git a/packages/backend/src/tests/queue/jobs/digest-dispatch.test.ts b/packages/backend/src/tests/queue/jobs/digest-dispatch.test.ts
new file mode 100644
index 00000000..9bead0e0
--- /dev/null
+++ b/packages/backend/src/tests/queue/jobs/digest-dispatch.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { IJob } from '../../../queue/abstractions/types.js';
+
+vi.mock('@logtide/core', () => ({
+ hub: {
+ captureLog: vi.fn(),
+ },
+}));
+
+const mockAdd = vi.fn().mockResolvedValue({ id: 'job_1' });
+vi.mock('../../../queue/connection.js', () => ({
+ createQueue: vi.fn(() => ({
+ add: mockAdd,
+ })),
+}));
+
+const mockExecute = vi.fn();
+const mockUpdateExecute = vi.fn().mockResolvedValue(undefined);
+vi.mock('../../../database/connection.js', () => ({
+ db: {
+ selectFrom: vi.fn().mockReturnThis(),
+ select: vi.fn().mockReturnThis(),
+ updateTable: vi.fn().mockReturnThis(),
+ set: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ execute: (...args: unknown[]) => mockExecute(...args),
+ },
+}));
+
+import { isConfigDue, processDigestDispatch } from '../../../queue/jobs/digest-dispatch.js';
+import { db } from '../../../database/connection.js';
+
+function makeJob(): IJob> {
+ return { id: 'dispatch_1', name: 'digest-dispatch', data: {} };
+}
+
+describe('isConfigDue', () => {
+ // 2026-07-13 is a Monday; 08:30 UTC
+ const mondayAt8 = new Date('2026-07-13T08:30:00.000Z');
+
+ const baseDaily = {
+ frequency: 'daily' as const,
+ delivery_hour: 8,
+ delivery_day_of_week: null,
+ last_sent_at: null,
+ };
+
+ it('daily config is due when the current UTC hour matches', () => {
+ expect(isConfigDue(baseDaily, mondayAt8)).toBe(true);
+ });
+
+ it('daily config is not due on a different hour', () => {
+ expect(isConfigDue({ ...baseDaily, delivery_hour: 9 }, mondayAt8)).toBe(false);
+ });
+
+ it('weekly config requires matching UTC day of week', () => {
+ const weekly = { ...baseDaily, frequency: 'weekly' as const, delivery_day_of_week: 1 };
+ expect(isConfigDue(weekly, mondayAt8)).toBe(true);
+ expect(isConfigDue({ ...weekly, delivery_day_of_week: 2 }, mondayAt8)).toBe(false);
+ });
+
+ it('is not due again when last_sent_at is within the double-fire guard', () => {
+ const sentRecently = { ...baseDaily, last_sent_at: new Date('2026-07-13T08:05:00.000Z') };
+ expect(isConfigDue(sentRecently, mondayAt8)).toBe(false);
+ });
+
+ it('is due when last_sent_at is older than the guard window', () => {
+ const sentYesterday = { ...baseDaily, last_sent_at: new Date('2026-07-12T08:30:00.000Z') };
+ expect(isConfigDue(sentYesterday, mondayAt8)).toBe(true);
+ });
+});
+
+describe('processDigestDispatch', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('enqueues generation jobs only for due configs and stamps last_sent_at', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-07-13T08:00:30.000Z'));
+
+ mockExecute.mockResolvedValueOnce([
+ {
+ id: 'conf_due',
+ organization_id: 'org_1',
+ frequency: 'daily',
+ delivery_hour: 8,
+ delivery_day_of_week: null,
+ last_sent_at: null,
+ },
+ {
+ id: 'conf_not_due',
+ organization_id: 'org_2',
+ frequency: 'daily',
+ delivery_hour: 20,
+ delivery_day_of_week: null,
+ last_sent_at: null,
+ },
+ ]);
+ // updateTable(...).execute() resolves via the same mockExecute
+ mockExecute.mockResolvedValue(undefined);
+
+ await processDigestDispatch(makeJob());
+
+ expect(mockAdd).toHaveBeenCalledTimes(1);
+ const [jobName, payload, options] = mockAdd.mock.calls[0];
+ expect(jobName).toBe('digest-generation');
+ expect(payload).toEqual({
+ organizationId: 'org_1',
+ digestConfigId: 'conf_due',
+ frequency: 'daily',
+ });
+ // hour-keyed jobKey dedupes double enqueues within the same hour
+ expect(options.jobKey).toBe('digest:conf_due:2026-07-13T08');
+
+ expect(db.updateTable).toHaveBeenCalledWith('digest_configs');
+ vi.useRealTimers();
+ });
+
+ it('does nothing when no config is due', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-07-13T03:00:00.000Z'));
+
+ mockExecute.mockResolvedValueOnce([
+ {
+ id: 'conf_1',
+ organization_id: 'org_1',
+ frequency: 'daily',
+ delivery_hour: 8,
+ delivery_day_of_week: null,
+ last_sent_at: null,
+ },
+ ]);
+
+ await processDigestDispatch(makeJob());
+
+ expect(mockAdd).not.toHaveBeenCalled();
+ expect(db.updateTable).not.toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+});
diff --git a/packages/backend/src/worker.ts b/packages/backend/src/worker.ts
index e7efc061..b58efb5b 100644
--- a/packages/backend/src/worker.ts
+++ b/packages/backend/src/worker.ts
@@ -25,8 +25,8 @@ import { enrichmentService } from './modules/siem/enrichment-service.js';
import { retentionService } from './modules/retention/index.js';
import { auditLogService } from './modules/audit-log/index.js';
import { sigmaSyncService } from './modules/sigma/sync-service.js';
-// Email digest feature is incomplete and disabled for 1.0.0-beta (see #154 below).
-// import { digestScheduler } from './modules/digests/scheduler.js';
+import { digestScheduler } from './modules/digests/scheduler.js';
+import { processDigestDispatch } from './queue/jobs/digest-dispatch.js';
import { initializeWorkerLogging, shutdownInternalLogging, isInternalLoggingEnabled } from './utils/internal-logger.js';
import { hub } from '@logtide/core';
import { reservoir, reservoirReady } from './database/reservoir.js';
@@ -92,12 +92,13 @@ const pipelineWorker = createWorker('log-pipeline', async (j
const digestWorker = createWorker('digest-generation', async (job) => {
await processDigestGeneration(job);
});
-// The email digest reports feature (#154) is incomplete: it only summarizes log
-// volume (no error groups, security or uptime sections, no HTML layout) and has no
-// UI to configure it. The scheduler is intentionally left unregistered so no partial
-// digests are ever sent in the 1.0.0-beta release. The worker, service, database
-// schema and tests stay in place so the remaining scope can be finished under #154.
-// await digestScheduler.registerAllDigests();
+// Hourly dispatch cron scans digest configs and enqueues due digests (#154).
+// A single static cron (instead of one per org) keeps config CRUD live on both
+// queue backends: graphile-worker cron items cannot change while the runner is up.
+const digestDispatchWorker = createWorker>('digest-dispatch', async (job) => {
+ await processDigestDispatch(job);
+});
+await digestScheduler.registerDispatchCron();
// Create worker for outbound webhook delivery (#218)
const webhookDeliveryWorker = createWorker('webhook-delivery', async (job) => {
@@ -335,6 +336,15 @@ digestWorker.on('failed', (job, err) => {
}
});
+digestDispatchWorker.on('failed', (job, err) => {
+ if (isInternalLoggingEnabled()) {
+ hub.captureLog('error', `Digest dispatch job failed: ${err.message}`, {
+ error: { name: err.name, message: err.message, stack: err.stack },
+ jobId: job?.id,
+ });
+ }
+});
+
webhookDeliveryWorker.on('failed', (job, err) => {
console.error(`Webhook delivery job ${job?.id} failed:`, err);
if (isInternalLoggingEnabled()) {
@@ -912,6 +922,7 @@ async function gracefulShutdown(signal: string) {
await monitorNotificationWorker.close();
await pipelineWorker.close();
await digestWorker.close();
+ await digestDispatchWorker.close();
await webhookDeliveryWorker.close();
await receiverEventsWorker.close();
console.log('[Worker] Workers closed');
From d531157b6c7beb488687c7e73160af9c2d1da2bb Mon Sep 17 00:00:00 2001
From: Polliog
Date: Mon, 13 Jul 2026 18:04:17 +0200
Subject: [PATCH 2/6] add digest report sections: errors, security, uptime
---
.../backend/src/modules/digests/generator.ts | 425 +++++++++++++-----
.../tests/modules/digests/generator.test.ts | 220 +++++++--
2 files changed, 489 insertions(+), 156 deletions(-)
diff --git a/packages/backend/src/modules/digests/generator.ts b/packages/backend/src/modules/digests/generator.ts
index 518a2781..6505ac46 100644
--- a/packages/backend/src/modules/digests/generator.ts
+++ b/packages/backend/src/modules/digests/generator.ts
@@ -1,7 +1,9 @@
/**
* Digest Generator Service
*
- * Generates and sends email digest reports summarizing log activity over a period.
+ * Generates and sends email digest reports summarizing organization activity
+ * over a period: log volume, top services by errors, new error groups,
+ * security detections and monitor uptime.
*/
import nodemailer from 'nodemailer';
@@ -11,10 +13,37 @@ import { hub } from '@logtide/core';
import { config } from '../../config/index.js';
import type { DigestJobPayload } from './scheduler.js';
-interface LogVolumeStats {
- currentPeriodCount: number;
- previousPeriodCount: number;
- trend: string;
+export interface DigestReportData {
+ organizationName: string;
+ frequency: 'daily' | 'weekly';
+ periodLabel: string;
+ logVolume: {
+ current: number;
+ previous: number;
+ trend: string;
+ };
+ topErrorServices: Array<{
+ service: string;
+ errorCount: number;
+ previousCount: number;
+ delta: number;
+ }>;
+ newErrorGroups: Array<{
+ exceptionType: string;
+ exceptionMessage: string;
+ occurrenceCount: number;
+ language: string;
+ }>;
+ security: {
+ totalDetections: number;
+ topRules: Array<{ ruleTitle: string; severity: string; count: number }>;
+ openIncidents: number;
+ };
+ uptime: {
+ monitorCount: number;
+ overallUptimePct: number;
+ worstMonitors: Array<{ name: string; uptimePct: number }>;
+ } | null;
}
interface DigestRecipient {
@@ -22,6 +51,14 @@ interface DigestRecipient {
unsubscribe_token: string;
}
+interface Period {
+ from: Date;
+ to: Date;
+ previousFrom: Date;
+ previousTo: Date;
+}
+
+const ERROR_LEVELS = ['error', 'critical'] as const;
let emailTransporter: nodemailer.Transporter | null = null;
@@ -53,14 +90,13 @@ function getEmailTransporter(): nodemailer.Transporter | null {
}
export class DigestGeneratorService {
-
+
async generateAndSendDigest(payload: DigestJobPayload): Promise {
const { organizationId, digestConfigId, frequency } = payload;
hub.captureLog('info', `[DigestGenerator] Generating ${frequency} digest for org ${organizationId}`);
try {
-
const organization = await db
.selectFrom('organizations')
.select(['name'])
@@ -71,7 +107,6 @@ export class DigestGeneratorService {
throw new Error(`Organization ${organizationId} not found`);
}
-
const recipients = await this.fetchRecipients(organizationId, digestConfigId);
if (recipients.length === 0) {
@@ -79,16 +114,9 @@ export class DigestGeneratorService {
return;
}
-
- const stats = await this.calculateLogVolume(organizationId, frequency);
+ const report = await this.buildReportData(organizationId, organization.name, frequency);
-
- await this.sendDigestEmails(
- recipients,
- organization.name,
- frequency,
- stats
- );
+ await this.sendDigestEmails(recipients, report);
hub.captureLog('info', `[DigestGenerator] Digest sent to ${recipients.length} recipient(s) for org ${organizationId}`);
} catch (error: any) {
@@ -97,92 +125,284 @@ export class DigestGeneratorService {
}
}
-
- private async fetchRecipients(
+ /**
+ * Compute all report sections for the period. Sections run sequentially:
+ * this is a background cron path where simplicity beats latency.
+ */
+ async buildReportData(
organizationId: string,
- digestConfigId: string
- ): Promise {
- const recipients = await db
- .selectFrom('digest_recipients')
- .select(['email', 'unsubscribe_token'])
+ organizationName: string,
+ frequency: 'daily' | 'weekly'
+ ): Promise {
+ const period = this.buildPeriod(frequency);
+
+ const projects = await db
+ .selectFrom('projects')
+ .select(['id'])
.where('organization_id', '=', organizationId)
- .where('digest_config_id', '=', digestConfigId)
- .where('subscribed', '=', true)
.execute();
+ const projectIds = projects.map((p) => p.id);
- return recipients;
+ const logVolume = await this.calculateLogVolume(projectIds, period);
+ const topErrorServices = await this.calculateTopErrorServices(projectIds, period);
+ const newErrorGroups = await this.calculateNewErrorGroups(organizationId, period);
+ const security = await this.calculateSecuritySummary(organizationId, period);
+ const uptime = await this.calculateUptimeSummary(organizationId, period);
+
+ return {
+ organizationName,
+ frequency,
+ periodLabel: frequency === 'daily' ? 'last 24 hours' : 'last 7 days',
+ logVolume,
+ topErrorServices,
+ newErrorGroups,
+ security,
+ uptime,
+ };
+ }
+
+ private buildPeriod(frequency: 'daily' | 'weekly'): Period {
+ // Sliding window relative to execution time
+ const hoursInPeriod = frequency === 'daily' ? 24 : 168;
+ const now = new Date();
+ const from = new Date(now.getTime() - hoursInPeriod * 60 * 60 * 1000);
+ const previousFrom = new Date(now.getTime() - hoursInPeriod * 2 * 60 * 60 * 1000);
+
+ return { from, to: now, previousFrom, previousTo: from };
}
private async calculateLogVolume(
+ projectIds: string[],
+ period: Period
+ ): Promise {
+ if (projectIds.length === 0) {
+ return { current: 0, previous: 0, trend: this.calculateTrend(0, 0) };
+ }
+
+ const currentResult = await reservoir.count({
+ projectId: projectIds,
+ from: period.from,
+ to: period.to,
+ toExclusive: true,
+ });
+
+ const previousResult = await reservoir.count({
+ projectId: projectIds,
+ from: period.previousFrom,
+ to: period.previousTo,
+ toExclusive: true,
+ });
+
+ return {
+ current: currentResult.count,
+ previous: previousResult.count,
+ trend: this.calculateTrend(currentResult.count, previousResult.count),
+ };
+ }
+
+ /**
+ * Top 5 services by error+critical log count, with the delta against the
+ * previous period. Engine-agnostic through reservoir.topValues.
+ */
+ private async calculateTopErrorServices(
+ projectIds: string[],
+ period: Period
+ ): Promise {
+ if (projectIds.length === 0) {
+ return [];
+ }
+
+ const current = await reservoir.topValues({
+ field: 'service',
+ projectId: projectIds,
+ level: [...ERROR_LEVELS],
+ from: period.from,
+ to: period.to,
+ limit: 5,
+ });
+
+ if (current.values.length === 0) {
+ return [];
+ }
+
+ // Wide limit so services that dropped out of the top 5 still resolve
+ const previous = await reservoir.topValues({
+ field: 'service',
+ projectId: projectIds,
+ level: [...ERROR_LEVELS],
+ from: period.previousFrom,
+ to: period.previousTo,
+ limit: 100,
+ });
+
+ const previousByService = new Map(previous.values.map((v) => [v.value, v.count]));
+
+ return current.values.map((v) => {
+ const previousCount = previousByService.get(v.value) ?? 0;
+ return {
+ service: v.value,
+ errorCount: v.count,
+ previousCount,
+ delta: v.count - previousCount,
+ };
+ });
+ }
+
+ /**
+ * Error groups whose first occurrence falls inside the period.
+ */
+ private async calculateNewErrorGroups(
organizationId: string,
- frequency: 'daily' | 'weekly'
- ): Promise {
- // Uses a 24-hour sliding window relative to execution time
- const hoursInPeriod = frequency === 'daily' ? 24 : 168; // 7 days = 168 hours
+ period: Period
+ ): Promise {
+ const groups = await db
+ .selectFrom('error_groups')
+ .select(['exception_type', 'exception_message', 'occurrence_count', 'language'])
+ .where('organization_id', '=', organizationId)
+ .where('first_seen', '>=', period.from)
+ .where('first_seen', '<', period.to)
+ .orderBy('occurrence_count', 'desc')
+ .limit(10)
+ .execute();
- const now = new Date();
- const currentPeriodStart = new Date(now.getTime() - hoursInPeriod * 60 * 60 * 1000);
- const previousPeriodStart = new Date(now.getTime() - hoursInPeriod * 2 * 60 * 60 * 1000);
- const previousPeriodEnd = currentPeriodStart;
+ return groups.map((g) => ({
+ exceptionType: g.exception_type,
+ exceptionMessage: g.exception_message ?? '',
+ occurrenceCount: g.occurrence_count,
+ language: g.language,
+ }));
+ }
- let currentPeriodCount = 0;
- let previousPeriodCount = 0;
+ /**
+ * Security summary: windowed detection totals and top triggered Sigma rules
+ * (raw detection_events, so the most recent hours are never stale like the
+ * continuous aggregates), plus a point-in-time open incident count.
+ */
+ private async calculateSecuritySummary(
+ organizationId: string,
+ period: Period
+ ): Promise {
+ const totalRow = await db
+ .selectFrom('detection_events')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('organization_id', '=', organizationId)
+ .where('time', '>=', period.from)
+ .where('time', '<', period.to)
+ .executeTakeFirst();
- try {
-
- // organization-level metrics we must count logs for all projects that
- // belong to the organization and pass those project ids to the reservoir
- const projects = await db
- .selectFrom('projects')
- .select(['id'])
- .where('organization_id', '=', organizationId)
- .execute();
-
- const projectIds = projects.map((p) => p.id);
-
- // If the organization has no projects, return zero counts explicitly
- if (projectIds.length === 0) {
- const trend = this.calculateTrend(0, 0);
- return {
- currentPeriodCount: 0,
- previousPeriodCount: 0,
- trend,
- };
- } else {
- const currentPeriodResult = await reservoir.count({
- projectId: projectIds,
- from: currentPeriodStart,
- to: now,
- toExclusive: true,
- });
- currentPeriodCount = currentPeriodResult.count;
+ const topRules = await db
+ .selectFrom('detection_events')
+ .select((eb) => ['rule_title', 'severity', eb.fn.countAll().as('count')] as const)
+ .where('organization_id', '=', organizationId)
+ .where('time', '>=', period.from)
+ .where('time', '<', period.to)
+ .groupBy(['rule_title', 'severity'])
+ .orderBy('count', 'desc')
+ .limit(5)
+ .execute();
+
+ const openRow = await db
+ .selectFrom('incidents')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('organization_id', '=', organizationId)
+ .where('status', 'in', ['open', 'investigating'])
+ .executeTakeFirst();
+
+ return {
+ totalDetections: Number(totalRow?.count ?? 0),
+ topRules: topRules.map((r) => ({
+ ruleTitle: r.rule_title,
+ severity: r.severity,
+ count: Number(r.count),
+ })),
+ openIncidents: Number(openRow?.count ?? 0),
+ };
+ }
+
+ /**
+ * Uptime summary from monitor_uptime_daily. Returns null when the org has
+ * no enabled monitors so the email can skip the section entirely.
+ * Daily buckets only partially overlap a sliding 24h window; that
+ * approximation is acceptable for a digest.
+ */
+ private async calculateUptimeSummary(
+ organizationId: string,
+ period: Period
+ ): Promise {
+ const monitors = await db
+ .selectFrom('monitors')
+ .select(['id', 'name'])
+ .where('organization_id', '=', organizationId)
+ .where('enabled', '=', true)
+ .execute();
+
+ if (monitors.length === 0) {
+ return null;
+ }
+
+ const bucketFrom = new Date(period.from);
+ bucketFrom.setUTCHours(0, 0, 0, 0);
+
+ const rows = await db
+ .selectFrom('monitor_uptime_daily')
+ .select((eb) => [
+ 'monitor_id',
+ eb.fn.sum('successful_checks').as('successful'),
+ eb.fn.sum('total_checks').as('total'),
+ ])
+ .where('organization_id', '=', organizationId)
+ .where('bucket', '>=', bucketFrom)
+ .groupBy('monitor_id')
+ .execute();
+
+ const statsByMonitor = new Map(rows.map((r) => [r.monitor_id, r]));
+
+ let overallSuccessful = 0;
+ let overallTotal = 0;
+ const perMonitor: Array<{ name: string; uptimePct: number }> = [];
- const previousPeriodResult = await reservoir.count({
- projectId: projectIds,
- from: previousPeriodStart,
- to: previousPeriodEnd,
- toExclusive: true,
+ for (const monitor of monitors) {
+ const stats = statsByMonitor.get(monitor.id);
+ const successful = Number(stats?.successful ?? 0);
+ const total = Number(stats?.total ?? 0);
+ overallSuccessful += successful;
+ overallTotal += total;
+
+ if (total > 0) {
+ perMonitor.push({
+ name: monitor.name,
+ uptimePct: Math.round((successful / total) * 10000) / 100,
});
- previousPeriodCount = previousPeriodResult.count;
}
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error);
- hub.captureLog('error', `[DigestGenerator] Log volume count failed for org ${organizationId}: ${message}`, { error });
- throw error;
}
- const trend = this.calculateTrend(currentPeriodCount, previousPeriodCount);
+ const overallUptimePct =
+ overallTotal > 0 ? Math.round((overallSuccessful / overallTotal) * 10000) / 100 : 100;
+
+ perMonitor.sort((a, b) => a.uptimePct - b.uptimePct);
return {
- currentPeriodCount,
- previousPeriodCount,
- trend,
+ monitorCount: monitors.length,
+ overallUptimePct,
+ worstMonitors: perMonitor.slice(0, 5),
};
}
-
- //current and previous counts
-
+ private async fetchRecipients(
+ organizationId: string,
+ digestConfigId: string
+ ): Promise {
+ const recipients = await db
+ .selectFrom('digest_recipients')
+ .select(['email', 'unsubscribe_token'])
+ .where('organization_id', '=', organizationId)
+ .where('digest_config_id', '=', digestConfigId)
+ .where('subscribed', '=', true)
+ .execute();
+
+ return recipients;
+ }
+
private calculateTrend(current: number, previous: number): string {
if (previous === 0 && current === 0) {
return 'no change';
@@ -204,12 +424,9 @@ export class DigestGeneratorService {
}
}
- //mail
private async sendDigestEmails(
recipients: DigestRecipient[],
- organizationName: string,
- frequency: 'daily' | 'weekly',
- stats: LogVolumeStats
+ report: DigestReportData
): Promise {
const transporter = getEmailTransporter();
@@ -217,16 +434,10 @@ export class DigestGeneratorService {
throw new Error('Email transporter not configured');
}
- const subject = `[LogTide Digest] ${frequency === 'daily' ? 'Daily' : 'Weekly'} Report - ${organizationName}`;
+ const subject = `[LogTide Digest] ${report.frequency === 'daily' ? 'Daily' : 'Weekly'} Report - ${report.organizationName}`;
-
const emailPromises = recipients.map(async (recipient) => {
- const text = this.generatePlaintextEmail(
- organizationName,
- frequency,
- stats,
- recipient.unsubscribe_token
- );
+ const text = this.generatePlaintextEmail(report, recipient.unsubscribe_token);
await transporter.sendMail({
from: `"LogTide" <${config.SMTP_FROM || config.SMTP_USER}>`,
@@ -249,25 +460,18 @@ export class DigestGeneratorService {
}
}
- //email template
- private generatePlaintextEmail(
- organizationName: string,
- frequency: 'daily' | 'weekly',
- stats: LogVolumeStats,
- unsubscribeToken: string
- ): string {
- const period = frequency === 'daily' ? 'last 24 hours' : 'last 7 days';
+ private generatePlaintextEmail(report: DigestReportData, unsubscribeToken: string): string {
const frontendUrl = this.getFrontendUrl();
const unsubscribeUrl = `${frontendUrl}/unsubscribe?token=${unsubscribeToken}`;
- let content = `LogTide ${frequency === 'daily' ? 'Daily' : 'Weekly'} Digest\n`;
- content += `Organization: ${organizationName}\n`;
- content += `Period: ${period}\n`;
+ let content = `LogTide ${report.frequency === 'daily' ? 'Daily' : 'Weekly'} Digest\n`;
+ content += `Organization: ${report.organizationName}\n`;
+ content += `Period: ${report.periodLabel}\n`;
content += `\n`;
content += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
content += `\n`;
- if (stats.currentPeriodCount === 0 && stats.previousPeriodCount === 0) {
+ if (report.logVolume.current === 0 && report.logVolume.previous === 0) {
content += ` Log Volume\n`;
content += `\n`;
content += `No activity during this period.\n`;
@@ -276,9 +480,9 @@ export class DigestGeneratorService {
content += ` Log Volume\n`;
content += `\n`;
// Fixed locale: digest content must not depend on the server's locale
- content += `Total logs: ${stats.currentPeriodCount.toLocaleString('en-US')}\n`;
- content += `Trend: ${stats.trend}\n`;
- content += `Previous period: ${stats.previousPeriodCount.toLocaleString('en-US')}\n`;
+ content += `Total logs: ${report.logVolume.current.toLocaleString('en-US')}\n`;
+ content += `Trend: ${report.logVolume.trend}\n`;
+ content += `Previous period: ${report.logVolume.previous.toLocaleString('en-US')}\n`;
}
content += `\n`;
@@ -289,13 +493,12 @@ export class DigestGeneratorService {
content += `To unsubscribe from these reports, click:\n`;
content += `${unsubscribeUrl}\n`;
content += `\n`;
- content += `—\n`;
+ content += `--\n`;
content += `LogTide - observability for your infrastructure\n`;
return content;
}
-
private getFrontendUrl(): string {
return config.FRONTEND_URL || 'http://localhost:3000';
}
diff --git a/packages/backend/src/tests/modules/digests/generator.test.ts b/packages/backend/src/tests/modules/digests/generator.test.ts
index f11a94ba..b0a7619a 100644
--- a/packages/backend/src/tests/modules/digests/generator.test.ts
+++ b/packages/backend/src/tests/modules/digests/generator.test.ts
@@ -5,6 +5,7 @@ import type { DigestJobPayload } from '../../../modules/digests/scheduler.js';
vi.mock('../../../database/reservoir.js', () => ({
reservoir: {
count: vi.fn(),
+ topValues: vi.fn(),
},
}));
@@ -19,6 +20,9 @@ vi.mock('../../../database/connection.js', () => ({
selectFrom: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
+ groupBy: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockReturnThis(),
+ limit: vi.fn().mockReturnThis(),
execute: vi.fn(),
executeTakeFirst: vi.fn(),
}
@@ -60,9 +64,11 @@ describe('DigestGeneratorService', () => {
const { db } = await import('../../../database/connection.js');
mockDb = db;
- mockDb.execute.mockReset();
- mockDb.executeTakeFirst.mockReset();
- mockReservoir.count.mockReset();
+ // Defaults keep the section queries harmless; tests override with *Once.
+ mockDb.execute.mockReset().mockResolvedValue([]);
+ mockDb.executeTakeFirst.mockReset().mockResolvedValue({ count: 0 });
+ mockReservoir.count.mockReset().mockResolvedValue({ count: 0 });
+ mockReservoir.topValues.mockReset().mockResolvedValue({ values: [] });
mockSendMail.mockReset().mockResolvedValue({ messageId: 'test-message-id' });
generator = new DigestGeneratorService();
@@ -73,6 +79,11 @@ describe('DigestGeneratorService', () => {
});
describe('generateAndSendDigest', () => {
+ /**
+ * Call order with the shared fluent mock:
+ * executeTakeFirst: org lookup, then per-section counts (default {count: 0})
+ * execute: recipients, projects, then per-section lists (default [])
+ */
it('should generate and send daily digest with log volume', async () => {
const payload: DigestJobPayload = {
organizationId: 'org_1',
@@ -80,7 +91,6 @@ describe('DigestGeneratorService', () => {
frequency: 'daily',
};
- // organization lookup
mockDb.executeTakeFirst.mockResolvedValueOnce({
name: 'Test Organization',
});
@@ -97,25 +107,16 @@ describe('DigestGeneratorService', () => {
},
]);
- // Mock projects query
+ // projects query
mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]);
- const { reservoir } = await import('../../../database/reservoir.js');
-
- (reservoir.count as any).mockResolvedValueOnce({
- count: 15000,
- });
-
- (reservoir.count as any).mockResolvedValueOnce({
- count: 12000,
- });
+ mockReservoir.count.mockResolvedValueOnce({ count: 15000 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 12000 });
await generator.generateAndSendDigest(payload);
- // Verify emails were sent
expect(mockSendMail).toHaveBeenCalledTimes(2);
- // Check first email
const firstCall = mockSendMail.mock.calls[0][0];
expect(firstCall.to).toBe('user1@test.com');
expect(firstCall.subject).toContain('Daily Report');
@@ -124,7 +125,6 @@ describe('DigestGeneratorService', () => {
expect(firstCall.text).toContain('+3000 (+25.0%)');
expect(firstCall.text).toContain('token_1');
- // Check second email
const secondCall = mockSendMail.mock.calls[1][0];
expect(secondCall.to).toBe('user2@test.com');
expect(secondCall.text).toContain('token_2');
@@ -148,18 +148,10 @@ describe('DigestGeneratorService', () => {
},
]);
- // Mock projects query
mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]);
- const { reservoir } = await import('../../../database/reservoir.js');
-
- (reservoir.count as any).mockResolvedValueOnce({
- count: 100000,
- });
-
- (reservoir.count as any).mockResolvedValueOnce({
- count: 95000,
- });
+ mockReservoir.count.mockResolvedValueOnce({ count: 100000 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 95000 });
await generator.generateAndSendDigest(payload);
@@ -188,13 +180,8 @@ describe('DigestGeneratorService', () => {
},
]);
- // Mock projects query
mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]);
- const { reservoir } = await import('../../../database/reservoir.js');
- (reservoir.count as any).mockResolvedValueOnce({ count: 0 });
- (reservoir.count as any).mockResolvedValueOnce({ count: 0 });
-
await generator.generateAndSendDigest(payload);
expect(mockSendMail).toHaveBeenCalledTimes(1);
@@ -221,12 +208,10 @@ describe('DigestGeneratorService', () => {
},
]);
- // Mock projects query
mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]);
- const { reservoir } = await import('../../../database/reservoir.js');
- (reservoir.count as any).mockResolvedValueOnce({ count: 8000 });
- (reservoir.count as any).mockResolvedValueOnce({ count: 10000 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 8000 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 10000 });
await generator.generateAndSendDigest(payload);
@@ -254,12 +239,10 @@ describe('DigestGeneratorService', () => {
},
]);
- // Mock projects query
mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]);
- const { reservoir } = await import('../../../database/reservoir.js');
- (reservoir.count as any).mockResolvedValueOnce({ count: 5000 });
- (reservoir.count as any).mockResolvedValueOnce({ count: 0 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 5000 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 0 });
await generator.generateAndSendDigest(payload);
@@ -285,7 +268,6 @@ describe('DigestGeneratorService', () => {
await generator.generateAndSendDigest(payload);
- // Should not send any emails
expect(mockSendMail).not.toHaveBeenCalled();
});
@@ -313,6 +295,9 @@ describe('DigestGeneratorService', () => {
expect(mockSendMail).toHaveBeenCalledTimes(1);
const emailCall = mockSendMail.mock.calls[0][0];
expect(emailCall.text).toContain('No activity during this period');
+ // No projects: the reservoir must never be queried
+ expect(mockReservoir.count).not.toHaveBeenCalled();
+ expect(mockReservoir.topValues).not.toHaveBeenCalled();
});
it('should throw error if organization not found', async () => {
@@ -322,7 +307,6 @@ describe('DigestGeneratorService', () => {
frequency: 'daily',
};
- // Organization not found
mockDb.executeTakeFirst.mockResolvedValueOnce(undefined);
await expect(generator.generateAndSendDigest(payload)).rejects.toThrow(
@@ -351,9 +335,8 @@ describe('DigestGeneratorService', () => {
]);
mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]);
- const { reservoir } = await import('../../../database/reservoir.js');
- (reservoir.count as any).mockResolvedValueOnce({ count: 1000 });
- (reservoir.count as any).mockResolvedValueOnce({ count: 900 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 1000 });
+ mockReservoir.count.mockResolvedValueOnce({ count: 900 });
await generator.generateAndSendDigest(payload);
@@ -364,4 +347,151 @@ describe('DigestGeneratorService', () => {
expect(emailCall.text).toContain('https://app.logtide.com/unsubscribe?token=secure_token_abc123');
});
});
+
+ describe('buildReportData', () => {
+ /**
+ * Call order inside buildReportData with the shared fluent mock:
+ * execute: projects, error_groups, top rules, monitors, uptime rows
+ * executeTakeFirst: detection total, open incidents
+ * reservoir: count x2, topValues x2 (previous period skipped when current is empty)
+ */
+ it('computes top error services with delta vs previous period', async () => {
+ mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]); // projects
+
+ mockReservoir.topValues.mockResolvedValueOnce({
+ values: [
+ { value: 'api', count: 50 },
+ { value: 'web', count: 10 },
+ ],
+ });
+ mockReservoir.topValues.mockResolvedValueOnce({
+ values: [{ value: 'api', count: 20 }],
+ });
+
+ const report = await generator.buildReportData('org_1', 'Test Org', 'daily');
+
+ expect(report.topErrorServices).toEqual([
+ { service: 'api', errorCount: 50, previousCount: 20, delta: 30 },
+ { service: 'web', errorCount: 10, previousCount: 0, delta: 10 },
+ ]);
+
+ // error+critical levels, top 5 for current period
+ const firstCall = mockReservoir.topValues.mock.calls[0][0];
+ expect(firstCall.field).toBe('service');
+ expect(firstCall.level).toEqual(['error', 'critical']);
+ expect(firstCall.limit).toBe(5);
+ });
+
+ it('skips the previous-period query when there are no current errors', async () => {
+ mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]);
+
+ const report = await generator.buildReportData('org_1', 'Test Org', 'daily');
+
+ expect(report.topErrorServices).toEqual([]);
+ expect(mockReservoir.topValues).toHaveBeenCalledTimes(1);
+ });
+
+ it('collects new error groups first seen in the period', async () => {
+ mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]); // projects
+ mockDb.execute.mockResolvedValueOnce([
+ {
+ exception_type: 'TypeError',
+ exception_message: 'Cannot read properties of undefined',
+ occurrence_count: 42,
+ language: 'nodejs',
+ },
+ {
+ exception_type: 'ValueError',
+ exception_message: null,
+ occurrence_count: 3,
+ language: 'python',
+ },
+ ]); // error_groups
+
+ const report = await generator.buildReportData('org_1', 'Test Org', 'daily');
+
+ expect(report.newErrorGroups).toEqual([
+ {
+ exceptionType: 'TypeError',
+ exceptionMessage: 'Cannot read properties of undefined',
+ occurrenceCount: 42,
+ language: 'nodejs',
+ },
+ {
+ exceptionType: 'ValueError',
+ exceptionMessage: '',
+ occurrenceCount: 3,
+ language: 'python',
+ },
+ ]);
+ });
+
+ it('computes the security summary', async () => {
+ mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]); // projects
+ mockDb.execute.mockResolvedValueOnce([]); // error_groups
+ mockDb.execute.mockResolvedValueOnce([
+ { rule_title: 'Suspicious Login', severity: 'high', count: 7 },
+ { rule_title: 'Port Scan', severity: 'medium', count: 4 },
+ ]); // top rules
+
+ mockDb.executeTakeFirst.mockResolvedValueOnce({ count: 11 }); // detections total
+ mockDb.executeTakeFirst.mockResolvedValueOnce({ count: 2 }); // open incidents
+
+ const report = await generator.buildReportData('org_1', 'Test Org', 'weekly');
+
+ expect(report.security).toEqual({
+ totalDetections: 11,
+ topRules: [
+ { ruleTitle: 'Suspicious Login', severity: 'high', count: 7 },
+ { ruleTitle: 'Port Scan', severity: 'medium', count: 4 },
+ ],
+ openIncidents: 2,
+ });
+ });
+
+ it('computes uptime summary from monitor daily buckets', async () => {
+ mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]); // projects
+ mockDb.execute.mockResolvedValueOnce([]); // error_groups
+ mockDb.execute.mockResolvedValueOnce([]); // top rules
+ mockDb.execute.mockResolvedValueOnce([
+ { id: 'mon_1', name: 'API Health' },
+ { id: 'mon_2', name: 'Landing Page' },
+ ]); // monitors
+ mockDb.execute.mockResolvedValueOnce([
+ { monitor_id: 'mon_1', successful: 95, total: 100 },
+ { monitor_id: 'mon_2', successful: 100, total: 100 },
+ ]); // uptime rows
+
+ const report = await generator.buildReportData('org_1', 'Test Org', 'daily');
+
+ expect(report.uptime).toEqual({
+ monitorCount: 2,
+ overallUptimePct: 97.5,
+ worstMonitors: [
+ { name: 'API Health', uptimePct: 95 },
+ { name: 'Landing Page', uptimePct: 100 },
+ ],
+ });
+ });
+
+ it('returns null uptime when the org has no enabled monitors', async () => {
+ mockDb.execute.mockResolvedValueOnce([{ id: 'project_1' }]); // projects
+ // error_groups, top rules, monitors all fall through to the [] default
+
+ const report = await generator.buildReportData('org_1', 'Test Org', 'daily');
+
+ expect(report.uptime).toBeNull();
+ });
+
+ it('labels the period by frequency', async () => {
+ mockDb.execute.mockResolvedValueOnce([]); // projects (none)
+
+ const daily = await generator.buildReportData('org_1', 'Test Org', 'daily');
+ expect(daily.periodLabel).toBe('last 24 hours');
+
+ mockDb.execute.mockResolvedValueOnce([]);
+ const weekly = await generator.buildReportData('org_1', 'Test Org', 'weekly');
+ expect(weekly.periodLabel).toBe('last 7 days');
+ });
+ });
});
From d245ba3180707c5748eb647959ddc72993748ece Mon Sep 17 00:00:00 2001
From: Polliog
Date: Mon, 13 Jul 2026 18:11:59 +0200
Subject: [PATCH 3/6] render digest emails with shared html template
---
packages/backend/src/lib/email-templates.ts | 260 +++++++++++++++++-
.../backend/src/modules/digests/generator.ts | 48 +---
.../src/tests/lib/email-templates.test.ts | 119 ++++++++
.../digests/generator-integration.test.ts | 173 +++++++++++-
4 files changed, 557 insertions(+), 43 deletions(-)
diff --git a/packages/backend/src/lib/email-templates.ts b/packages/backend/src/lib/email-templates.ts
index fce0953b..dcba239a 100644
--- a/packages/backend/src/lib/email-templates.ts
+++ b/packages/backend/src/lib/email-templates.ts
@@ -97,10 +97,13 @@ const colors = {
interface BaseEmailOptions {
preheader?: string; // Preview text shown in email clients
+ // When set, the footer swaps the channel-settings link for a one-click
+ // unsubscribe link (used by digest emails, where recipients may have no account)
+ unsubscribeUrl?: string;
}
function baseTemplate(content: string, options: BaseEmailOptions = {}): string {
- const { preheader } = options;
+ const { preheader, unsubscribeUrl } = options;
const frontendUrl = getFrontendUrl();
const logoUrl = getLogoUrl();
@@ -143,7 +146,9 @@ function baseTemplate(content: string, options: BaseEmailOptions = {}): string {
Sent by LogTide
- Manage notification settings
+ ${unsubscribeUrl
+ ? `Unsubscribe from these reports`
+ : `Manage notification settings`}
@@ -833,3 +838,254 @@ Manage notifications: ${frontendUrl}/dashboard/settings/channels
return { html, text };
}
+
+// ============================================================================
+// DIGEST REPORT EMAIL (#154)
+// ============================================================================
+
+export interface DigestEmailData {
+ organizationName: string;
+ frequency: 'daily' | 'weekly';
+ periodLabel: string;
+ logVolume: {
+ current: number;
+ previous: number;
+ trend: string;
+ };
+ topErrorServices: Array<{
+ service: string;
+ errorCount: number;
+ previousCount: number;
+ delta: number;
+ }>;
+ newErrorGroups: Array<{
+ exceptionType: string;
+ exceptionMessage: string;
+ occurrenceCount: number;
+ language: string;
+ }>;
+ security: {
+ totalDetections: number;
+ topRules: Array<{ ruleTitle: string; severity: string; count: number }>;
+ openIncidents: number;
+ };
+ uptime: {
+ monitorCount: number;
+ overallUptimePct: number;
+ worstMonitors: Array<{ name: string; uptimePct: number }>;
+ } | null;
+ unsubscribeUrl: string;
+ dashboardUrl: string;
+}
+
+function digestSectionTitle(title: string): string {
+ return `
+
+
+ ${escapeHtml(title)}
+
+ |
+
`;
+}
+
+function digestEmptyLine(message: string): string {
+ return `
+ |
+ ${escapeHtml(message)}
+ |
+
`;
+}
+
+function digestTable(headers: string[], rows: string[][]): string {
+ const headerCells = headers
+ .map(
+ (h, i) =>
+ `${escapeHtml(h)} | `
+ )
+ .join('');
+ const bodyRows = rows
+ .map(
+ (cells) =>
+ `${cells
+ .map(
+ (c, i) =>
+ `| ${escapeHtml(c)} | `
+ )
+ .join('')}
`
+ )
+ .join('');
+
+ return `
+
+
+ ${headerCells}
+ ${bodyRows}
+
+ |
+
`;
+}
+
+function formatDelta(delta: number): string {
+ if (delta > 0) return `+${delta.toLocaleString('en-US')}`;
+ return delta.toLocaleString('en-US');
+}
+
+export function generateDigestEmail(data: DigestEmailData): { html: string; text: string } {
+ const frequencyLabel = data.frequency === 'daily' ? 'Daily' : 'Weekly';
+ const title = `${frequencyLabel} Digest`;
+ const quiet = data.logVolume.current === 0 && data.logVolume.previous === 0;
+
+ const logVolumeSection = quiet
+ ? alertBox('No activity during this period. Your systems have been quiet.', 'info')
+ : infoBox([
+ { label: 'Total Logs', value: data.logVolume.current.toLocaleString('en-US') },
+ { label: 'Trend', value: data.logVolume.trend },
+ { label: 'Previous Period', value: data.logVolume.previous.toLocaleString('en-US') },
+ ]);
+
+ const topServicesSection =
+ data.topErrorServices.length > 0
+ ? digestTable(
+ ['Service', 'Errors', 'Previous', 'Delta'],
+ data.topErrorServices.map((s) => [
+ s.service,
+ s.errorCount.toLocaleString('en-US'),
+ s.previousCount.toLocaleString('en-US'),
+ formatDelta(s.delta),
+ ])
+ )
+ : digestEmptyLine('No errors recorded in this period.');
+
+ const errorGroupsSection =
+ data.newErrorGroups.length > 0
+ ? digestTable(
+ ['Error', 'Language', 'Occurrences'],
+ data.newErrorGroups.map((g) => [
+ `${g.exceptionType}${g.exceptionMessage ? ': ' + truncate(g.exceptionMessage, 60) : ''}`,
+ g.language,
+ g.occurrenceCount.toLocaleString('en-US'),
+ ])
+ )
+ : digestEmptyLine('No new error groups appeared in this period.');
+
+ const securityRows = [
+ { label: 'Detections', value: data.security.totalDetections.toLocaleString('en-US') },
+ { label: 'Open Incidents', value: data.security.openIncidents.toLocaleString('en-US') },
+ ];
+ const securitySection =
+ data.security.topRules.length > 0
+ ? infoBox(securityRows) +
+ digestTable(
+ ['Rule', 'Severity', 'Detections'],
+ data.security.topRules.map((r) => [r.ruleTitle, r.severity, r.count.toLocaleString('en-US')])
+ )
+ : infoBox(securityRows);
+
+ const uptimeSection = data.uptime
+ ? digestSectionTitle('Uptime') +
+ infoBox([
+ { label: 'Overall', value: `${data.uptime.overallUptimePct.toLocaleString('en-US')}%` },
+ { label: 'Monitors', value: data.uptime.monitorCount.toLocaleString('en-US') },
+ ]) +
+ (data.uptime.worstMonitors.length > 0
+ ? digestTable(
+ ['Monitor', 'Uptime'],
+ data.uptime.worstMonitors.map((m) => [m.name, `${m.uptimePct.toLocaleString('en-US')}%`])
+ )
+ : '')
+ : '';
+
+ const html = baseTemplate(
+ card(`
+ ${header(title, { text: data.frequency, color: colors.info })}
+ ${divider()}
+ ${subtitle(`${data.organizationName} - ${data.periodLabel}`)}
+ ${timestamp()}
+ ${digestSectionTitle('Log Volume')}
+ ${logVolumeSection}
+ ${digestSectionTitle('Top Services by Errors')}
+ ${topServicesSection}
+ ${digestSectionTitle('New Error Groups')}
+ ${errorGroupsSection}
+ ${digestSectionTitle('Security')}
+ ${securitySection}
+ ${uptimeSection}
+ ${cta('View Dashboard', data.dashboardUrl)}
+ `),
+ {
+ preheader: quiet
+ ? `Quiet period for ${data.organizationName}`
+ : `${data.logVolume.current.toLocaleString('en-US')} logs, ${data.security.totalDetections.toLocaleString('en-US')} detections for ${data.organizationName}`,
+ unsubscribeUrl: data.unsubscribeUrl,
+ }
+ );
+
+ const lines: string[] = [];
+ lines.push(`LogTide ${frequencyLabel} Digest`);
+ lines.push(`Organization: ${data.organizationName}`);
+ lines.push(`Period: ${data.periodLabel}`);
+ lines.push('');
+ lines.push('='.repeat(50));
+ lines.push('');
+ lines.push('LOG VOLUME');
+ lines.push('-'.repeat(10));
+ if (quiet) {
+ lines.push('No activity during this period.');
+ lines.push('Your systems have been quiet.');
+ } else {
+ lines.push(`Total logs: ${data.logVolume.current.toLocaleString('en-US')}`);
+ lines.push(`Trend: ${data.logVolume.trend}`);
+ lines.push(`Previous period: ${data.logVolume.previous.toLocaleString('en-US')}`);
+ }
+ lines.push('');
+ lines.push('TOP SERVICES BY ERRORS');
+ lines.push('-'.repeat(22));
+ if (data.topErrorServices.length > 0) {
+ for (const s of data.topErrorServices) {
+ lines.push(
+ `${s.service}: ${s.errorCount.toLocaleString('en-US')} errors (previous: ${s.previousCount.toLocaleString('en-US')}, delta: ${formatDelta(s.delta)})`
+ );
+ }
+ } else {
+ lines.push('No errors recorded in this period.');
+ }
+ lines.push('');
+ lines.push('NEW ERROR GROUPS');
+ lines.push('-'.repeat(16));
+ if (data.newErrorGroups.length > 0) {
+ for (const g of data.newErrorGroups) {
+ const message = g.exceptionMessage ? `: ${truncate(g.exceptionMessage, 80)}` : '';
+ lines.push(`${g.exceptionType}${message} (${g.language}, ${g.occurrenceCount.toLocaleString('en-US')} occurrences)`);
+ }
+ } else {
+ lines.push('No new error groups appeared in this period.');
+ }
+ lines.push('');
+ lines.push('SECURITY');
+ lines.push('-'.repeat(8));
+ lines.push(`Detections: ${data.security.totalDetections.toLocaleString('en-US')}`);
+ lines.push(`Open incidents: ${data.security.openIncidents.toLocaleString('en-US')}`);
+ for (const r of data.security.topRules) {
+ lines.push(`${r.ruleTitle} [${r.severity}]: ${r.count.toLocaleString('en-US')} detections`);
+ }
+ if (data.uptime) {
+ lines.push('');
+ lines.push('UPTIME');
+ lines.push('-'.repeat(6));
+ lines.push(`Overall: ${data.uptime.overallUptimePct.toLocaleString('en-US')}% across ${data.uptime.monitorCount.toLocaleString('en-US')} monitor(s)`);
+ for (const m of data.uptime.worstMonitors) {
+ lines.push(`${m.name}: ${m.uptimePct.toLocaleString('en-US')}%`);
+ }
+ }
+ lines.push('');
+ lines.push(`View your dashboard: ${data.dashboardUrl}`);
+ lines.push('');
+ lines.push('To unsubscribe from these reports, click:');
+ lines.push(data.unsubscribeUrl);
+ lines.push('');
+ lines.push('--');
+ lines.push('Sent by LogTide');
+
+ return { html, text: lines.join('\n') };
+}
+
diff --git a/packages/backend/src/modules/digests/generator.ts b/packages/backend/src/modules/digests/generator.ts
index 6505ac46..67b35447 100644
--- a/packages/backend/src/modules/digests/generator.ts
+++ b/packages/backend/src/modules/digests/generator.ts
@@ -11,6 +11,7 @@ import { db } from '../../database/connection.js';
import { reservoir } from '../../database/reservoir.js';
import { hub } from '@logtide/core';
import { config } from '../../config/index.js';
+import { generateDigestEmail } from '../../lib/email-templates.js';
import type { DigestJobPayload } from './scheduler.js';
export interface DigestReportData {
@@ -435,15 +436,21 @@ export class DigestGeneratorService {
}
const subject = `[LogTide Digest] ${report.frequency === 'daily' ? 'Daily' : 'Weekly'} Report - ${report.organizationName}`;
+ const frontendUrl = this.getFrontendUrl();
const emailPromises = recipients.map(async (recipient) => {
- const text = this.generatePlaintextEmail(report, recipient.unsubscribe_token);
+ const { html, text } = generateDigestEmail({
+ ...report,
+ unsubscribeUrl: `${frontendUrl}/unsubscribe?token=${recipient.unsubscribe_token}`,
+ dashboardUrl: `${frontendUrl}/dashboard`,
+ });
await transporter.sendMail({
from: `"LogTide" <${config.SMTP_FROM || config.SMTP_USER}>`,
to: recipient.email,
subject,
text,
+ html,
});
hub.captureLog('info', `[DigestGenerator] Email sent to ${recipient.email}`);
@@ -460,45 +467,6 @@ export class DigestGeneratorService {
}
}
- private generatePlaintextEmail(report: DigestReportData, unsubscribeToken: string): string {
- const frontendUrl = this.getFrontendUrl();
- const unsubscribeUrl = `${frontendUrl}/unsubscribe?token=${unsubscribeToken}`;
-
- let content = `LogTide ${report.frequency === 'daily' ? 'Daily' : 'Weekly'} Digest\n`;
- content += `Organization: ${report.organizationName}\n`;
- content += `Period: ${report.periodLabel}\n`;
- content += `\n`;
- content += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
- content += `\n`;
-
- if (report.logVolume.current === 0 && report.logVolume.previous === 0) {
- content += ` Log Volume\n`;
- content += `\n`;
- content += `No activity during this period.\n`;
- content += `Your systems have been quiet.\n`;
- } else {
- content += ` Log Volume\n`;
- content += `\n`;
- // Fixed locale: digest content must not depend on the server's locale
- content += `Total logs: ${report.logVolume.current.toLocaleString('en-US')}\n`;
- content += `Trend: ${report.logVolume.trend}\n`;
- content += `Previous period: ${report.logVolume.previous.toLocaleString('en-US')}\n`;
- }
-
- content += `\n`;
- content += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
- content += `\n`;
- content += `View your dashboard: ${frontendUrl}\n`;
- content += `\n`;
- content += `To unsubscribe from these reports, click:\n`;
- content += `${unsubscribeUrl}\n`;
- content += `\n`;
- content += `--\n`;
- content += `LogTide - observability for your infrastructure\n`;
-
- return content;
- }
-
private getFrontendUrl(): string {
return config.FRONTEND_URL || 'http://localhost:3000';
}
diff --git a/packages/backend/src/tests/lib/email-templates.test.ts b/packages/backend/src/tests/lib/email-templates.test.ts
index 0cb9692e..ff9b25df 100644
--- a/packages/backend/src/tests/lib/email-templates.test.ts
+++ b/packages/backend/src/tests/lib/email-templates.test.ts
@@ -18,6 +18,8 @@ import {
generateIncidentEmail,
generateSigmaDetectionEmail,
generateInvitationEmail,
+ generateDigestEmail,
+ type DigestEmailData,
} from '../../lib/email-templates.js';
describe('Email Templates - Helpers', () => {
@@ -752,3 +754,120 @@ describe('Email Templates - Common Elements', () => {
expect(result.html).toContain('