feat(08-01): Health monitoring & logging infrastructure

- Set up Winston structured logging with console and file outputs
- Create GET /api/health endpoint with uptime, database status, response times
- Add request logging middleware (method, path, statusCode, duration)
- Create health monitoring module with database connectivity checks
- Log all HTTP requests with timing information
- Log auth events (login, register) and data modifications
- Replace console.log/error with structured logger calls
- Update backend README with logging configuration documentation
- Add tests for health endpoint and logging middleware
- Logs directory: logs/combined.log and logs/error.log

Deliverables met:
✓ Structured logging (Winston) integrated
✓ Enhanced health endpoint with uptime & database info
✓ Request logging middleware attached to all routes
✓ Comprehensive logging documentation in README.md
✓ Tests passing for health and logging functionality
✓ All critical operations logged with context
This commit is contained in:
2026-03-03 21:28:46 +01:00
parent 1104f6360e
commit e09017d2e0
11 changed files with 867 additions and 114 deletions
+68 -30
View File
@@ -3,6 +3,9 @@ const cors = require('cors');
const { Pool } = require('pg');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const logger = require('./utils/logger');
const requestLoggerMiddleware = require('./middleware/requestLogger');
const { getHealthStatus, getUptime } = require('./utils/health');
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
const { createExerciseRecommendationRouter } = require('./routes/exerciseRecommendations');
const { searchExerciseResearch } = require('./services/exaSearch');
@@ -19,8 +22,11 @@ const pool = new Pool({
database: process.env.DB_NAME || 'gravl'
});
// Middleware setup
app.use(cors());
app.use(express.json());
app.use(requestLoggerMiddleware); // Add request logging middleware
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
app.use('/api/exercises', createExerciseRecommendationRouter());
@@ -33,8 +39,21 @@ const authMiddleware = (req, res, next) => {
} catch { res.status(401).json({ error: 'Invalid token' }); }
};
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
// Enhanced health endpoint with uptime and database status
app.get('/api/health', async (req, res) => {
try {
const health = await getHealthStatus(pool);
const statusCode = health.status === 'healthy' ? 200 : (health.status === 'degraded' ? 200 : 503);
res.status(statusCode).json(health);
} catch (err) {
logger.error('Health check error', { error: err.message });
res.status(503).json({
status: 'unhealthy',
uptime: getUptime(),
timestamp: new Date().toISOString(),
error: 'Health check failed'
});
}
});
app.post('/api/auth/register', async (req, res) => {
@@ -47,10 +66,14 @@ app.post('/api/auth/register', async (req, res) => {
[email.toLowerCase(), hash]
);
const token = jwt.sign({ id: result.rows[0].id, email: result.rows[0].email }, JWT_SECRET, { expiresIn: '30d' });
logger.info('User registered', { userId: result.rows[0].id, email: result.rows[0].email });
res.json({ token, user: result.rows[0] });
} catch (err) {
if (err.code === '23505') return res.status(400).json({ error: 'Email already exists' });
console.error('Register error:', err);
if (err.code === '23505') {
logger.warn('Registration failed - email exists', { email: req.body.email });
return res.status(400).json({ error: 'Email already exists' });
}
logger.error('Register error', { error: err.message });
res.status(500).json({ error: 'Server error' });
}
});
@@ -59,15 +82,22 @@ app.post('/api/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);
if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' });
if (!result.rows.length) {
logger.warn('Login failed - user not found', { email });
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
if (!valid) {
logger.warn('Login failed - invalid password', { userId: user.id });
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' });
const { password_hash, ...safeUser } = user;
logger.info('User logged in', { userId: user.id, email: user.email });
res.json({ token, user: safeUser });
} catch (err) {
console.error('Login error:', err);
logger.error('Login error', { error: err.message });
res.status(500).json({ error: 'Server error' });
}
});
@@ -100,7 +130,7 @@ app.get('/api/user/profile', authMiddleware, async (req, res) => {
strength: strResult.rows[0] || null
});
} catch (err) {
console.error('Profile error:', err);
logger.error('Profile error', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Server error' });
}
});
@@ -115,9 +145,10 @@ app.put('/api/user/profile', authMiddleware, async (req, res) => {
WHERE id=$8 RETURNING id, email, gender, age, height_cm, experience_level, goal, workouts_per_week, onboarding_complete`,
[gender, num(age), num(height_cm), experience_level, goal, num(workouts_per_week), onboarding_complete, req.user.id]
);
logger.info('User profile updated', { userId: req.user.id });
res.json(result.rows[0]);
} catch (err) {
console.error('Update profile error:', err);
logger.error('Update profile error', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Server error' });
}
});
@@ -133,9 +164,10 @@ app.post('/api/user/measurements', authMiddleware, async (req, res) => {
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[req.user.id, num(weight), num(neck_cm), num(waist_cm), num(hip_cm), num(body_fat_pct)]
);
logger.info('Measurements added', { userId: req.user.id });
res.json(result.rows[0]);
} catch (err) {
console.error('Add measurements error:', err);
logger.error('Add measurements error', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Server error' });
}
});
@@ -149,7 +181,7 @@ app.get('/api/user/measurements', authMiddleware, async (req, res) => {
);
res.json(result.rows);
} catch (err) {
console.error('Get measurements error:', err);
logger.error('Get measurements error', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Server error' });
}
});
@@ -165,9 +197,10 @@ app.post('/api/user/strength', authMiddleware, async (req, res) => {
VALUES ($1, $2, $3, $4) RETURNING *`,
[req.user.id, num(bench_1rm), num(squat_1rm), num(deadlift_1rm)]
);
logger.info('Strength record added', { userId: req.user.id });
res.json(result.rows[0]);
} catch (err) {
console.error('Add strength error:', err);
logger.error('Add strength error', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Server error' });
}
});
@@ -181,7 +214,7 @@ app.get('/api/user/strength', authMiddleware, async (req, res) => {
);
res.json(result.rows);
} catch (err) {
console.error('Get strength error:', err);
logger.error('Get strength error', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Server error' });
}
});
@@ -192,7 +225,7 @@ app.get('/api/programs', async (req, res) => {
const result = await pool.query('SELECT * FROM programs ORDER BY id');
res.json(result.rows);
} catch (err) {
console.error('Error fetching programs:', err);
logger.error('Error fetching programs', { error: err.message });
res.status(500).json({ error: 'Database error' });
}
});
@@ -230,7 +263,7 @@ app.get('/api/programs/:id', async (req, res) => {
days: days.rows
});
} catch (err) {
console.error('Error fetching program:', err);
logger.error('Error fetching program', { error: err.message, programId: req.params.id });
res.status(500).json({ error: 'Database error' });
}
});
@@ -248,7 +281,7 @@ app.get('/api/days/:dayId/exercises', async (req, res) => {
`, [req.params.dayId]);
res.json(result.rows);
} catch (err) {
console.error('Error fetching exercises:', err);
logger.error('Error fetching exercises', { error: err.message, dayId: req.params.dayId });
res.status(500).json({ error: 'Database error' });
}
});
@@ -276,7 +309,7 @@ app.get('/api/exercises/:id/alternatives', async (req, res) => {
res.json(alternatives.rows);
} catch (err) {
console.error('Error fetching alternatives:', err);
logger.error('Error fetching alternatives', { error: err.message, exerciseId: req.params.id });
res.status(500).json({ error: 'Database error' });
}
});
@@ -303,7 +336,7 @@ app.get('/api/exercises/:id/last-workout', async (req, res) => {
`, [req.params.id, user_id || 1]);
res.json(result.rows);
} catch (err) {
console.error('Error fetching last workout for exercise:', err);
logger.error('Error fetching last workout for exercise', { error: err.message, exerciseId: req.params.id });
res.status(500).json({ error: 'Database error' });
}
});
@@ -357,7 +390,7 @@ app.get('/api/progression/:programExerciseId', async (req, res) => {
reason: 'Keep same weight until you hit max reps on all sets'
});
} catch (err) {
console.error('Error calculating progression:', err);
logger.error('Error calculating progression', { error: err.message, programExerciseId: req.params.programExerciseId });
res.status(500).json({ error: 'Database error' });
}
});
@@ -394,14 +427,14 @@ app.get('/api/today/:programId', async (req, res) => {
days: days.rows
});
} catch (err) {
console.error('Error fetching today workout:', err);
logger.error('Error fetching today workout', { error: err.message, programId: req.params.programId });
res.status(500).json({ error: 'Database error' });
}
});
if (require.main === module) {
app.listen(PORT, '0.0.0.0', () => {
console.log(`Gravl API running on port ${PORT}`);
logger.info(`Gravl API started`, { port: PORT, environment: process.env.NODE_ENV || 'development' });
});
}
@@ -417,7 +450,7 @@ app.get('/api/exercises', async (req, res) => {
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching exercises:', err);
logger.error('Error fetching exercises', { error: err.message });
res.status(500).json({ error: 'Database error' });
}
});
@@ -464,6 +497,7 @@ app.post('/api/custom-workouts', authMiddleware, async (req, res) => {
);
await client.query('COMMIT');
logger.info('Custom workout created', { userId: user_id, workoutId: customWorkout.id });
res.json({
...customWorkout,
@@ -471,7 +505,7 @@ app.post('/api/custom-workouts', authMiddleware, async (req, res) => {
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error creating custom workout:', err);
logger.error('Error creating custom workout', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Database error' });
} finally {
client.release();
@@ -493,7 +527,7 @@ app.get('/api/custom-workouts', authMiddleware, async (req, res) => {
);
res.json(result.rows);
} catch (err) {
console.error('Error fetching custom workouts:', err);
logger.error('Error fetching custom workouts', { error: err.message, userId: req.user.id });
res.status(500).json({ error: 'Database error' });
}
});
@@ -536,7 +570,7 @@ app.get('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
exercises: exercisesResult.rows
});
} catch (err) {
console.error('Error fetching custom workout:', err);
logger.error('Error fetching custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
res.status(500).json({ error: 'Database error' });
}
});
@@ -596,6 +630,7 @@ app.put('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
}
await client.query('COMMIT');
logger.info('Custom workout updated', { userId: user_id, workoutId: workout_id });
// Fetch and return updated workout
const updatedResult = await pool.query(
@@ -622,7 +657,7 @@ app.put('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Error updating custom workout:', err);
logger.error('Error updating custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
res.status(500).json({ error: 'Database error' });
} finally {
client.release();
@@ -644,9 +679,10 @@ app.delete('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
return res.status(404).json({ error: 'Custom workout not found' });
}
logger.info('Custom workout deleted', { userId: user_id, workoutId: workout_id });
res.json({ deleted: result.rows[0].id });
} catch (err) {
console.error('Error deleting custom workout:', err);
logger.error('Error deleting custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
res.status(500).json({ error: 'Database error' });
}
});
@@ -684,7 +720,7 @@ app.get('/api/logs', async (req, res) => {
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) {
console.error('Error fetching logs:', err);
logger.error('Error fetching logs', { error: err.message });
res.status(500).json({ error: 'Database error' });
}
});
@@ -733,9 +769,10 @@ app.post('/api/logs', async (req, res) => {
);
}
logger.debug('Workout set logged', { userId: user_id, exerciseId: exerciseRef, weight, reps });
res.json(result.rows[0]);
} catch (err) {
console.error('Error logging set:', err);
logger.error('Error logging set', { error: err.message });
res.status(500).json({ error: 'Database error' });
}
});
@@ -764,9 +801,10 @@ app.delete('/api/logs', async (req, res) => {
return res.status(404).json({ error: 'Log not found' });
}
logger.info('Workout log deleted', { userId: user_id, date, setNumber: set_number });
res.json({ deleted: result.rows[0].id });
} catch (err) {
console.error('Error deleting log:', err);
logger.error('Error deleting log', { error: err.message });
res.status(500).json({ error: 'Database error' });
}
});
+33
View File
@@ -0,0 +1,33 @@
const logger = require('../utils/logger');
/**
* Request Logging Middleware
* Logs HTTP method, path, status code, and request duration
*/
function requestLoggerMiddleware(req, res, next) {
const startTime = Date.now();
const originalSend = res.send;
// Override send method to capture response
res.send = function (data) {
const duration = Date.now() - startTime;
const statusCode = res.statusCode;
// Log request details
logger.info('HTTP Request', {
method: req.method,
path: req.path,
statusCode: statusCode,
duration: `${duration}ms`,
ip: req.ip,
userAgent: req.get('user-agent')
});
// Call original send method
return originalSend.call(this, data);
};
next();
}
module.exports = requestLoggerMiddleware;
+58
View File
@@ -0,0 +1,58 @@
const { Pool } = require('pg');
const logger = require('./logger');
/**
* Health Monitoring Module
* Tracks application health metrics including uptime and database connectivity
*/
const startTime = Date.now();
/**
* Get application health status
* @returns {Object} Health status object with status, uptime, and timestamp
*/
async function getHealthStatus(pool) {
try {
// Check database connectivity
const dbHealthStart = Date.now();
const dbResult = await pool.query('SELECT NOW()');
const dbHealthDuration = Date.now() - dbHealthStart;
const dbHealthy = dbResult.rows.length > 0;
return {
status: dbHealthy ? 'healthy' : 'degraded',
uptime: Math.floor((Date.now() - startTime) / 1000), // uptime in seconds
timestamp: new Date().toISOString(),
database: {
connected: dbHealthy,
responseTime: `${dbHealthDuration}ms`
}
};
} catch (err) {
logger.error('Health check failed', { error: err.message });
return {
status: 'unhealthy',
uptime: Math.floor((Date.now() - startTime) / 1000),
timestamp: new Date().toISOString(),
database: {
connected: false,
error: err.message
}
};
}
}
/**
* Get uptime in seconds since application start
* @returns {number} Uptime in seconds
*/
function getUptime() {
return Math.floor((Date.now() - startTime) / 1000);
}
module.exports = {
getHealthStatus,
getUptime
};
+68
View File
@@ -0,0 +1,68 @@
const winston = require('winston');
const path = require('path');
/**
* Winston Logger Configuration
* Structured logging for Gravl backend with console and file outputs
*/
const logDir = path.join(__dirname, '../../logs');
const env = process.env.NODE_ENV || 'development';
const isDev = env === 'development';
// Custom format for readable console output
const consoleFormat = winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(info => {
const { timestamp, level, message, ...meta } = info;
const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
return `${timestamp} [${level}] ${message} ${metaStr}`;
})
);
// JSON format for file logging
const fileFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
winston.format.json()
);
// Logger configuration
const logger = winston.createLogger({
level: isDev ? 'debug' : 'info',
format: fileFormat,
defaultMeta: { service: 'gravl-backend' },
transports: [
// Console transport with readable format
new winston.transports.Console({
format: consoleFormat
}),
// All logs to combined file
new winston.transports.File({
filename: path.join(logDir, 'combined.log'),
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// Error logs only
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
});
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
logger.error('Uncaught Exception', { error: err.message, stack: err.stack });
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', { promise, reason });
process.exit(1);
});
module.exports = logger;