import { Injectable } from '@nestjs/common';
import { TELEMETRY_REMOTE_CATEGORY_KEYS, type TelemetryRemoteCategory, type TelemetrySettings } from './telemetry-settings.service';
export type RemoteTelemetryCategory = TelemetryRemoteCategory;
export interface RemoteTelemetryQueueItem {
id: string;
category: RemoteTelemetryCategory;
}
export interface TelemetryDispatchDecision {
allowed: boolean;
action: 'send' | 'retain_local' | 'discard_existing' | 'manual_only';
reason: 'category_enabled' | 'category_disabled' | 'pilot_mode_required' | 'manual_upload_required';
}
export interface TelemetryQueuePolicyResult {
sendable: RemoteTelemetryQueueItem[];
retainedLocal: RemoteTelemetryQueueItem[];
discarded: RemoteTelemetryQueueItem[];
}
export interface TelemetrySupportBundleEntry {
id: string;
category: TelemetrySupportBundleCategory;
bytes: number;
containsPersonalData: boolean;
}
export type TelemetrySupportBundleCategory = 'technical_diagnostics' | 'crash_report' | 'performance' | 'audit';
const TELEMETRY_SETTINGS_KEYS = [...TELEMETRY_REMOTE_CATEGORY_KEYS, 'pilotMode'] as const;
export interface TelemetrySupportBundlePreview {
allowed: boolean;
requiresManualConfirmation: true;
includedEntryIds: string[];
excludedEntryIds: string[];
totalBytes: number;
redactionRequired: boolean;
reason: 'manual_confirmation_required' | 'category_disabled' | 'empty_after_redaction' | 'ready';
}
/**
* Central runtime boundary between local operational logs and remote telemetry.
* It is deliberately pure: callers must apply the returned queue decision and
* may not fall back to another remote transport when a category is suppressed.
*/
@Injectable()
export class TelemetryDispatchPolicyService {
decide(category: RemoteTelemetryCategory, settings: TelemetrySettings): TelemetryDispatchDecision {
this.assertValidSettings(settings);
if (!this.isRemoteCategory(category)) throw new Error('telemetry.category_invalid');
if (category === 'manualSupportBundleUploads') {
return settings.manualSupportBundleUploads === true
? {allowed: false, action: 'manual_only', reason: 'manual_upload_required'}
: {allowed: false, action: 'retain_local', reason: 'category_disabled'};
}
if (category === 'extendedPilotDiagnostics' && settings.pilotMode !== true) {
return {allowed: false, action: 'retain_local', reason: 'pilot_mode_required'};
}
if (settings[category] === true) return {allowed: true, action: 'send', reason: 'category_enabled'};
return {allowed: false, action: 'discard_existing', reason: 'category_disabled'};
}
applyQueuePolicy(queue: RemoteTelemetryQueueItem[], settings: TelemetrySettings): TelemetryQueuePolicyResult {
this.assertValidSettings(settings);
if (!Array.isArray(queue) || queue.some((item) => !this.isValidQueueItem(item))) {
throw new Error('telemetry.dispatch_queue_invalid');
}
const result: TelemetryQueuePolicyResult = {sendable: [], retainedLocal: [], discarded: []};
for (const item of queue) {
const decision = this.decide(item.category, settings);
if (decision.action === 'send') result.sendable.push(item);
else if (decision.action === 'discard_existing') result.discarded.push(item);
else result.retainedLocal.push(item);
}
return result;
}
private assertValidSettings(settings: TelemetrySettings): void {
if (!settings || typeof settings !== 'object' || TELEMETRY_SETTINGS_KEYS.some((key) => typeof (settings as unknown as Record<string, unknown>)[key] !== 'boolean')) {
throw new Error('telemetry.settings_invalid');
}
}
private isValidQueueItem(value: unknown): value is RemoteTelemetryQueueItem {
if (!value || typeof value !== 'object') return false;
const item = value as Record<string, unknown>;
return typeof item.id === 'string' && item.id.trim().length > 0 && this.isRemoteCategory(item.category);
}
private isRemoteCategory(category: unknown): category is RemoteTelemetryCategory {
return typeof category === 'string' &&
(TELEMETRY_REMOTE_CATEGORY_KEYS as readonly string[]).includes(category);
}
previewManualSupportBundle(settings: TelemetrySettings, entries: TelemetrySupportBundleEntry[], confirmed = false): TelemetrySupportBundlePreview {
this.assertValidSettings(settings);
if (!Array.isArray(entries) || entries.some((entry) => !this.isValidSupportBundleEntry(entry))) throw new Error('telemetry.support_bundle_invalid');
if (settings.manualSupportBundleUploads !== true) return {allowed: false, requiresManualConfirmation: true, includedEntryIds: [], excludedEntryIds: entries.map((entry) => entry.id), totalBytes: 0, redactionRequired: false, reason: 'category_disabled'};
const safeEntries = entries.filter((entry) => !entry.containsPersonalData);
const totalBytes = safeEntries.reduce((sum, entry) => sum + entry.bytes, 0);
if (totalBytes > 50_000_000 || safeEntries.some((entry) => entry.bytes > 10_000_000)) throw new Error('telemetry.support_bundle_size_exceeded');
const preview = {allowed: confirmed && safeEntries.length > 0, requiresManualConfirmation: true as const, includedEntryIds: safeEntries.map((entry) => entry.id), excludedEntryIds: entries.filter((entry) => entry.containsPersonalData).map((entry) => entry.id), totalBytes, redactionRequired: safeEntries.length !== entries.length, reason: (safeEntries.length === 0 ? 'empty_after_redaction' : confirmed ? 'ready' : 'manual_confirmation_required') as TelemetrySupportBundlePreview['reason']};
return preview;
}
private isValidSupportBundleEntry(value: unknown): value is TelemetrySupportBundleEntry {
if (!value || typeof value !== 'object') return false;
const entry = value as Record<string, unknown>;
return typeof entry.id === 'string' && entry.id.trim().length > 0 &&
typeof entry.category === 'string' && ['technical_diagnostics', 'crash_report', 'performance', 'audit'].includes(entry.category) &&
Number.isSafeInteger(entry.bytes) && (entry.bytes as number) >= 0 && typeof entry.containsPersonalData === 'boolean';
}
}
