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:
parent
c6ec1a06ea
commit
1e64d75fd4
@ -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
|
||||
|
||||
74
dashboard/backend/src/lib/logger.ts
Normal file
74
dashboard/backend/src/lib/logger.ts
Normal 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 });
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user