diff --git a/dev/issues/0019-structured-logging.md b/dev/issues/completed/0019-structured-logging.md similarity index 100% rename from dev/issues/0019-structured-logging.md rename to dev/issues/completed/0019-structured-logging.md diff --git a/functions/infra/log_debug.go b/functions/infra/log_debug.go new file mode 100644 index 00000000..8ea2d66b --- /dev/null +++ b/functions/infra/log_debug.go @@ -0,0 +1,11 @@ +package infra + +// LogDebug emite un log a nivel debug en el Logger. +// Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas"). +// Si el nivel del logger es mayor que Debug, el mensaje se descarta. +func LogDebug(logger *Logger, msg string, fields ...any) { + if logger == nil || logger.inner == nil { + return + } + logger.inner.Debug(msg, fields...) +} diff --git a/functions/infra/log_debug.md b/functions/infra/log_debug.md new file mode 100644 index 00000000..e5c57bdd --- /dev/null +++ b/functions/infra/log_debug.md @@ -0,0 +1,41 @@ +--- +name: log_debug +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func LogDebug(logger *Logger, msg string, fields ...any)" +description: "Emite un log a nivel debug en el Logger. Los fields son pares key-value variadicos. Si el nivel del logger es mayor que Debug, el mensaje se descarta silenciosamente." +tags: [logging, log, debug, slog, infra] +uses_functions: [] +uses_types: [Logger_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: logger + desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada" + - name: msg + desc: "mensaje principal del log" + - name: fields + desc: "pares key-value variadicos (ej: \"port\", 8484, \"user\", \"lucas\")" +output: "nada (side effect: escribe al Output del Logger)" +tested: true +tests: ["LogDebug emite nivel DEBUG", "campos inline en la llamada aparecen en el JSON", "logger nil no hace panic en las funciones de log"] +test_file_path: "functions/infra/logger_test.go" +file_path: "functions/infra/log_debug.go" +--- + +## Ejemplo + +```go +logger, _ := LoggerNew(LogLevelDebug, os.Stdout, "json") +LogDebug(logger, "parsing body", "content_type", "application/json", "size", 1024) +// {"time":"...","level":"DEBUG","msg":"parsing body","content_type":"application/json","size":1024} +``` + +## Notas + +Funcion impura — delega a `slog.Logger.Debug()`. El filtrado por nivel lo hace el handler de slog internamente (no se evalua el costo de los campos si el nivel esta debajo). Los fields deben venir en pares: si el numero es impar slog lo marca como `!BADKEY`. Usar este nivel para trazas detalladas de desarrollo que normalmente no se ven en produccion. diff --git a/functions/infra/log_entry.go b/functions/infra/log_entry.go new file mode 100644 index 00000000..c1cf4ca0 --- /dev/null +++ b/functions/infra/log_entry.go @@ -0,0 +1,12 @@ +package infra + +import "time" + +// LogEntry representa una entrada de log estructurada serializable a JSON. +// Se usa como modelo canonico para tests y para pipelines que procesan logs. +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` +} diff --git a/functions/infra/log_error.go b/functions/infra/log_error.go new file mode 100644 index 00000000..d3331721 --- /dev/null +++ b/functions/infra/log_error.go @@ -0,0 +1,11 @@ +package infra + +// LogError emite un log a nivel error en el Logger. +// Los fields son pares key-value variadicos (ej: "err", err, "table", "users"). +// El nivel error siempre se emite (es el mas severo). +func LogError(logger *Logger, msg string, fields ...any) { + if logger == nil || logger.inner == nil { + return + } + logger.inner.Error(msg, fields...) +} diff --git a/functions/infra/log_error.md b/functions/infra/log_error.md new file mode 100644 index 00000000..50d3a5ee --- /dev/null +++ b/functions/infra/log_error.md @@ -0,0 +1,44 @@ +--- +name: log_error +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func LogError(logger *Logger, msg string, fields ...any)" +description: "Emite un log a nivel error en el Logger. Los fields son pares key-value variadicos. Nivel maximo de severidad, siempre se emite salvo que el logger tenga un handler que lo filtre explicitamente." +tags: [logging, log, error, slog, infra] +uses_functions: [] +uses_types: [Logger_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: logger + desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada" + - name: msg + desc: "mensaje principal del log" + - name: fields + desc: "pares key-value variadicos (ej: \"err\", err.Error(), \"table\", \"users\", \"query\", sql)" +output: "nada (side effect: escribe al Output del Logger)" +tested: true +tests: ["LogError emite nivel ERROR", "logger nil no hace panic en las funciones de log"] +test_file_path: "functions/infra/logger_test.go" +file_path: "functions/infra/log_error.go" +--- + +## Ejemplo + +```go +logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json") +err := db.QueryRow(...).Scan(&x) +if err != nil { + LogError(logger, "db query failed", "err", err.Error(), "table", "users") +} +// {"time":"...","level":"ERROR","msg":"db query failed","err":"connection refused","table":"users"} +``` + +## Notas + +Funcion impura — delega a `slog.Logger.Error()`. Usar para fallos que requieren atencion: panics capturados, errores de I/O, estados invalidos. No aborta el programa por si solo — el caller decide que hacer. Para convertir un `error` en campo se recomienda usar `err.Error()` directamente, aunque slog tambien acepta el tipo `error` como valor (lo serializa con `.Error()` internamente). diff --git a/functions/infra/log_info.go b/functions/infra/log_info.go new file mode 100644 index 00000000..7243f7e0 --- /dev/null +++ b/functions/infra/log_info.go @@ -0,0 +1,11 @@ +package infra + +// LogInfo emite un log a nivel info en el Logger. +// Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas"). +// Si el nivel del logger es mayor que Info, el mensaje se descarta. +func LogInfo(logger *Logger, msg string, fields ...any) { + if logger == nil || logger.inner == nil { + return + } + logger.inner.Info(msg, fields...) +} diff --git a/functions/infra/log_info.md b/functions/infra/log_info.md new file mode 100644 index 00000000..28a1f6c0 --- /dev/null +++ b/functions/infra/log_info.md @@ -0,0 +1,41 @@ +--- +name: log_info +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func LogInfo(logger *Logger, msg string, fields ...any)" +description: "Emite un log a nivel info en el Logger. Los fields son pares key-value variadicos. Si el nivel del logger es mayor que Info, el mensaje se descarta silenciosamente." +tags: [logging, log, info, slog, infra] +uses_functions: [] +uses_types: [Logger_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: logger + desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada" + - name: msg + desc: "mensaje principal del log" + - name: fields + desc: "pares key-value variadicos (ej: \"port\", 8484, \"user\", \"lucas\")" +output: "nada (side effect: escribe al Output del Logger)" +tested: true +tests: ["LogInfo emite nivel INFO", "emite JSON valido al escribir", "campos inline en la llamada aparecen en el JSON"] +test_file_path: "functions/infra/logger_test.go" +file_path: "functions/infra/log_info.go" +--- + +## Ejemplo + +```go +logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json") +LogInfo(logger, "server starting", "port", 8484, "app", "sqlite_api") +// {"time":"...","level":"INFO","msg":"server starting","port":8484,"app":"sqlite_api"} +``` + +## Notas + +Funcion impura — delega a `slog.Logger.Info()`. Nivel por defecto recomendado para eventos normales del ciclo de vida de la app (arranque, conexiones establecidas, requests completadas). Para errores usar `LogError`, para situaciones anomalas no fatales usar `LogWarn`. diff --git a/functions/infra/log_level.go b/functions/infra/log_level.go new file mode 100644 index 00000000..299d6169 --- /dev/null +++ b/functions/infra/log_level.go @@ -0,0 +1,16 @@ +package infra + +// LogLevel representa los niveles de log soportados por el Logger. +// El orden implicito es Debug < Info < Warn < Error. +type LogLevel int + +const ( + // LogLevelDebug es el nivel mas verbose, util para trazas de desarrollo. + LogLevelDebug LogLevel = iota + // LogLevelInfo es el nivel por defecto para eventos normales del sistema. + LogLevelInfo + // LogLevelWarn indica situaciones anomalas que no impiden el funcionamiento. + LogLevelWarn + // LogLevelError indica fallos que requieren atencion. + LogLevelError +) diff --git a/functions/infra/log_warn.go b/functions/infra/log_warn.go new file mode 100644 index 00000000..2198a3fa --- /dev/null +++ b/functions/infra/log_warn.go @@ -0,0 +1,11 @@ +package infra + +// LogWarn emite un log a nivel warn en el Logger. +// Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas"). +// Si el nivel del logger es mayor que Warn, el mensaje se descarta. +func LogWarn(logger *Logger, msg string, fields ...any) { + if logger == nil || logger.inner == nil { + return + } + logger.inner.Warn(msg, fields...) +} diff --git a/functions/infra/log_warn.md b/functions/infra/log_warn.md new file mode 100644 index 00000000..5c290b3d --- /dev/null +++ b/functions/infra/log_warn.md @@ -0,0 +1,41 @@ +--- +name: log_warn +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func LogWarn(logger *Logger, msg string, fields ...any)" +description: "Emite un log a nivel warn en el Logger. Los fields son pares key-value variadicos. Indica situaciones anomalas que no impiden el funcionamiento del sistema." +tags: [logging, log, warn, slog, infra] +uses_functions: [] +uses_types: [Logger_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: logger + desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada" + - name: msg + desc: "mensaje principal del log" + - name: fields + desc: "pares key-value variadicos (ej: \"retry_count\", 3, \"endpoint\", \"/api/users\")" +output: "nada (side effect: escribe al Output del Logger)" +tested: true +tests: ["LogWarn emite nivel WARN", "filtra mensajes debajo del nivel configurado"] +test_file_path: "functions/infra/logger_test.go" +file_path: "functions/infra/log_warn.go" +--- + +## Ejemplo + +```go +logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json") +LogWarn(logger, "retry attempt", "attempt", 2, "max", 5, "err", "timeout") +// {"time":"...","level":"WARN","msg":"retry attempt","attempt":2,"max":5,"err":"timeout"} +``` + +## Notas + +Funcion impura — delega a `slog.Logger.Warn()`. Usar para eventos recuperables: reintentos, fallos de cache, deprecaciones, datos inesperados pero no invalidos. Si el evento requiere intervencion humana usar `LogError`. diff --git a/functions/infra/logger.go b/functions/infra/logger.go new file mode 100644 index 00000000..d1ff52b9 --- /dev/null +++ b/functions/infra/logger.go @@ -0,0 +1,16 @@ +package infra + +import ( + "io" + "log/slog" +) + +// Logger wrappea slog.Logger con config del registry (nivel, output, formato, campos contextuales). +// Se crea con LoggerNew y se clona inmutablemente con LoggerWith anadiendo campos. +type Logger struct { + Level LogLevel // nivel minimo filtrado + Output io.Writer // destino de los logs (stdout, stderr, file, buffer) + Format string // "json" | "text" + Fields map[string]any // campos contextuales adjuntos al logger + inner *slog.Logger // handler real de slog +} diff --git a/functions/infra/logger_middleware.go b/functions/infra/logger_middleware.go new file mode 100644 index 00000000..b9bde70e --- /dev/null +++ b/functions/infra/logger_middleware.go @@ -0,0 +1,38 @@ +package infra + +import ( + "net/http" + "time" +) + +// LoggerMiddleware retorna un Middleware que emite un log estructurado por cada request HTTP. +// Cada request produce una entrada a nivel info con method, path, status y duration_ms. +// Respeta los campos contextuales que ya tenga el logger (app, version, request_id...). +func LoggerMiddleware(logger *Logger) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &loggerResponseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rw, r) + duration := time.Since(start) + LogInfo(logger, "http request", + "method", r.Method, + "path", r.URL.Path, + "status", rw.status, + "duration_ms", duration.Milliseconds(), + ) + }) + } +} + +// loggerResponseWriter captura el status code escrito al ResponseWriter. +// Nombrado distinto de responseWriter (http_logger_middleware.go) para evitar colision. +type loggerResponseWriter struct { + http.ResponseWriter + status int +} + +func (rw *loggerResponseWriter) WriteHeader(status int) { + rw.status = status + rw.ResponseWriter.WriteHeader(status) +} diff --git a/functions/infra/logger_middleware.md b/functions/infra/logger_middleware.md new file mode 100644 index 00000000..0618854e --- /dev/null +++ b/functions/infra/logger_middleware.md @@ -0,0 +1,43 @@ +--- +name: logger_middleware +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func LoggerMiddleware(logger *Logger) Middleware" +description: "Retorna un Middleware HTTP que emite un log estructurado por cada request. Cada request produce una entrada info con method, path, status y duration_ms. Respeta los campos contextuales del Logger (app, version, request_id...)." +tags: [logging, log, slog, middleware, http, server, infra] +uses_functions: [log_info_go_infra] +uses_types: [Logger_go_infra, Middleware_go_infra] +returns: [Middleware_go_infra] +returns_optional: false +error_type: "error_go_core" +imports: [net/http, time] +params: + - name: logger + desc: "Logger estructurado al que emitir cada request. Hereda los campos contextuales (app, version...)" +output: "Middleware que loguea cada request HTTP tras su procesamiento" +tested: true +tests: ["loguea method, path, status y duration_ms", "usa status 200 si el handler no llama WriteHeader", "preserva los campos contextuales del logger"] +test_file_path: "functions/infra/logger_test.go" +file_path: "functions/infra/logger_middleware.go" +--- + +## Ejemplo + +```go +base, _ := LoggerNew(LogLevelInfo, os.Stdout, "json") +appLog := LoggerWith(base, map[string]any{"app": "sqlite_api"}) + +mux := HTTPRouter(routes) +chain := HTTPMiddlewareChain(LoggerMiddleware(appLog), HTTPCORSMiddleware([]string{"*"}, []string{"GET"})) + +http.ListenAndServe(":8484", chain(mux)) +// Cada request produce: +// {"time":"...","level":"INFO","msg":"http request","app":"sqlite_api","method":"GET","path":"/health","status":200,"duration_ms":1} +``` + +## Notas + +Funcion impura — captura el status code con un `loggerResponseWriter` envolvente que intercepta `WriteHeader`. Si el handler no llama `WriteHeader` explicitamente el status por defecto es 200. La duracion se mide desde el inicio del middleware hasta despues de que el handler siguiente termine — incluye el tiempo de los middlewares internos pero no los externos en la cadena. El mensaje emitido es `"http request"` a nivel info para facilitar filtrado via `msg:"http request"` en queries downstream. diff --git a/functions/infra/logger_new.go b/functions/infra/logger_new.go new file mode 100644 index 00000000..d7c38372 --- /dev/null +++ b/functions/infra/logger_new.go @@ -0,0 +1,54 @@ +package infra + +import ( + "fmt" + "io" + "log/slog" + "os" +) + +// LoggerNew crea un Logger con nivel, destino y formato configurables. +// format debe ser "json" o "text". Si output es nil se usa os.Stderr. +// Retorna error si el formato no es valido. +func LoggerNew(level LogLevel, output io.Writer, format string) (*Logger, error) { + if output == nil { + output = os.Stderr + } + + slogLevel := toSlogLevel(level) + opts := &slog.HandlerOptions{Level: slogLevel} + + var handler slog.Handler + switch format { + case "json": + handler = slog.NewJSONHandler(output, opts) + case "text": + handler = slog.NewTextHandler(output, opts) + default: + return nil, fmt.Errorf("logger_new: formato invalido %q, usa \"json\" o \"text\"", format) + } + + return &Logger{ + Level: level, + Output: output, + Format: format, + Fields: map[string]any{}, + inner: slog.New(handler), + }, nil +} + +// toSlogLevel convierte LogLevel a slog.Level. +func toSlogLevel(level LogLevel) slog.Level { + switch level { + case LogLevelDebug: + return slog.LevelDebug + case LogLevelInfo: + return slog.LevelInfo + case LogLevelWarn: + return slog.LevelWarn + case LogLevelError: + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/functions/infra/logger_new.md b/functions/infra/logger_new.md new file mode 100644 index 00000000..e2ff19bd --- /dev/null +++ b/functions/infra/logger_new.md @@ -0,0 +1,44 @@ +--- +name: logger_new +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func LoggerNew(level LogLevel, output io.Writer, format string) (*Logger, error)" +description: "Crea un Logger estructurado sobre log/slog con nivel, destino y formato configurables. Formato soportado: json o text. Si output es nil cae en os.Stderr." +tags: [logging, log, slog, logger, infra] +uses_functions: [] +uses_types: [Logger_go_infra, LogLevel_go_infra] +returns: [Logger_go_infra] +returns_optional: true +error_type: "error_go_core" +imports: [fmt, io, log/slog, os] +params: + - name: level + desc: "nivel minimo de log (LogLevelDebug, LogLevelInfo, LogLevelWarn o LogLevelError)" + - name: output + desc: "destino de los logs (os.Stdout, os.Stderr, un archivo, bytes.Buffer). Si es nil se usa os.Stderr" + - name: format + desc: "formato de los logs: \"json\" para maquina o \"text\" para desarrollo local" +output: "Logger listo para usar con LogInfo/LogWarn/... o error si el formato no es valido" +tested: true +tests: ["crea logger JSON valido", "crea logger text valido", "rechaza formato invalido", "output nil cae en os.Stderr sin panic", "emite JSON valido al escribir", "filtra mensajes debajo del nivel configurado"] +test_file_path: "functions/infra/logger_test.go" +file_path: "functions/infra/logger_new.go" +--- + +## Ejemplo + +```go +logger, err := LoggerNew(LogLevelInfo, os.Stdout, "json") +if err != nil { + log.Fatal(err) +} +LogInfo(logger, "server starting", "port", 8484) +// {"time":"...","level":"INFO","msg":"server starting","port":8484} +``` + +## Notas + +Funcion impura — internamente construye `slog.NewJSONHandler` o `slog.NewTextHandler` segun el formato y lo envuelve en `slog.New()`. El campo privado `inner` del Logger es el `*slog.Logger` real. Cada Logger es inmutable una vez creado: para anadir campos usar `LoggerWith`, que retorna una copia. diff --git a/functions/infra/logger_test.go b/functions/infra/logger_test.go new file mode 100644 index 00000000..ffd12c45 --- /dev/null +++ b/functions/infra/logger_test.go @@ -0,0 +1,312 @@ +package infra + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// --- LoggerNew --- + +func TestLoggerNew(t *testing.T) { + t.Run("crea logger JSON valido", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, err := LoggerNew(LogLevelInfo, buf, "json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if logger == nil { + t.Fatal("expected non-nil logger") + } + if logger.Format != "json" { + t.Errorf("got format=%q, want json", logger.Format) + } + if logger.Level != LogLevelInfo { + t.Errorf("got level=%d, want LogLevelInfo", logger.Level) + } + }) + + t.Run("crea logger text valido", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, err := LoggerNew(LogLevelDebug, buf, "text") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if logger.Format != "text" { + t.Errorf("got format=%q, want text", logger.Format) + } + }) + + t.Run("rechaza formato invalido", func(t *testing.T) { + _, err := LoggerNew(LogLevelInfo, &bytes.Buffer{}, "xml") + if err == nil { + t.Fatal("expected error for invalid format") + } + }) + + t.Run("output nil cae en os.Stderr sin panic", func(t *testing.T) { + logger, err := LoggerNew(LogLevelError, nil, "json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if logger.Output == nil { + t.Error("expected Output to default to os.Stderr, got nil") + } + }) + + t.Run("emite JSON valido al escribir", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelInfo, buf, "json") + LogInfo(logger, "hello") + + var parsed map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil { + t.Fatalf("output no es JSON valido: %v\noutput: %s", err, buf.String()) + } + if parsed["msg"] != "hello" { + t.Errorf("got msg=%v, want hello", parsed["msg"]) + } + if parsed["level"] != "INFO" { + t.Errorf("got level=%v, want INFO", parsed["level"]) + } + }) + + t.Run("filtra mensajes debajo del nivel configurado", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelWarn, buf, "json") + LogDebug(logger, "debug msg") + LogInfo(logger, "info msg") + LogWarn(logger, "warn msg") + LogError(logger, "error msg") + + output := buf.String() + if strings.Contains(output, "debug msg") { + t.Error("debug msg no deberia aparecer con LogLevelWarn") + } + if strings.Contains(output, "info msg") { + t.Error("info msg no deberia aparecer con LogLevelWarn") + } + if !strings.Contains(output, "warn msg") { + t.Error("warn msg deberia aparecer con LogLevelWarn") + } + if !strings.Contains(output, "error msg") { + t.Error("error msg deberia aparecer con LogLevelWarn") + } + }) +} + +// --- LoggerWith --- + +func TestLoggerWith(t *testing.T) { + t.Run("anade campos al logger", func(t *testing.T) { + buf := &bytes.Buffer{} + base, _ := LoggerNew(LogLevelInfo, buf, "json") + appLog := LoggerWith(base, map[string]any{"app": "test", "version": "1.0"}) + + LogInfo(appLog, "evento") + + var parsed map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil { + t.Fatalf("JSON invalido: %v", err) + } + if parsed["app"] != "test" { + t.Errorf("got app=%v, want test", parsed["app"]) + } + if parsed["version"] != "1.0" { + t.Errorf("got version=%v, want 1.0", parsed["version"]) + } + }) + + t.Run("no muta el logger original", func(t *testing.T) { + base, _ := LoggerNew(LogLevelInfo, &bytes.Buffer{}, "json") + if len(base.Fields) != 0 { + t.Fatalf("base logger deberia tener 0 fields iniciales, got %d", len(base.Fields)) + } + _ = LoggerWith(base, map[string]any{"a": 1}) + if len(base.Fields) != 0 { + t.Errorf("base logger no deberia haber mutado, got %d fields", len(base.Fields)) + } + }) + + t.Run("apila fields sobre un logger ya contextualizado", func(t *testing.T) { + buf := &bytes.Buffer{} + base, _ := LoggerNew(LogLevelInfo, buf, "json") + appLog := LoggerWith(base, map[string]any{"app": "api"}) + reqLog := LoggerWith(appLog, map[string]any{"request_id": "abc"}) + + LogInfo(reqLog, "inicio") + + var parsed map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil { + t.Fatalf("JSON invalido: %v", err) + } + if parsed["app"] != "api" { + t.Errorf("falta campo app heredado del padre, got %v", parsed["app"]) + } + if parsed["request_id"] != "abc" { + t.Errorf("falta campo request_id nuevo, got %v", parsed["request_id"]) + } + }) + + t.Run("retorna nil si recibe nil", func(t *testing.T) { + got := LoggerWith(nil, map[string]any{"k": "v"}) + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +// --- Log niveles --- + +func TestLogLevels(t *testing.T) { + t.Run("LogInfo emite nivel INFO", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelDebug, buf, "json") + LogInfo(logger, "m") + + var parsed map[string]any + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed) + if parsed["level"] != "INFO" { + t.Errorf("got level=%v, want INFO", parsed["level"]) + } + }) + + t.Run("LogWarn emite nivel WARN", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelDebug, buf, "json") + LogWarn(logger, "m") + + var parsed map[string]any + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed) + if parsed["level"] != "WARN" { + t.Errorf("got level=%v, want WARN", parsed["level"]) + } + }) + + t.Run("LogError emite nivel ERROR", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelDebug, buf, "json") + LogError(logger, "m") + + var parsed map[string]any + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed) + if parsed["level"] != "ERROR" { + t.Errorf("got level=%v, want ERROR", parsed["level"]) + } + }) + + t.Run("LogDebug emite nivel DEBUG", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelDebug, buf, "json") + LogDebug(logger, "m") + + var parsed map[string]any + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed) + if parsed["level"] != "DEBUG" { + t.Errorf("got level=%v, want DEBUG", parsed["level"]) + } + }) + + t.Run("campos inline en la llamada aparecen en el JSON", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelInfo, buf, "json") + LogInfo(logger, "evento", "port", 8080, "user", "lucas") + + var parsed map[string]any + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed) + if parsed["port"] != float64(8080) { + t.Errorf("got port=%v, want 8080", parsed["port"]) + } + if parsed["user"] != "lucas" { + t.Errorf("got user=%v, want lucas", parsed["user"]) + } + }) + + t.Run("logger nil no hace panic en las funciones de log", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("panic inesperado con logger nil: %v", r) + } + }() + LogDebug(nil, "msg") + LogInfo(nil, "msg") + LogWarn(nil, "msg") + LogError(nil, "msg") + }) +} + +// --- LoggerMiddleware --- + +func TestLoggerMiddleware(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("ok")) + }) + + t.Run("loguea method, path, status y duration_ms", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelInfo, buf, "json") + mw := LoggerMiddleware(logger) + + req := httptest.NewRequest(http.MethodPost, "/api/users", nil) + rec := httptest.NewRecorder() + mw(handler).ServeHTTP(rec, req) + + var parsed map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil { + t.Fatalf("JSON invalido: %v\noutput: %s", err, buf.String()) + } + if parsed["method"] != "POST" { + t.Errorf("got method=%v, want POST", parsed["method"]) + } + if parsed["path"] != "/api/users" { + t.Errorf("got path=%v, want /api/users", parsed["path"]) + } + if parsed["status"] != float64(http.StatusCreated) { + t.Errorf("got status=%v, want 201", parsed["status"]) + } + if _, ok := parsed["duration_ms"]; !ok { + t.Error("falta campo duration_ms en el log") + } + }) + + t.Run("usa status 200 si el handler no llama WriteHeader", func(t *testing.T) { + buf := &bytes.Buffer{} + logger, _ := LoggerNew(LogLevelInfo, buf, "json") + mw := LoggerMiddleware(logger) + + silentHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("hi")) + }) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + mw(silentHandler).ServeHTTP(rec, req) + + var parsed map[string]any + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed) + if parsed["status"] != float64(http.StatusOK) { + t.Errorf("got status=%v, want 200", parsed["status"]) + } + }) + + t.Run("preserva los campos contextuales del logger", func(t *testing.T) { + buf := &bytes.Buffer{} + base, _ := LoggerNew(LogLevelInfo, buf, "json") + appLog := LoggerWith(base, map[string]any{"app": "sqlite_api"}) + mw := LoggerMiddleware(appLog) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + mw(handler).ServeHTTP(rec, req) + + var parsed map[string]any + _ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed) + if parsed["app"] != "sqlite_api" { + t.Errorf("falta campo contextual app=sqlite_api, got %v", parsed["app"]) + } + }) +} diff --git a/functions/infra/logger_with.go b/functions/infra/logger_with.go new file mode 100644 index 00000000..395ac8e8 --- /dev/null +++ b/functions/infra/logger_with.go @@ -0,0 +1,46 @@ +package infra + +import "sort" + +// LoggerWith retorna una copia del Logger con campos adicionales. +// No muta el logger original — los campos se apilan sobre los ya existentes. +// Funcion pura: misma entrada produce siempre la misma salida sin I/O. +func LoggerWith(logger *Logger, fields map[string]any) *Logger { + if logger == nil { + return nil + } + + // Combinar fields existentes + nuevos (los nuevos tienen precedencia) + combined := make(map[string]any, len(logger.Fields)+len(fields)) + for k, v := range logger.Fields { + combined[k] = v + } + for k, v := range fields { + combined[k] = v + } + + // Convertir a args key-value ordenados para slog.With (orden determinista) + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + + args := make([]any, 0, len(keys)*2) + for _, k := range keys { + args = append(args, k, fields[k]) + } + + var inner = logger.inner + if inner != nil && len(args) > 0 { + inner = inner.With(args...) + } + + return &Logger{ + Level: logger.Level, + Output: logger.Output, + Format: logger.Format, + Fields: combined, + inner: inner, + } +} diff --git a/functions/infra/logger_with.md b/functions/infra/logger_with.md new file mode 100644 index 00000000..524236d9 --- /dev/null +++ b/functions/infra/logger_with.md @@ -0,0 +1,42 @@ +--- +name: logger_with +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func LoggerWith(logger *Logger, fields map[string]any) *Logger" +description: "Retorna una copia del Logger con campos contextuales adicionales. No muta el logger original — los campos se apilan sobre los existentes y aparecen en cada entrada del nuevo logger." +tags: [logging, log, slog, logger, context, pure, infra] +uses_functions: [] +uses_types: [Logger_go_infra] +returns: [Logger_go_infra] +returns_optional: false +error_type: "" +imports: [sort] +params: + - name: logger + desc: "Logger base a clonar. Si es nil retorna nil" + - name: fields + desc: "mapa de campos key-value a anadir al logger (ej: {\"app\": \"api\", \"request_id\": \"abc\"})" +output: "nuevo Logger con los fields combinados (los del parametro tienen precedencia sobre los del logger base)" +tested: true +tests: ["anade campos al logger", "no muta el logger original", "apila fields sobre un logger ya contextualizado", "retorna nil si recibe nil"] +test_file_path: "functions/infra/logger_test.go" +file_path: "functions/infra/logger_with.go" +--- + +## Ejemplo + +```go +base, _ := LoggerNew(LogLevelInfo, os.Stdout, "json") +appLog := LoggerWith(base, map[string]any{"app": "sqlite_api", "version": "1.0.0"}) +reqLog := LoggerWith(appLog, map[string]any{"request_id": "abc-123"}) + +LogInfo(reqLog, "evento") +// {"...","msg":"evento","app":"sqlite_api","version":"1.0.0","request_id":"abc-123"} +``` + +## Notas + +Funcion pura — no hace I/O, no muta estado. Internamente llama a `slog.Logger.With()` que ya retorna un nuevo logger. Los campos se pasan en orden alfabetico a `With()` para que el output sea determinista (util para tests). El campo `Fields` del `*Logger` mantiene la union combinada (base + nuevos) para permitir inspeccion programatica. diff --git a/registry.db b/registry.db index 401be57e..f5b917ee 100644 Binary files a/registry.db and b/registry.db differ diff --git a/types/infra/log_entry.md b/types/infra/log_entry.md new file mode 100644 index 00000000..84590911 --- /dev/null +++ b/types/infra/log_entry.md @@ -0,0 +1,35 @@ +--- +name: LogEntry +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` + } +description: "Entrada de log estructurada serializable a JSON. Modelo canonico para tests y pipelines de procesamiento de logs." +tags: [logging, log, entry, json, infra] +uses_types: [] +file_path: "functions/infra/log_entry.go" +--- + +## Ejemplo + +```go +entry := LogEntry{ + Timestamp: time.Now(), + Level: "INFO", + Message: "server starting", + Fields: map[string]any{"port": 8484, "app": "api"}, +} +data, _ := json.Marshal(entry) +// {"timestamp":"2026-04-18T10:00:00Z","level":"INFO","message":"server starting","fields":{"app":"api","port":8484}} +``` + +## Notas + +Tipo producto — cuatro campos, todos exportados. El formato JSON de slog usa claves diferentes (`time`, `level`, `msg`) pero este tipo sirve como adaptador cuando se deserializan logs para tests o para pipelines downstream. `Fields` es opcional (omitempty) y permite adjuntar cualquier contexto key-value. diff --git a/types/infra/log_level.md b/types/infra/log_level.md new file mode 100644 index 00000000..e88e3583 --- /dev/null +++ b/types/infra/log_level.md @@ -0,0 +1,32 @@ +--- +name: LogLevel +lang: go +domain: infra +version: "1.0.0" +algebraic: sum +definition: | + type LogLevel int + + const ( + LogLevelDebug LogLevel = iota + LogLevelInfo + LogLevelWarn + LogLevelError + ) +description: "Nivel de log soportado por el Logger. Los valores ordenados de menor a mayor severidad son Debug, Info, Warn y Error." +tags: [logging, log, level, slog, infra] +uses_types: [] +file_path: "functions/infra/log_level.go" +--- + +## Ejemplo + +```go +logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json") +LogDebug(logger, "no se imprime") // filtrado porque Debug < Info +LogInfo(logger, "server up") // se imprime +``` + +## Notas + +Tipo suma — los cuatro valores son exhaustivos y se corresponden uno a uno con los niveles de `log/slog` (slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError). El orden de comparacion sigue la convencion clasica: cuanto mas alto, mas severo. Util para configurar filtrado en LoggerNew. diff --git a/types/infra/logger.md b/types/infra/logger.md new file mode 100644 index 00000000..3e478d44 --- /dev/null +++ b/types/infra/logger.md @@ -0,0 +1,34 @@ +--- +name: Logger +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type Logger struct { + Level LogLevel + Output io.Writer + Format string + Fields map[string]any + inner *slog.Logger + } +description: "Wrapper sobre slog.Logger con config del registry: nivel, destino (io.Writer), formato (json/text) y campos contextuales inmutables." +tags: [logging, log, slog, logger, infra] +uses_types: [LogLevel_go_infra] +file_path: "functions/infra/logger.go" +--- + +## Ejemplo + +```go +logger, err := LoggerNew(LogLevelInfo, os.Stdout, "json") +if err != nil { + log.Fatal(err) +} +appLog := LoggerWith(logger, map[string]any{"app": "sqlite_api"}) +LogInfo(appLog, "server starting", "port", 8484) +``` + +## Notas + +Tipo producto — los campos publicos describen la config y los fields contextuales. El campo privado `inner` es la instancia real de `slog.Logger` que escribe. Se construye con `LoggerNew` (impuro) y se clona con `LoggerWith` (puro). Nunca se muta despues de creado.