logs para averiaguar el fallo de subida de archivos desde la app

This commit is contained in:
abiandev
2026-07-29 12:59:29 +02:00
parent 73c1416f7a
commit c093c3f7c2
6 changed files with 219 additions and 25 deletions
+70
View File
@@ -55,6 +55,76 @@ app.use((req, res, next) => {
contentType.toLowerCase().startsWith('multipart/form-data'); contentType.toLowerCase().startsWith('multipart/form-data');
if (isMultipartFormData) { if (isMultipartFormData) {
const startedAt = process.hrtime.bigint();
const baseLogPayload = {
request_id: requestId,
method: req.method,
path: req.originalUrl || req.url,
ip: req.ip || null,
content_type: contentType,
content_length: Number.parseInt(req.get('content-length'), 10) || null,
has_authorization_header: Boolean(authorizationHeader),
user_agent: String(req.get('user-agent') || '').slice(0, 255) || null
};
let responseFinished = false;
appendPostLog({
event: 'upload_request_started',
...baseLogPayload
});
res.once('finish', () => {
responseFinished = true;
const parserStatus = req.uploadDiagnostics?.parser_status || 'not_reached';
let failureStage = null;
if (res.statusCode >= 400) {
if (parserStatus === 'rejected') {
failureStage = 'multipart_parser';
} else if (parserStatus === 'parsed') {
failureStage = 'controller_or_persistence';
} else {
failureStage = 'authentication_rate_limit_or_route';
}
}
appendPostLog({
event: 'upload_request_finished',
...baseLogPayload,
status_code: res.statusCode,
duration_ms: Number(
(Number(process.hrtime.bigint() - startedAt) / 1e6).toFixed(2)
),
request_complete: req.complete,
outcome: res.statusCode < 400 ? 'success' : 'error',
failure_stage: failureStage,
upload: req.uploadDiagnostics || {
parser_status: 'not_reached'
}
});
});
res.once('close', () => {
if (responseFinished) {
return;
}
appendPostLog({
event: 'upload_request_interrupted',
...baseLogPayload,
status_code: res.statusCode,
duration_ms: Number(
(Number(process.hrtime.bigint() - startedAt) / 1e6).toFixed(2)
),
request_complete: req.complete,
outcome: 'interrupted',
failure_stage: 'transport_or_client_disconnect',
upload: req.uploadDiagnostics || {
parser_status: 'not_reached'
}
});
});
return next(); return next();
} }
+22
View File
@@ -1,5 +1,10 @@
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_DRIVER_LICENSE_SIZE_BYTES = 5 * 1024 * 1024; const MAX_DRIVER_LICENSE_SIZE_BYTES = 5 * 1024 * 1024;
const FRONT_FILE_FIELD = 'carnet_conducir_frontal'; const FRONT_FILE_FIELD = 'carnet_conducir_frontal';
@@ -40,14 +45,31 @@ const internalUpload = multer({
}); });
const uploadDriverLicense = (req, res, next) => { const uploadDriverLicense = (req, res, next) => {
const flow = 'driver_license';
beginUploadParsing(req, flow);
internalUpload.fields([ internalUpload.fields([
{ name: FRONT_FILE_FIELD, maxCount: 1 }, { name: FRONT_FILE_FIELD, maxCount: 1 },
{ name: BACK_FILE_FIELD, maxCount: 1 } { name: BACK_FILE_FIELD, maxCount: 1 }
])(req, res, (error) => { ])(req, res, (error) => {
if (!error) { if (!error) {
const uploadedFiles = [
...(Array.isArray(req.files?.[FRONT_FILE_FIELD])
? req.files[FRONT_FILE_FIELD]
: []),
...(Array.isArray(req.files?.[BACK_FILE_FIELD])
? req.files[BACK_FILE_FIELD]
: [])
];
markUploadParsed(req, {
flow,
files: uploadedFiles
});
return next(); return next();
} }
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) { if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') { if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ return res.status(400).json({
+14
View File
@@ -2,6 +2,11 @@ const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_PROFILE_PHOTO_SIZE_BYTES = 5 * 1024 * 1024; const MAX_PROFILE_PHOTO_SIZE_BYTES = 5 * 1024 * 1024;
const PROFILE_UPLOADS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'profile'); const PROFILE_UPLOADS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'profile');
@@ -57,11 +62,20 @@ const internalUpload = multer({
}); });
const uploadProfilePhoto = (req, res, next) => { const uploadProfilePhoto = (req, res, next) => {
const flow = 'profile_photo';
beginUploadParsing(req, flow);
internalUpload.single('foto_perfil')(req, res, (error) => { internalUpload.single('foto_perfil')(req, res, (error) => {
if (!error) { if (!error) {
markUploadParsed(req, {
flow,
files: req.file ? [req.file] : []
});
return next(); return next();
} }
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) { if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') { if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Archivo demasiado grande. Maximo 5MB.' }); return res.status(400).json({ error: 'Archivo demasiado grande. Maximo 5MB.' });
+26 -15
View File
@@ -4,11 +4,15 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const { const {
getTripStatusUploadsDir, getTripStatusUploadsDir,
getTripStatusFallbackUploadsDir,
replicateUploadedFilesToRemote, replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles removeUploadedTripStatusFiles
} = require('../services/tripStatusPhotoStorage'); } = require('../services/tripStatusPhotoStorage');
const { appendPostLog } = require('../utils/postLog'); const { appendPostLog } = require('../utils/postLog');
const {
beginUploadParsing,
markUploadParsed,
markUploadRejected
} = require('../utils/uploadDiagnostics');
const MAX_TRIP_STATUS_PHOTO_SIZE_BYTES = 15 * 1024 * 1024; const MAX_TRIP_STATUS_PHOTO_SIZE_BYTES = 15 * 1024 * 1024;
const MAX_TRIP_STATUS_FILES = 5; const MAX_TRIP_STATUS_FILES = 5;
@@ -28,21 +32,9 @@ const getTripDirectorySegment = (req) => {
const getTripStatusUploadsTripDir = (req) => const getTripStatusUploadsTripDir = (req) =>
path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req)); path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req));
const getTripStatusFallbackUploadsTripDir = (req) =>
path.join(getTripStatusFallbackUploadsDir(), getTripDirectorySegment(req));
const ensureTripStatusUploadsDir = (req) => { const ensureTripStatusUploadsDir = (req) => {
const primaryTripDir = getTripStatusUploadsTripDir(req); fs.mkdirSync(getTripStatusUploadsTripDir(req), { recursive: true });
try {
fs.mkdirSync(primaryTripDir, { recursive: true });
return primaryTripDir;
} catch (primaryError) {
const fallbackTripDir = getTripStatusFallbackUploadsTripDir(req);
fs.mkdirSync(fallbackTripDir, { recursive: true });
return fallbackTripDir;
}
}; };
const getExtensionFromMimeType = (mimeType) => { const getExtensionFromMimeType = (mimeType) => {
@@ -68,7 +60,8 @@ const getExtensionFromMimeType = (mimeType) => {
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
try { try {
cb(null, ensureTripStatusUploadsDir(req)); ensureTripStatusUploadsDir(req);
cb(null, getTripStatusUploadsTripDir(req));
} catch (error) { } catch (error) {
cb(error); cb(error);
} }
@@ -96,6 +89,12 @@ const internalUpload = multer({
}); });
const uploadTripStatusPhotos = (req, res, next) => { const uploadTripStatusPhotos = (req, res, next) => {
const flow = 'trip_status_photos';
const storageMode = String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
.trim()
.toLowerCase();
beginUploadParsing(req, flow);
internalUpload.fields([ internalUpload.fields([
{ name: 'fotos', maxCount: MAX_TRIP_STATUS_FILES }, { name: 'fotos', maxCount: MAX_TRIP_STATUS_FILES },
{ name: 'fotos[]', maxCount: MAX_TRIP_STATUS_FILES } { name: 'fotos[]', maxCount: MAX_TRIP_STATUS_FILES }
@@ -104,6 +103,11 @@ const uploadTripStatusPhotos = (req, res, next) => {
const uploadedFiles = collectUploadedTripStatusFiles(req); const uploadedFiles = collectUploadedTripStatusFiles(req);
const authorizationHeader = req.get('authorization'); const authorizationHeader = req.get('authorization');
markUploadParsed(req, {
flow,
files: uploadedFiles,
storageMode
});
appendPostLog({ appendPostLog({
request_id: req.requestId || null, request_id: req.requestId || null,
method: req.method, method: req.method,
@@ -127,9 +131,16 @@ const uploadTripStatusPhotos = (req, res, next) => {
tripId: req.params?.id, tripId: req.params?.id,
files: uploadedFiles files: uploadedFiles
}); });
markUploadParsed(req, {
flow,
files: uploadedFiles,
storageMode
});
return next(); return next();
} }
markUploadRejected(req, { flow, error });
if (error instanceof multer.MulterError) { if (error instanceof multer.MulterError) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
+19 -10
View File
@@ -6,11 +6,6 @@ const DEFAULT_SFTP_PORT = 22;
let sftpClientFactoryOverride = null; let sftpClientFactoryOverride = null;
const PRIMARY_TRIP_STATUS_UPLOAD_DIR =
'/var/www/vhosts/gestion.abianservice.com/httpdocs/produccion/app/fotos_estado_react_native/trips/status';
const FALLBACK_TRIP_STATUS_UPLOAD_DIR =
path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status');
const resolveUploadDirCandidate = (uploadDir) => const resolveUploadDirCandidate = (uploadDir) =>
path.isAbsolute(uploadDir) path.isAbsolute(uploadDir)
? uploadDir ? uploadDir
@@ -42,10 +37,19 @@ const selectUploadDirCandidate = (uploadDirs) =>
.sort((left, right) => left.score - right.score || left.index - right.index)[0]?.path; .sort((left, right) => left.score - right.score || left.index - right.index)[0]?.path;
const getTripStatusUploadsDir = () => { const getTripStatusUploadsDir = () => {
return PRIMARY_TRIP_STATUS_UPLOAD_DIR; const configuredUploadDir = String(process.env.TRIP_STATUS_UPLOAD_DIR || '').trim();
};
const getTripStatusFallbackUploadsDir = () => FALLBACK_TRIP_STATUS_UPLOAD_DIR; if (configuredUploadDir) {
const configuredUploadDirs = configuredUploadDir
.split(';')
.map((uploadDir) => uploadDir.trim())
.filter(Boolean);
return selectUploadDirCandidate(configuredUploadDirs);
}
return path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status');
};
const getTripStatusPhotoStorageMode = () => const getTripStatusPhotoStorageMode = () =>
String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local') String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
@@ -205,7 +209,7 @@ const replicateUploadedFilesToRemote = async ({ tripId, files }) => {
file.tripStatusTripId = tripDirectorySegment; file.tripStatusTripId = tripDirectorySegment;
} }
await withSftpClient( const remoteOperationSucceeded = await withSftpClient(
async (client, sftpConfig) => { async (client, sftpConfig) => {
await ensureRemoteTripDirectory(client, { await ensureRemoteTripDirectory(client, {
remoteBaseDir: sftpConfig.remoteBaseDir, remoteBaseDir: sftpConfig.remoteBaseDir,
@@ -238,6 +242,12 @@ const replicateUploadedFilesToRemote = async ({ tripId, files }) => {
logContext: 'replicate_upload' logContext: 'replicate_upload'
} }
); );
if (shouldUseRemoteStorage() && !remoteOperationSucceeded) {
for (const file of normalizedFiles) {
file.tripStatusRemoteUploaded = false;
}
}
}; };
const removeRemoteFiles = async (remoteFilePaths, { logContext }) => { const removeRemoteFiles = async (remoteFilePaths, { logContext }) => {
@@ -389,7 +399,6 @@ const __resetSftpClientFactoryForTests = () => {
module.exports = { module.exports = {
getTripStatusUploadsDir, getTripStatusUploadsDir,
getTripStatusFallbackUploadsDir,
replicateUploadedFilesToRemote, replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles, removeUploadedTripStatusFiles,
removeStatusPhotosByName, removeStatusPhotosByName,
+68
View File
@@ -0,0 +1,68 @@
const path = require('path');
const getBodyFieldNames = (req) =>
Object.keys(req.body || {}).sort();
const getFileMetadata = (file) => {
const originalExtension = path.extname(String(file?.originalname || '')).toLowerCase();
let remoteUploadStatus = null;
if (file?.tripStatusRemoteUploaded === true) {
remoteUploadStatus = 'success';
} else if (file?.tripStatusRemoteUploaded === false) {
remoteUploadStatus = 'failed';
}
return {
field_name: file?.fieldname || null,
original_extension: originalExtension || null,
mimetype: file?.mimetype || null,
size_bytes: Number.isFinite(file?.size) ? file.size : null,
stored_filename: file?.filename || null,
local_file_created: Boolean(file?.path),
remote_upload_status: remoteUploadStatus
};
};
const beginUploadParsing = (req, flow) => {
req.uploadDiagnostics = {
flow,
parser: 'multer',
parser_status: 'started'
};
};
const markUploadParsed = (req, { flow, files, storageMode = null }) => {
const normalizedFiles = Array.isArray(files) ? files : [];
req.uploadDiagnostics = {
flow,
parser: 'multer',
parser_status: 'parsed',
body_fields: getBodyFieldNames(req),
file_count: normalizedFiles.length,
files: normalizedFiles.map(getFileMetadata),
storage_mode: storageMode
};
};
const markUploadRejected = (req, { flow, error }) => {
req.uploadDiagnostics = {
flow,
parser: 'multer',
parser_status: 'rejected',
body_fields: getBodyFieldNames(req),
error: {
type: error?.constructor?.name || 'Error',
code: error?.code || error?.message || null,
field_name: error?.field || null,
message: String(error?.message || 'Unknown upload error').slice(0, 500)
}
};
};
module.exports = {
beginUploadParsing,
markUploadParsed,
markUploadRejected
};