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_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.