feat(dashboard): Phase 5 P2 — structured pino logging with redaction

First half of Phase 5 P2 (the "structured backend logging" piece;
E2E-in-CI lands separately so the diff stays reviewable).

Adds `lib/logger.ts` exporting a singleton pino instance shared between
Fastify (via `loggerInstance`) and any non-request code path. One
configured logger across the backend means uniform formatting,
redaction, and log-level control:

  - LOG_LEVEL env knob (defaults: debug in non-prod, info in prod when
    NODE_ENV=production). Documented in `.env.example`.
  - Built-in redaction for Authorization / Cookie headers and the
    common secret-shaped field names (password, token, refreshToken,
    accessToken, csrfToken, JWT_SECRET, CSRF_SECRET, ENCRYPTION_KEY,
    COSMOS_KEY, AZURE_CLIENT_SECRET) so an accidental
    `req.log.info(req.body)` or `logger.error({ err, config }, …)`
    won't dump credentials. This is a backstop, not the primary
    defense — call sites should still avoid logging raw config/req.
  - JSON to stdout in every environment. Pipe through `pino-pretty`
    locally if you want pretty output; we deliberately don't bundle
    pino-pretty as a runtime dep.
  - `childLogger(module)` helper tags log lines with their origin so
    repositories/background workers don't have to repeat the module
    name on every line.

Sweeps the runtime `console.error` sites that lose request context
(deployment orchestrator background fire-and-forget, system docker
stats/cleanup, backup CRUD, vm getAllContainers) onto the structured
logger. CLI-only modules (`scripts/run-migrations.ts`,
`migrations/index.ts`, `cosmos-init.ts` startup, `azure-keyvault.ts`,
`config.ts` env warnings, `lib/migrations.ts` no-op message) keep
`console.*` for now — they run before Fastify is up and are queued for
a separate cleanup pass.

Tests, typecheck, lint (0 errors), build green. Coverage gate still
passing (≥95% lines on every gated file).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hermes VM 2026-05-30 07:18:44 +00:00
parent c6ec1a06ea
commit 1e64d75fd4
7 changed files with 106 additions and 12 deletions

View File

@ -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

View File

@ -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: <instance>` 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 });
}

View File

@ -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<Backup> {
@ -21,7 +24,7 @@ export async function createBackup(params: BackupParams = {}): Promise<Backup> {
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<Backup[]> {
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<void> {
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<void> {
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;
}
}

View File

@ -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<string> {
// 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',
);
}
}
}

View File

@ -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 {

View File

@ -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<ContainerInfo[]> {
return a.name.localeCompare(b.name);
});
} catch (err) {
console.error('getAllContainers failed:', err);
log.error({ err }, 'getAllContainers failed');
return [];
}
}

View File

@ -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