| | | 1 | | using Serilog.Events; |
| | | 2 | | using Serilog.Formatting; |
| | | 3 | | using Serilog.Parsing; |
| | | 4 | | using System.Text; |
| | | 5 | | |
| | | 6 | | namespace FAU.Logica.Logging; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Formateador de logs en el formato estándar FAU: |
| | | 10 | | /// [TIMESTAMP] [LEVEL] [usuario] [contexto] Mensaje {datos} |
| | | 11 | | /// </summary> |
| | | 12 | | public class FauLogFormatter : ITextFormatter |
| | | 13 | | { |
| | 1 | 14 | | private static readonly HashSet<string> MetaProperties = new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 15 | | { |
| | 1 | 16 | | "SourceContext", "RequestId", "RequestPath", "ConnectionId", |
| | 1 | 17 | | "ActionId", "ActionName", "SpanId", "TraceId", "ParentId", |
| | 1 | 18 | | "MachineName", "ThreadId", "ProcessId", "ProcessName", "EventId", |
| | 1 | 19 | | // Propiedad de contexto explícita (mostrada en posición 4, no en datos) |
| | 1 | 20 | | "Context", |
| | 1 | 21 | | // IDs internos de auditoría (reemplazados por nombres descriptivos) |
| | 1 | 22 | | "usuario_id", "accion_id", "contexto_id", "host" |
| | 1 | 23 | | }; |
| | | 24 | | |
| | 1 | 25 | | private static readonly Dictionary<string, string> ContextMap = new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 26 | | { |
| | 1 | 27 | | // Servicios |
| | 1 | 28 | | ["PersonalService"] = "personal", |
| | 1 | 29 | | ["DependienteService"] = "personal", |
| | 1 | 30 | | ["UsuarioService"] = "usuarios", |
| | 1 | 31 | | ["RolService"] = "roles", |
| | 1 | 32 | | ["AuditoriaService"] = "auditoria", |
| | 1 | 33 | | ["LiquidacionService"] = "liquidacion", |
| | 1 | 34 | | ["NovedadPeriodoService"] = "liquidacion", |
| | 1 | 35 | | ["ParametroLiquidacionService"] = "liquidacion", |
| | 1 | 36 | | ["PeriodoService"] = "liquidacion", |
| | 1 | 37 | | ["PeriodoGuard"] = "liquidacion", |
| | 1 | 38 | | ["InsumoEvaluador"] = "liquidacion", |
| | 1 | 39 | | ["BeneficioSocialService"] = "beneficios-sociales", |
| | 1 | 40 | | ["TipoBeneficioService"] = "beneficios-sociales", |
| | 1 | 41 | | ["CompensacionService"] = "compensaciones", |
| | 1 | 42 | | ["BancoService"] = "bancos", |
| | 1 | 43 | | ["AuthService"] = "autenticacion", |
| | 1 | 44 | | // Controladores |
| | 1 | 45 | | ["PersonalController"] = "personal", |
| | 1 | 46 | | ["DependienteController"] = "personal", |
| | 1 | 47 | | ["UsuariosController"] = "usuarios", |
| | 1 | 48 | | ["RolesController"] = "roles", |
| | 1 | 49 | | ["AuditoriaController"] = "auditoria", |
| | 1 | 50 | | ["LiquidacionesController"] = "liquidacion", |
| | 1 | 51 | | ["NovedadesPeriodoController"] = "liquidacion", |
| | 1 | 52 | | ["ParametrosLiquidacionController"] = "liquidacion", |
| | 1 | 53 | | ["PeriodosController"] = "liquidacion", |
| | 1 | 54 | | ["BeneficiosSocialesController"] = "beneficios-sociales", |
| | 1 | 55 | | ["TiposBeneficioController"] = "beneficios-sociales", |
| | 1 | 56 | | ["CompensacionesController"] = "compensaciones", |
| | 1 | 57 | | ["BancosController"] = "bancos", |
| | 1 | 58 | | ["AuthController"] = "autenticacion", |
| | 1 | 59 | | ["CatalogoItemsController"] = "sistema", |
| | 1 | 60 | | ["RegimenesController"] = "sistema", |
| | 1 | 61 | | ["TestController"] = "sistema", |
| | 1 | 62 | | ["Auditoria"] = "sistema", |
| | 1 | 63 | | }; |
| | | 64 | | |
| | | 65 | | public void Format(LogEvent logEvent, TextWriter output) |
| | | 66 | | { |
| | | 67 | | // 1. Timestamp ISO 8601 UTC con milisegundos |
| | 37 | 68 | | var ts = logEvent.Timestamp.UtcDateTime; |
| | 37 | 69 | | output.Write('['); |
| | 37 | 70 | | output.Write(ts.ToString("yyyy-MM-ddTHH:mm:ss.fff")); |
| | 37 | 71 | | output.Write("Z]"); |
| | | 72 | | |
| | | 73 | | // 2. Level en mayúsculas |
| | 37 | 74 | | output.Write(" ["); |
| | 37 | 75 | | output.Write(logEvent.Level switch |
| | 37 | 76 | | { |
| | 1 | 77 | | LogEventLevel.Debug => "DEBUG", |
| | 32 | 78 | | LogEventLevel.Information => "INFO", |
| | 1 | 79 | | LogEventLevel.Warning => "WARN", |
| | 2 | 80 | | LogEventLevel.Error => "ERROR", |
| | 1 | 81 | | LogEventLevel.Fatal => "FATAL", |
| | 0 | 82 | | _ => logEvent.Level.ToString().ToUpperInvariant() |
| | 37 | 83 | | }); |
| | 37 | 84 | | output.Write(']'); |
| | | 85 | | |
| | | 86 | | // 3. Usuario (opcional - solo si está autenticado) |
| | 37 | 87 | | if (logEvent.Properties.TryGetValue("Username", out var userProp) && |
| | 37 | 88 | | userProp is ScalarValue { Value: string uname } && |
| | 37 | 89 | | !string.IsNullOrWhiteSpace(uname)) |
| | | 90 | | { |
| | 3 | 91 | | output.Write(" ["); |
| | 3 | 92 | | output.Write(uname); |
| | 3 | 93 | | output.Write(']'); |
| | | 94 | | } |
| | | 95 | | |
| | | 96 | | // 4. Contexto en kebab-case |
| | 37 | 97 | | output.Write(" ["); |
| | 37 | 98 | | output.Write(ResolveContext(logEvent)); |
| | 37 | 99 | | output.Write(']'); |
| | | 100 | | |
| | | 101 | | // 5. Mensaje |
| | 37 | 102 | | output.Write(' '); |
| | 37 | 103 | | logEvent.MessageTemplate.Render(logEvent.Properties, output); |
| | | 104 | | |
| | | 105 | | // 6. Datos extra (propiedades que no están en el template ni son meta) |
| | 37 | 106 | | var templateNames = GetTemplatePropertyNames(logEvent.MessageTemplate); |
| | 37 | 107 | | var extras = logEvent.Properties |
| | 32 | 108 | | .Where(kv => !MetaProperties.Contains(kv.Key) |
| | 32 | 109 | | && kv.Key != "Username" |
| | 32 | 110 | | && !templateNames.Contains(kv.Key)) |
| | 37 | 111 | | .ToList(); |
| | | 112 | | |
| | 37 | 113 | | if (extras.Count > 0) |
| | | 114 | | { |
| | 4 | 115 | | output.Write(" {"); |
| | 4 | 116 | | bool first = true; |
| | 18 | 117 | | foreach (var kv in extras) |
| | | 118 | | { |
| | | 119 | | // "detalle" se expande inline para evitar el wrapper {detalle: {...}} |
| | 5 | 120 | | if (kv.Key == "detalle" && kv.Value is StructureValue sv) |
| | | 121 | | { |
| | 12 | 122 | | foreach (var prop in sv.Properties) |
| | | 123 | | { |
| | 6 | 124 | | if (!first) output.Write(", "); |
| | 4 | 125 | | first = false; |
| | 4 | 126 | | output.Write(prop.Name); |
| | 4 | 127 | | output.Write(": "); |
| | 4 | 128 | | prop.Value.Render(output); |
| | | 129 | | } |
| | | 130 | | } |
| | | 131 | | else |
| | | 132 | | { |
| | 4 | 133 | | if (!first) output.Write(", "); |
| | 3 | 134 | | first = false; |
| | 3 | 135 | | output.Write(kv.Key); |
| | 3 | 136 | | output.Write(": "); |
| | 3 | 137 | | kv.Value.Render(output); |
| | | 138 | | } |
| | | 139 | | } |
| | 4 | 140 | | output.Write('}'); |
| | | 141 | | } |
| | | 142 | | |
| | | 143 | | // 7. Excepción |
| | 37 | 144 | | if (logEvent.Exception != null) |
| | | 145 | | { |
| | 1 | 146 | | output.WriteLine(); |
| | 1 | 147 | | output.Write(" "); |
| | 1 | 148 | | output.Write(logEvent.Exception.GetType().FullName); |
| | 1 | 149 | | output.Write(": "); |
| | 1 | 150 | | output.Write(logEvent.Exception.Message); |
| | 1 | 151 | | if (logEvent.Exception.StackTrace is { } st) |
| | | 152 | | { |
| | 0 | 153 | | output.WriteLine(); |
| | 0 | 154 | | output.Write(st); |
| | | 155 | | } |
| | | 156 | | } |
| | | 157 | | |
| | 37 | 158 | | output.WriteLine(); |
| | 37 | 159 | | } |
| | | 160 | | |
| | | 161 | | private static string ResolveContext(LogEvent logEvent) |
| | | 162 | | { |
| | | 163 | | // Prioridad 1: propiedad Context explícita (ej: auditoria con ContextoEnum) |
| | 37 | 164 | | if (logEvent.Properties.TryGetValue("Context", out var ctxProp) && |
| | 37 | 165 | | ctxProp is ScalarValue { Value: string explicitCtx } && |
| | 37 | 166 | | !string.IsNullOrWhiteSpace(explicitCtx)) |
| | | 167 | | { |
| | 2 | 168 | | return explicitCtx; |
| | | 169 | | } |
| | | 170 | | |
| | | 171 | | // Prioridad 2: SourceContext (nombre de la clase que loguea) |
| | 35 | 172 | | if (logEvent.Properties.TryGetValue("SourceContext", out var sc) && |
| | 35 | 173 | | sc is ScalarValue { Value: string sourceContext }) |
| | | 174 | | { |
| | 14 | 175 | | var className = sourceContext.Split('.').Last(); |
| | 14 | 176 | | if (ContextMap.TryGetValue(className, out var ctx)) |
| | 13 | 177 | | return ctx; |
| | | 178 | | |
| | | 179 | | // Fallback: remover sufijo y convertir a kebab-case |
| | 1 | 180 | | className = className |
| | 1 | 181 | | .Replace("Controller", "") |
| | 1 | 182 | | .Replace("Service", ""); |
| | 1 | 183 | | if (!string.IsNullOrEmpty(className)) |
| | 1 | 184 | | return ToKebabCase(className); |
| | | 185 | | } |
| | 21 | 186 | | return "sistema"; |
| | | 187 | | } |
| | | 188 | | |
| | | 189 | | private static string ToKebabCase(string pascal) |
| | | 190 | | { |
| | 1 | 191 | | if (string.IsNullOrEmpty(pascal)) return pascal; |
| | 1 | 192 | | var sb = new StringBuilder(); |
| | 28 | 193 | | for (int i = 0; i < pascal.Length; i++) |
| | | 194 | | { |
| | 13 | 195 | | if (i > 0 && char.IsUpper(pascal[i])) |
| | 1 | 196 | | sb.Append('-'); |
| | 13 | 197 | | sb.Append(char.ToLower(pascal[i])); |
| | | 198 | | } |
| | 1 | 199 | | return sb.ToString(); |
| | | 200 | | } |
| | | 201 | | |
| | | 202 | | private static HashSet<string> GetTemplatePropertyNames(MessageTemplate template) |
| | | 203 | | { |
| | 37 | 204 | | var names = new HashSet<string>(StringComparer.Ordinal); |
| | 152 | 205 | | foreach (var token in template.Tokens) |
| | | 206 | | { |
| | 39 | 207 | | if (token is PropertyToken pt) |
| | 2 | 208 | | names.Add(pt.PropertyName); |
| | | 209 | | } |
| | 37 | 210 | | return names; |
| | | 211 | | } |
| | | 212 | | } |