auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4

Open
dataforge wants to merge 615 commits from auto/0129 into master
23 changed files with 935 additions and 0 deletions
Showing only changes of commit e96f8eaf6a - Show all commits
+11
View File
@@ -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...)
}
+41
View File
@@ -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.
+12
View File
@@ -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"`
}
+11
View File
@@ -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...)
}
+44
View File
@@ -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).
+11
View File
@@ -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...)
}
+41
View File
@@ -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`.
+16
View File
@@ -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
)
+11
View File
@@ -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...)
}
+41
View File
@@ -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`.
+16
View File
@@ -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
}
+38
View File
@@ -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)
}
+43
View File
@@ -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.
+54
View File
@@ -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
}
}
+44
View File
@@ -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.
+312
View File
@@ -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"])
}
})
}
+46
View File
@@ -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,
}
}
+42
View File
@@ -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
View File
Binary file not shown.
+35
View File
@@ -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.
+32
View File
@@ -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.
+34
View File
@@ -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.