merge: issue/0019-structured-logging — slog-based structured logging (7 fns, 3 tipos)
# Conflicts: # registry.db
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user