Files
webmail/lib/telemetry/payload.ts
T

194 lines
6.6 KiB
TypeScript

import { readFileSync } from 'node:fs';
import path from 'node:path';
import { configManager } from '@/lib/admin/config-manager';
import { effectiveErrorsConsent, getInstanceId } from './state';
import { getLoginCounts } from './login-tracker';
import { isErrorCode } from '@/lib/error-reporting/envelope';
import { readErrors, utcDay, type Aggregate } from '@/lib/error-reporting/aggregate';
import {
MAX_TELEMETRY_ERRORS,
MAX_TELEMETRY_FRAMES,
type AnyTelemetryPayload,
type TelemetryError,
type TelemetryPayload,
type TelemetryFeatures,
type Platform,
type OsFamily,
type CountBucket,
} from './types';
let processStartedAt = Date.now();
export function markProcessStart(): void {
processStartedAt = Date.now();
}
function readPackage(): { version: string; build: string | null } {
try {
const pkg = JSON.parse(
readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
) as { version?: string };
return { version: pkg.version ?? '0.0.0', build: process.env.BULWARK_BUILD ?? 'release' };
} catch {
return { version: '0.0.0', build: null };
}
}
function detectPlatform(): Platform {
if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s';
// /.dockerenv is the standard Docker container marker.
try {
readFileSync('/.dockerenv');
return 'docker';
} catch { /* not in docker */ }
return 'bare';
}
function detectOs(): OsFamily {
switch (process.platform) {
case 'linux': return 'linux';
case 'darwin': return 'darwin';
case 'win32': return 'windows';
default: return 'unknown';
}
}
export function bucketCount(n: number): CountBucket {
if (n <= 0) return '0';
if (n === 1) return '1';
if (n <= 5) return '2-5';
if (n <= 10) return '6-10';
if (n <= 50) return '11-50';
if (n <= 200) return '51-200';
return '201+';
}
async function readFeatures(): Promise<TelemetryFeatures> {
await configManager.ensureLoaded();
const gates = configManager.getPolicy().features;
const cfg = configManager.getAll();
return {
// Booleans only. We read whether a feature is enabled - never any
// config value beyond a presence check.
calendar: gates.calendarEnabled === true,
contacts: gates.contactsEnabled === true,
files: gates.filesEnabled === true,
extensions: gates.pluginsEnabled === true,
oauth_enabled: cfg['oauthEnabled'] === true,
smime_enabled: gates.smimeEnabled === true,
};
}
// Only what the admin sets in STALWART_VERSION. Stalwart publishes no version
// a client could read: it sends no Server header (0.16.0 through 1.0), and
// the version it names elsewhere is a fixed "1.0.0" even on 0.16. Reading the
// Server header of /.well-known/jmap reported null, or whatever version a
// reverse proxy in front of Stalwart put there.
function stalwartVersion(): string | null {
return process.env.STALWART_VERSION?.trim() || null;
}
// Account counts come from the local login tracker, which records a per-
// instance HMAC of every successful login plus the timestamp. Total = unique
// identities seen in the last 90 days; active7d = identities with a login in
// the last 7 days.
async function countExtensions(): Promise<{ extensions: number; themes: number }> {
try {
const { getPluginRegistry, getThemeRegistry } = await import('@/lib/admin/plugin-registry');
const [plugins, themes] = await Promise.all([getPluginRegistry(), getThemeRegistry()]);
return {
extensions: plugins.plugins.length,
themes: themes.themes.length,
};
} catch {
return { extensions: 0, themes: 0 };
}
}
const BUNDLE_PATH = /^\/(?:_next\/static|\.next\/server)\/[\w./()[\]@+~-]{1,200}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/**
* The error counts of the last complete UTC day (the day before `now`), for
* schema v2: at most MAX_TELEMETRY_ERRORS, the most frequent first. Only
* frames in Bulwark's own bundles go (no extension, plugin or Node paths),
* and a message only when it is one of Bulwark's fixed error codes.
*/
export function errorsForPayload(agg: Aggregate, now = Date.now()): TelemetryError[] {
return errorsForDay(agg, utcDay(now - 86_400_000));
}
/**
* The error counts of one UTC day, shaped as in errorsForPayload. `days`
* counts the days of the week ending on `day` the error occurred on.
* Instant mode sends the current day's this way.
*/
export function errorsForDay(agg: Aggregate, day: string): TelemetryError[] {
const end = Date.parse(`${day}T00:00:00Z`);
const recentDays = Array.from({ length: 7 }, (_, i) => utcDay(end - i * 86_400_000));
return Object.entries(agg[day] ?? {})
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, MAX_TELEMETRY_ERRORS)
.map(([fingerprint, entry]) => ({
name: entry.name,
fingerprint,
frames: entry.frames
.filter((frame) => BUNDLE_PATH.test(frame.path))
.slice(0, MAX_TELEMETRY_FRAMES)
.map((frame) => ({
path: frame.path,
line: frame.line ?? null,
col: frame.column ?? null,
debug_id: frame.debugId && UUID.test(frame.debugId) ? frame.debugId : null,
})),
version: entry.version,
count: bucketCount(entry.count),
days: recentDays.filter((d) => agg[d]?.[fingerprint]).length,
...(isErrorCode(entry.code) ? { message: entry.code } : {}),
}));
}
/**
* The heartbeat. Schema 1, exactly as before, unless the admin also agreed
* to send error counts; then schema 2 with `errors` added.
*/
export async function buildPayload(): Promise<AnyTelemetryPayload> {
const base = await buildPayloadV1();
// Daily and instant mode both send the last complete day with the heartbeat.
if ((await effectiveErrorsConsent()).consent === 'off') return base;
return { ...base, schema: '2', errors: errorsForPayload(await readErrors()) };
}
async function buildPayloadV1(): Promise<TelemetryPayload> {
const instance_id = await getInstanceId();
const { version, build } = readPackage();
const features = await readFeatures();
const accounts = await getLoginCounts();
const exts = await countExtensions();
const stalwart_version = stalwartVersion();
const uptime_days = Math.min(
365,
Math.floor((Date.now() - processStartedAt) / 86_400_000),
);
return {
schema: '1',
instance_id,
ts: new Date().toISOString(),
version,
build,
platform: detectPlatform(),
node_version: process.versions.node,
os_family: detectOs(),
stalwart_version,
features,
counts: {
accounts: bucketCount(accounts.total),
accounts_active_7d: bucketCount(accounts.active7d),
extensions_installed: exts.extensions,
themes_installed: exts.themes,
},
uptime_days,
};
}