diff --git a/dashboard/backend/.env.example b/dashboard/backend/.env.example index 8179f9a..c05fa49 100644 --- a/dashboard/backend/.env.example +++ b/dashboard/backend/.env.example @@ -10,3 +10,8 @@ AZURE_TENANT_ID=your-azure-tenant-id AZURE_CLIENT_ID=your-azure-client-id AZURE_CLIENT_SECRET=your-azure-client-secret AZURE_KEY_VAULT_URL=https://your-key-vault.vault.azure.net/ + +# Structured logging (pino → stdout). Override per environment as needed. +# Levels: fatal | error | warn | info | debug | trace | silent +# Default: debug in non-prod, info in prod (when NODE_ENV=production). +LOG_LEVEL=info diff --git a/dashboard/backend/src/lib/logger.ts b/dashboard/backend/src/lib/logger.ts new file mode 100644 index 0000000..168a9ef --- /dev/null +++ b/dashboard/backend/src/lib/logger.ts @@ -0,0 +1,74 @@ +// Centralized pino logger. +// +// Fastify already uses pino under the hood, but we want one configured pino +// instance shared between Fastify (via `logger: ` in `Fastify({...})`) +// and any non-request code path (background tasks, repositories called outside +// a request, scripts). Importing the same instance everywhere means uniform +// formatting, redaction, and log level — and gives us one place to change +// transport later. +// +// Env knobs: +// LOG_LEVEL — pino level (`fatal|error|warn|info|debug|trace|silent`). +// Default: `debug` in non-production, `info` in production. +// NODE_ENV — `production` flips the default level. +// +// Redaction: +// We strip Authorization headers and a small allow-list of secret-shaped +// field names (`password`, `token`, `secret`, common Azure/JWT keys) from +// any logged object so that an accidental `req.log.info(req.body)` or +// `logger.error({ err, config }, ...)` doesn't leak credentials. + +import pino from 'pino'; + +const isProd = process.env.NODE_ENV === 'production'; +const level = process.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'); + +// Field paths we never want in logs. Pino's redact uses fast-redact's +// dot-path syntax with `*` wildcards. Cover the common cases without trying +// to be exhaustive — this is a backstop, not the primary defense. +const redactPaths = [ + // Headers (Fastify request log shape) + 'req.headers.authorization', + 'req.headers.cookie', + 'request.headers.authorization', + 'request.headers.cookie', + 'headers.authorization', + 'headers.cookie', + // Common secret-shaped keys at the top level of a logged object + '*.password', + '*.token', + '*.refreshToken', + '*.refresh_token', + '*.accessToken', + '*.access_token', + '*.csrfToken', + '*.csrf_token', + '*.JWT_SECRET', + '*.CSRF_SECRET', + '*.ENCRYPTION_KEY', + '*.COSMOS_KEY', + '*.AZURE_CLIENT_SECRET', +]; + +export const logger = pino({ + level, + redact: { + paths: redactPaths, + censor: '[REDACTED]', + }, + // Stable, JSON to stdout in every environment. If you want pretty output + // locally, pipe through `pino-pretty` from your shell — we deliberately + // don't bundle it as a runtime dep. + base: { service: 'devops-backend' }, + timestamp: pino.stdTimeFunctions.isoTime, +}); + +// Convenience: a child logger tagged with a module name. Use this in +// repositories / background workers so log lines carry their origin +// without having to repeat it in every call site. +// +// const log = childLogger('deployments/orchestrator'); +// log.error({ err, deploymentId }, 'background work failed'); +export function childLogger(module: string) { + return logger.child({ module }); +} diff --git a/dashboard/backend/src/modules/backup/repository.ts b/dashboard/backend/src/modules/backup/repository.ts index b494318..c27223f 100644 --- a/dashboard/backend/src/modules/backup/repository.ts +++ b/dashboard/backend/src/modules/backup/repository.ts @@ -1,7 +1,10 @@ import { getContainer } from '../../lib/cosmos-init.js'; import { productId } from '../../lib/config.js'; +import { childLogger } from '../../lib/logger.js'; import type { Backup, BackupParams } from './types.js'; +const log = childLogger('backup/repository'); + const BACKUPS_CONTAINER = 'backups'; export async function createBackup(params: BackupParams = {}): Promise { @@ -21,7 +24,7 @@ export async function createBackup(params: BackupParams = {}): Promise { backupData[containerName] = resources; totalItems += resources.length; } catch (error) { - console.error(`Failed to backup container ${containerName}:`, error); + log.error({ err: error, containerName }, "failed to backup container"); throw error; } } @@ -54,7 +57,7 @@ export async function getBackups(): Promise { return resources as Backup[]; } catch (error) { - console.error('Failed to get backups:', error); + log.error({ err: error }, "failed to get backups"); return []; } } @@ -89,7 +92,7 @@ export async function restoreBackup(backupId: string): Promise { try { await targetContainer.items.upsert(item); } catch (error) { - console.error(`Failed to restore item in ${containerName}:`, error); + log.error({ err: error, containerName }, "failed to restore backup item"); } } } @@ -99,7 +102,7 @@ export async function deleteBackup(backupId: string): Promise { try { await getContainer(BACKUPS_CONTAINER).item(backupId).delete(); } catch (error) { - console.error('Failed to delete backup:', error); + log.error({ err: error }, "failed to delete backup"); throw error; } } diff --git a/dashboard/backend/src/modules/deployments/orchestrator.ts b/dashboard/backend/src/modules/deployments/orchestrator.ts index 18b9cf1..cd2c0d2 100644 --- a/dashboard/backend/src/modules/deployments/orchestrator.ts +++ b/dashboard/backend/src/modules/deployments/orchestrator.ts @@ -4,8 +4,10 @@ import { join } from 'path'; import type { Service } from '../services/types.js'; import { createDeployment, updateDeployment } from './repository.js'; import { productId } from '../../lib/config.js'; +import { childLogger } from '../../lib/logger.js'; const execAsync = promisify(exec); +const log = childLogger('deployments/orchestrator'); export async function triggerDeployment(service: Service, triggeredBy: string): Promise { // Create deployment record @@ -20,7 +22,7 @@ export async function triggerDeployment(service: Service, triggeredBy: string): // Trigger bash script asynchronously runDeploymentScript(service, deploymentId).catch(error => { - console.error(`Deployment ${deploymentId} failed:`, error); + log.error({ err: error, deploymentId, serviceId: service.id }, 'background deployment failed'); }); return deploymentId; @@ -81,7 +83,10 @@ async function runDeploymentScript(service: Service, deploymentId: string) { ...(version ? { version } : {}), }); } catch (updateError) { - console.error(`Failed to persist final deployment status for ${deploymentId}:`, updateError); + log.error( + { err: updateError, deploymentId, finalStatus }, + 'failed to persist final deployment status', + ); } } } diff --git a/dashboard/backend/src/modules/system/repository.ts b/dashboard/backend/src/modules/system/repository.ts index 1e6d7cf..ebe90a0 100644 --- a/dashboard/backend/src/modules/system/repository.ts +++ b/dashboard/backend/src/modules/system/repository.ts @@ -1,8 +1,10 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import * as os from 'os'; +import { childLogger } from '../../lib/logger.js'; const execAsync = promisify(exec); +const log = childLogger('system/repository'); export async function getSystemMetrics() { const timestamp = new Date().toISOString(); @@ -43,7 +45,7 @@ export async function getSystemMetrics() { } } } catch (error) { - console.error('Failed to get disk info:', error); + log.error({ err: error }, 'failed to get disk info'); diskInfo = [{ path: '/', total: 0, @@ -122,7 +124,7 @@ export async function getDockerStats() { const volumeSizes = volumeSizeOutput.split('\n').filter(s => s).map(parseSize); volumes.size = volumeSizes[0] || 0; } catch (error) { - console.error('Failed to get Docker stats:', error); + log.error({ err: error }, 'failed to get docker stats'); } return { @@ -166,7 +168,7 @@ export async function dockerCleanup(type: string, force: boolean = false): Promi try { await execAsync(command); } catch (error) { - console.error(`Failed to execute ${command}:`, error); + log.error({ err: error, command }, 'docker cleanup command failed'); } } @@ -189,7 +191,7 @@ export async function dockerCleanup(type: string, force: boolean = false): Promi freedSpace = beforeSpace - afterSpace; } catch (error) { - console.error('Failed to calculate freed space:', error); + log.error({ err: error }, 'failed to calculate freed space'); } return { diff --git a/dashboard/backend/src/modules/vm/repository.ts b/dashboard/backend/src/modules/vm/repository.ts index dbabcc0..95932ac 100644 --- a/dashboard/backend/src/modules/vm/repository.ts +++ b/dashboard/backend/src/modules/vm/repository.ts @@ -2,8 +2,10 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { hostname } from 'os'; import { readFile } from 'fs/promises'; +import { childLogger } from '../../lib/logger.js'; const execAsync = promisify(exec); +const log = childLogger('vm/repository'); // Paths are env-configurable so they work both in the Docker container (via // volume mounts) and when the backend is run directly on the host for dev. @@ -448,7 +450,7 @@ export async function getAllContainers(): Promise { return a.name.localeCompare(b.name); }); } catch (err) { - console.error('getAllContainers failed:', err); + log.error({ err }, 'getAllContainers failed'); return []; } } diff --git a/dashboard/backend/src/server.ts b/dashboard/backend/src/server.ts index 53cb801..0e86d29 100644 --- a/dashboard/backend/src/server.ts +++ b/dashboard/backend/src/server.ts @@ -1,5 +1,6 @@ import Fastify from 'fastify'; import { config } from './lib/config.js'; +import { logger } from './lib/logger.js'; import { initializeContainers } from './lib/cosmos-init.js'; import { extractAuth, AuthError, requireAdmin } from './lib/auth.js'; import { generateCsrfToken, validateCsrfToken, getSessionId } from './lib/csrf.js'; @@ -19,8 +20,10 @@ import rateLimit from '@fastify/rate-limit'; import swagger from '@fastify/swagger'; import swaggerUi from '@fastify/swagger-ui'; +// Hand Fastify the shared pino instance so its per-request logs go through +// the same formatter + redaction config as everything else in the backend. const fastify = Fastify({ - logger: true, + loggerInstance: logger, }); // NOTE: there is no Server-Sent-Events log stream. `fastify-sse-v2 ^4` is not